diff --git a/ruoyi-wcs-shadow/pom.xml b/ruoyi-wcs-shadow/pom.xml
index 1f8c096..8c99458 100644
--- a/ruoyi-wcs-shadow/pom.xml
+++ b/ruoyi-wcs-shadow/pom.xml
@@ -33,6 +33,14 @@
org.springframework.boot
spring-boot-starter-web
+
+ org.springframework.boot
+ spring-boot-starter-jdbc
+
+
+ com.microsoft.sqlserver
+ mssql-jdbc
+
org.springframework.boot
spring-boot-starter-test
diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/Ft546ShadowApplication.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/Ft546ShadowApplication.java
index b9f77bc..de14082 100644
--- a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/Ft546ShadowApplication.java
+++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/Ft546ShadowApplication.java
@@ -1,7 +1,9 @@
package com.ruoyi.shadow;
import com.ruoyi.shadow.config.ShadowKernelConfiguration;
+import com.ruoyi.shadow.config.ShadowEvidenceConfiguration;
import com.ruoyi.shadow.config.ShadowRuntimeConfiguration;
+import com.ruoyi.shadow.config.ShadowSourceConfiguration;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
@@ -17,7 +19,8 @@ import org.springframework.scheduling.annotation.EnableScheduling;
SecurityAutoConfiguration.class,
UserDetailsServiceAutoConfiguration.class
})
-@Import({ShadowRuntimeConfiguration.class, ShadowKernelConfiguration.class})
+@Import({ShadowRuntimeConfiguration.class, ShadowKernelConfiguration.class,
+ ShadowSourceConfiguration.class, ShadowEvidenceConfiguration.class})
@EnableScheduling
public class Ft546ShadowApplication {
public static void main(String[] args) {
diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowEvidenceConfiguration.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowEvidenceConfiguration.java
new file mode 100644
index 0000000..7653e63
--- /dev/null
+++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowEvidenceConfiguration.java
@@ -0,0 +1,40 @@
+package com.ruoyi.shadow.config;
+
+import com.zaxxer.hikari.HikariConfig;
+import com.zaxxer.hikari.HikariDataSource;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.datasource.DataSourceTransactionManager;
+
+import javax.sql.DataSource;
+
+@Configuration(proxyBeanMethods = false)
+public class ShadowEvidenceConfiguration {
+ @Bean(name = "shadowEvidenceDataSource", destroyMethod = "close")
+ public DataSource shadowEvidenceDataSource(ShadowProperties properties) {
+ HikariConfig config = ShadowSourceConfiguration.dataSourceConfig(
+ properties.getEvidence(), "ft546-shadow-evidence");
+ config.setReadOnly(false);
+ config.setMaximumPoolSize(2);
+ config.setMinimumIdle(0);
+ config.setConnectionTimeout(2_000L);
+ config.setInitializationFailTimeout(-1L);
+ return ShadowSourceConfiguration.lazyDataSource(config);
+ }
+
+ @Bean("shadowEvidenceJdbc")
+ public JdbcTemplate shadowEvidenceJdbc(
+ @Qualifier("shadowEvidenceDataSource") DataSource evidence) {
+ JdbcTemplate jdbc = new JdbcTemplate(evidence);
+ jdbc.setQueryTimeout(3);
+ return jdbc;
+ }
+
+ @Bean("shadowEvidenceTx")
+ public DataSourceTransactionManager shadowEvidenceTx(
+ @Qualifier("shadowEvidenceDataSource") DataSource evidence) {
+ return new DataSourceTransactionManager(evidence);
+ }
+}
diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowProperties.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowProperties.java
index 822c236..66b79b0 100644
--- a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowProperties.java
+++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowProperties.java
@@ -15,8 +15,14 @@ public class ShadowProperties {
public static final class JdbcEndpoint {
private String jdbcUrl;
+ private String username;
+ private String password;
public String getJdbcUrl() { return jdbcUrl; }
public void setJdbcUrl(String jdbcUrl) { this.jdbcUrl = jdbcUrl; }
+ 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; }
}
}
diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowRuntimeConfiguration.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowRuntimeConfiguration.java
index 9d2fc55..4d346bf 100644
--- a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowRuntimeConfiguration.java
+++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowRuntimeConfiguration.java
@@ -2,12 +2,20 @@ package com.ruoyi.shadow.config;
import com.ruoyi.shadow.startup.ShadowStartupGate;
import com.ruoyi.shadow.startup.ShadowStartupReport;
+import com.ruoyi.shadow.startup.ShadowDatabaseGate;
+import com.ruoyi.shadow.startup.SqlServerPermissionProbe;
+import com.ruoyi.shadow.startup.SqlServerShadowDatabaseGate;
import com.ruoyi.shadow.web.ShadowStatusController;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+import javax.sql.DataSource;
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(ShadowProperties.class)
@@ -17,11 +25,28 @@ public class ShadowRuntimeConfiguration {
return new ShadowStartupGate();
}
+ @Bean
+ public SqlServerPermissionProbe sqlServerPermissionProbe() {
+ return new SqlServerPermissionProbe();
+ }
+
+ @Bean
+ @ConditionalOnMissingBean(ShadowDatabaseGate.class)
+ public ShadowDatabaseGate shadowDatabaseGate(
+ @Qualifier("shadowSourceDataSource") DataSource source,
+ @Qualifier("shadowEvidenceDataSource") DataSource evidence,
+ @Qualifier("shadowEvidenceJdbc") JdbcTemplate evidenceJdbc,
+ SqlServerPermissionProbe permissionProbe) {
+ return new SqlServerShadowDatabaseGate(source, evidence,
+ evidenceJdbc, permissionProbe);
+ }
+
@Bean
public ShadowStartupReport shadowStartupReport(
ShadowStartupGate gate, ApplicationContext context,
- Environment environment, ShadowProperties properties) {
- return gate.verify(context, environment, properties);
+ Environment environment, ShadowProperties properties,
+ ShadowDatabaseGate databaseGate) {
+ return gate.verify(context, environment, properties, databaseGate);
}
@Bean
diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowSourceConfiguration.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowSourceConfiguration.java
new file mode 100644
index 0000000..92fdd51
--- /dev/null
+++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowSourceConfiguration.java
@@ -0,0 +1,58 @@
+package com.ruoyi.shadow.config;
+
+import com.zaxxer.hikari.HikariConfig;
+import com.zaxxer.hikari.HikariDataSource;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.datasource.DataSourceTransactionManager;
+
+import javax.sql.DataSource;
+
+@Configuration(proxyBeanMethods = false)
+public class ShadowSourceConfiguration {
+ @Bean(name = "shadowSourceDataSource", destroyMethod = "close")
+ public DataSource shadowSourceDataSource(ShadowProperties properties) {
+ HikariConfig config = dataSourceConfig(
+ properties.getSource(), "ft546-shadow-source");
+ config.setReadOnly(true);
+ config.setMaximumPoolSize(2);
+ config.setMinimumIdle(0);
+ config.setConnectionTimeout(2_000L);
+ config.setConnectionInitSql(
+ "SET TRANSACTION ISOLATION LEVEL READ COMMITTED");
+ config.setInitializationFailTimeout(-1L);
+ return lazyDataSource(config);
+ }
+
+ @Bean("shadowSourceJdbc")
+ public JdbcTemplate shadowSourceJdbc(
+ @Qualifier("shadowSourceDataSource") DataSource source) {
+ JdbcTemplate jdbc = new JdbcTemplate(source);
+ jdbc.setQueryTimeout(3);
+ return jdbc;
+ }
+
+ @Bean("shadowSourceTx")
+ public DataSourceTransactionManager shadowSourceTx(
+ @Qualifier("shadowSourceDataSource") DataSource source) {
+ return new SqlServerReadOnlyTransactionManager(source);
+ }
+
+ static HikariConfig dataSourceConfig(
+ ShadowProperties.JdbcEndpoint endpoint, String poolName) {
+ HikariConfig config = new HikariConfig();
+ config.setJdbcUrl(endpoint.getJdbcUrl());
+ config.setUsername(endpoint.getUsername());
+ config.setPassword(endpoint.getPassword());
+ config.setPoolName(poolName);
+ return config;
+ }
+
+ static HikariDataSource lazyDataSource(HikariConfig config) {
+ HikariDataSource dataSource = new HikariDataSource();
+ config.copyStateTo(dataSource);
+ return dataSource;
+ }
+}
diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/SqlServerReadOnlyTransactionManager.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/SqlServerReadOnlyTransactionManager.java
new file mode 100644
index 0000000..42fd7c2
--- /dev/null
+++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/SqlServerReadOnlyTransactionManager.java
@@ -0,0 +1,38 @@
+package com.ruoyi.shadow.config;
+
+import org.springframework.jdbc.datasource.DataSourceTransactionManager;
+import org.springframework.transaction.TransactionDefinition;
+
+import javax.sql.DataSource;
+import java.sql.Connection;
+
+/**
+ * Rejects write-capable transaction definitions without emitting the
+ * unsupported SQL Server statement {@code SET TRANSACTION READ ONLY}.
+ * Database permissions remain the authoritative write barrier.
+ */
+final class SqlServerReadOnlyTransactionManager
+ extends DataSourceTransactionManager {
+ SqlServerReadOnlyTransactionManager(DataSource dataSource) {
+ super(dataSource);
+ setEnforceReadOnly(true);
+ }
+
+ @Override
+ protected void doBegin(Object transaction,
+ TransactionDefinition definition) {
+ if (!definition.isReadOnly()) {
+ throw new IllegalStateException(
+ "SHADOW_SOURCE_TRANSACTION_MUST_BE_READ_ONLY");
+ }
+ super.doBegin(transaction, definition);
+ }
+
+ @Override
+ protected void prepareTransactionalConnection(
+ Connection connection, TransactionDefinition definition) {
+ // SQL Server JDBC treats Connection#setReadOnly as a hint and SQL
+ // Server does not support SET TRANSACTION READ ONLY. The source login
+ // is independently verified to have no production write permission.
+ }
+}
diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/PermissionCheckResult.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/PermissionCheckResult.java
new file mode 100644
index 0000000..fe9d35a
--- /dev/null
+++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/PermissionCheckResult.java
@@ -0,0 +1,22 @@
+package com.ruoyi.shadow.startup;
+
+public final class PermissionCheckResult {
+ private final boolean accepted;
+ private final String reasonCode;
+
+ private PermissionCheckResult(boolean accepted, String reasonCode) {
+ this.accepted = accepted;
+ this.reasonCode = reasonCode;
+ }
+
+ public static PermissionCheckResult accepted(String reasonCode) {
+ return new PermissionCheckResult(true, reasonCode);
+ }
+
+ public static PermissionCheckResult rejected(String reasonCode) {
+ return new PermissionCheckResult(false, reasonCode);
+ }
+
+ public boolean isAccepted() { return accepted; }
+ public String getReasonCode() { return reasonCode; }
+}
diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/ShadowDatabaseGate.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/ShadowDatabaseGate.java
new file mode 100644
index 0000000..136a12c
--- /dev/null
+++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/ShadowDatabaseGate.java
@@ -0,0 +1,11 @@
+package com.ruoyi.shadow.startup;
+
+import com.ruoyi.shadow.config.ShadowProperties;
+
+import java.util.Map;
+
+public interface ShadowDatabaseGate {
+ Map verify(ShadowProperties properties);
+
+ void persistChecks(Map checks);
+}
diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/ShadowDatabaseIdentity.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/ShadowDatabaseIdentity.java
new file mode 100644
index 0000000..b65e431
--- /dev/null
+++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/ShadowDatabaseIdentity.java
@@ -0,0 +1,132 @@
+package com.ruoyi.shadow.startup;
+
+import java.sql.Driver;
+import java.sql.DriverManager;
+import java.sql.DriverPropertyInfo;
+import java.sql.SQLException;
+import java.util.Locale;
+import java.util.Properties;
+
+public final class ShadowDatabaseIdentity {
+ private final String serverName;
+ private final String port;
+ private final String databaseName;
+ private final String username;
+
+ private ShadowDatabaseIdentity(String serverName, String port,
+ String databaseName, String username) {
+ this.serverName = normalize(serverName);
+ this.port = normalize(port == null || port.trim().isEmpty()
+ ? "1433" : port);
+ this.databaseName = normalize(databaseName);
+ this.username = normalize(username);
+ }
+
+ public static ShadowDatabaseIdentity fromJdbcUrl(
+ String jdbcUrl, String username) {
+ try {
+ Driver driver = DriverManager.getDriver(jdbcUrl);
+ DriverPropertyInfo[] properties = driver.getPropertyInfo(
+ jdbcUrl, new Properties());
+ String server = property(properties, "serverName");
+ String port = property(properties, "portNumber");
+ String database = property(properties, "databaseName");
+ if (server == null || server.trim().isEmpty()) {
+ throw failure("JDBC_SERVER_NAME_REQUIRED");
+ }
+ if (database == null || database.trim().isEmpty()) {
+ throw failure("JDBC_DATABASE_NAME_REQUIRED");
+ }
+ return new ShadowDatabaseIdentity(server, port, database,
+ username);
+ } catch (SQLException exception) {
+ throw new IllegalStateException("JDBC_IDENTITY_UNRESOLVED",
+ exception);
+ }
+ }
+
+ public static ShadowDatabaseIdentity fromActualDatabase(
+ String serverName, String databaseName, String username) {
+ if (serverName == null || serverName.trim().isEmpty()) {
+ throw failure("ACTUAL_DATABASE_SERVER_NAME_REQUIRED");
+ }
+ if (databaseName == null || databaseName.trim().isEmpty()) {
+ throw failure("ACTUAL_DATABASE_NAME_REQUIRED");
+ }
+ if (username == null || username.trim().isEmpty()) {
+ throw failure("ACTUAL_DATABASE_LOGIN_REQUIRED");
+ }
+ return new ShadowDatabaseIdentity(serverName, "server-reported",
+ databaseName, username);
+ }
+
+ public static void requirePhysicallyDistinct(
+ ShadowDatabaseIdentity source,
+ ShadowDatabaseIdentity evidence) {
+ if (source.physicalKey().equals(evidence.physicalKey())) {
+ throw failure("SOURCE_AND_EVIDENCE_DATABASE_MUST_DIFFER");
+ }
+ }
+
+ public static void requireDifferentLogins(
+ ShadowDatabaseIdentity source,
+ ShadowDatabaseIdentity evidence) {
+ if (source.username.equals(evidence.username)) {
+ throw failure("SOURCE_AND_EVIDENCE_LOGIN_MUST_DIFFER");
+ }
+ }
+
+ public void requireMatchesConfigured(String jdbcUrl, String configuredUser,
+ String side) {
+ ShadowDatabaseIdentity configured = fromJdbcUrl(jdbcUrl,
+ configuredUser);
+ if (!physicalKey().equals(configured.physicalKey())) {
+ throw failure(side + "_DATABASE_IDENTITY_MISMATCH");
+ }
+ if (!username.equals(configured.username)) {
+ throw failure(side + "_DATABASE_LOGIN_MISMATCH");
+ }
+ }
+
+ public void requireDatabaseAndLoginMatchConfigured(
+ String jdbcUrl, String configuredUser, String side) {
+ ShadowDatabaseIdentity configured = fromJdbcUrl(jdbcUrl,
+ configuredUser);
+ if (!databaseName.equals(configured.databaseName)) {
+ throw failure(side + "_DATABASE_IDENTITY_MISMATCH");
+ }
+ if (!username.equals(configured.username)) {
+ throw failure(side + "_DATABASE_LOGIN_MISMATCH");
+ }
+ }
+
+ private String physicalKey() {
+ return serverName + ':' + port + '/' + databaseName;
+ }
+
+ public boolean isSameServer(ShadowDatabaseIdentity other) {
+ return serverName.equals(other.serverName);
+ }
+
+ public String getDatabaseName() {
+ return databaseName;
+ }
+
+ private static String property(DriverPropertyInfo[] properties,
+ String name) {
+ for (DriverPropertyInfo property : properties) {
+ if (name.equalsIgnoreCase(property.name)) {
+ return property.value;
+ }
+ }
+ return null;
+ }
+
+ private static String normalize(String value) {
+ return value == null ? "" : value.trim().toLowerCase(Locale.ROOT);
+ }
+
+ private static IllegalStateException failure(String reasonCode) {
+ return new IllegalStateException(reasonCode);
+ }
+}
diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/ShadowStartupGate.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/ShadowStartupGate.java
index 026fa87..9916246 100644
--- a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/ShadowStartupGate.java
+++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/ShadowStartupGate.java
@@ -15,7 +15,8 @@ import java.util.Map;
public final class ShadowStartupGate {
public ShadowStartupReport verify(ApplicationContext context,
Environment environment,
- ShadowProperties properties) {
+ ShadowProperties properties,
+ ShadowDatabaseGate databaseGate) {
if (!properties.isEnabled()) {
throw failure("SHADOW_MUST_BE_EXPLICITLY_ENABLED");
}
@@ -27,6 +28,10 @@ public final class ShadowStartupGate {
"SHADOW_SOURCE_JDBC_URL_REQUIRED");
requireText(properties.getEvidence().getJdbcUrl(),
"SHADOW_EVIDENCE_JDBC_URL_REQUIRED");
+ requireText(properties.getSource().getUsername(),
+ "SHADOW_SOURCE_USERNAME_REQUIRED");
+ requireText(properties.getEvidence().getUsername(),
+ "SHADOW_EVIDENCE_USERNAME_REQUIRED");
requireLoopback(environment.getProperty("server.address",
"127.0.0.1"));
requireNoBeans(context, DeviceCommandGateway.class,
@@ -39,11 +44,12 @@ public final class ShadowStartupGate {
checks.put("SHADOW_PROFILE_ACTIVE", "PASS");
checks.put("SOURCE_JDBC_CONFIGURED", "PASS");
checks.put("EVIDENCE_JDBC_CONFIGURED", "PASS");
+ checks.put("DATABASE_USERNAMES_CONFIGURED", "PASS");
checks.put("STATUS_ADDRESS_LOOPBACK", "PASS");
checks.put("SIDE_EFFECT_BEANS_ABSENT", "PASS");
checks.put("FORBIDDEN_BEAN_PACKAGES_ABSENT", "PASS");
- checks.put("SOURCE_PERMISSIONS_VERIFIED", "PENDING");
- checks.put("EVIDENCE_DATABASE_VERIFIED", "PENDING");
+ checks.putAll(databaseGate.verify(properties));
+ databaseGate.persistChecks(checks);
return ShadowStartupReport.starting(Instant.now(), checks);
}
diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/SqlServerPermissionProbe.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/SqlServerPermissionProbe.java
new file mode 100644
index 0000000..e170eab
--- /dev/null
+++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/SqlServerPermissionProbe.java
@@ -0,0 +1,458 @@
+package com.ruoyi.shadow.startup;
+
+import java.sql.Connection;
+import java.sql.DatabaseMetaData;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+
+public class SqlServerPermissionProbe {
+ public static final List REQUIRED_SOURCE_VIEWS =
+ Collections.unmodifiableList(Arrays.asList(
+ "v_wcs_srgv_queue",
+ "v_wcs_srgv_queue_finish",
+ "v_device_srgv",
+ "v_wcs_srgv_map",
+ "v_bas_cargo_space",
+ "v_tsj_execution",
+ "v_current_database_clock"));
+
+ public static final List REQUIRED_EVIDENCE_TABLES =
+ Collections.unmodifiableList(Arrays.asList(
+ "shadow_run", "shadow_cycle", "shadow_input_batch",
+ "shadow_legacy_queue", "shadow_decision",
+ "shadow_encoded_write", "shadow_difference",
+ "shadow_traffic_state", "shadow_hold_history",
+ "shadow_startup_check"));
+
+ private static final List SOURCE_BASE_TABLES =
+ Arrays.asList("wcs_srgv_queue", "wcs_srgv_queue_finish",
+ "device_srgv", "wcs_srgv_map", "bas_cargo_space",
+ "tsj_device_execution_queue");
+ private static final List FORBIDDEN_DATABASE_PERMISSIONS =
+ Arrays.asList("SELECT", "INSERT", "UPDATE", "DELETE",
+ "EXECUTE", "ALTER", "CONTROL", "TAKE OWNERSHIP");
+ private static final List FORBIDDEN_DATABASE_ROLES =
+ Arrays.asList("db_owner", "db_datareader", "db_datawriter",
+ "db_ddladmin", "db_securityadmin", "db_accessadmin");
+ private static final List EVIDENCE_TABLE_PERMISSIONS =
+ Arrays.asList("SELECT", "INSERT", "UPDATE", "DELETE");
+ private static final String DATABASE_ROLE_MEMBERSHIP_SQL =
+ "SELECT role_principal.name AS role_name "
+ + "FROM sys.database_role_members rm "
+ + "JOIN sys.database_principals role_principal "
+ + "ON role_principal.principal_id = rm.role_principal_id "
+ + "JOIN sys.database_principals member_principal "
+ + "ON member_principal.principal_id = rm.member_principal_id "
+ + "WHERE member_principal.name = USER_NAME()";
+ private static final String EFFECTIVE_SECURABLE_PERMISSION_SQL =
+ "SELECT N'SCHEMA' AS scope_type, QUOTENAME(s.name) "
+ + "AS securable_name, permission_name "
+ + "FROM sys.schemas s CROSS APPLY "
+ + "sys.fn_my_permissions(QUOTENAME(s.name), 'SCHEMA') "
+ + "WHERE s.name NOT IN (N'sys', N'INFORMATION_SCHEMA') "
+ + "UNION ALL "
+ + "SELECT N'OBJECT' AS scope_type, "
+ + "QUOTENAME(s.name) + N'.' + QUOTENAME(o.name) "
+ + "AS securable_name, permission_name "
+ + "FROM sys.objects o JOIN sys.schemas s "
+ + "ON s.schema_id = o.schema_id CROSS APPLY "
+ + "sys.fn_my_permissions(QUOTENAME(s.name) + N'.' "
+ + "+ QUOTENAME(o.name), 'OBJECT') "
+ + "WHERE o.is_ms_shipped = 0";
+
+ public PermissionCheckResult verifySource(Connection connection)
+ throws SQLException {
+ requireSqlServer(connection);
+ connection.setReadOnly(true);
+ connection.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED);
+ try (Statement statement = connection.createStatement()) {
+ statement.setQueryTimeout(3);
+ try (ResultSet row = statement.executeQuery(sourcePermissionSql())) {
+ if (!row.next()) {
+ return PermissionCheckResult.rejected(
+ "SOURCE_PERMISSION_PROBE_RETURNED_NO_ROW");
+ }
+ for (String role : FORBIDDEN_DATABASE_ROLES) {
+ PermissionValue value = permissionValue(
+ row, "role_" + role);
+ if (!value.isResolved()) {
+ return unresolved(value);
+ }
+ if (value.getValue() != 0) {
+ return PermissionCheckResult.rejected(
+ "SOURCE_IS_MEMBER_OF_FORBIDDEN_ROLE:" + role);
+ }
+ }
+ PermissionValue sysadmin = permissionValue(
+ row, "role_sysadmin");
+ if (!sysadmin.isResolved()) {
+ return unresolved(sysadmin);
+ }
+ if (sysadmin.getValue() != 0) {
+ return PermissionCheckResult.rejected(
+ "SOURCE_IS_MEMBER_OF_FORBIDDEN_ROLE:sysadmin");
+ }
+ for (String operation : FORBIDDEN_DATABASE_PERMISSIONS) {
+ PermissionValue value = permissionValue(
+ row, alias(operation, "database"));
+ if (!value.isResolved()) {
+ return unresolved(value);
+ }
+ if (value.getValue() != 0) {
+ return PermissionCheckResult.rejected(
+ "SOURCE_HAS_" + reason(operation)
+ + "_PERMISSION");
+ }
+ }
+ for (String table : SOURCE_BASE_TABLES) {
+ PermissionValue select = permissionValue(
+ row, alias("SELECT", table));
+ if (!select.isResolved()) {
+ return unresolved(select);
+ }
+ if (select.getValue() != 0) {
+ return PermissionCheckResult.rejected(
+ "SOURCE_HAS_DIRECT_SELECT_PERMISSION:"
+ + table);
+ }
+ for (String operation : Arrays.asList(
+ "INSERT", "UPDATE", "DELETE", "ALTER", "CONTROL")) {
+ PermissionValue value = permissionValue(
+ row, alias(operation, table));
+ if (!value.isResolved()) {
+ return unresolved(value);
+ }
+ if (value.getValue() != 0) {
+ return PermissionCheckResult.rejected(
+ "SOURCE_HAS_" + reason(operation)
+ + "_PERMISSION:" + table);
+ }
+ }
+ }
+ for (String view : REQUIRED_SOURCE_VIEWS) {
+ PermissionValue value = permissionValue(
+ row, alias("SELECT", view));
+ if (!value.isResolved()) {
+ return unresolved(value);
+ }
+ if (value.getValue() != 1) {
+ return PermissionCheckResult.rejected(
+ "SOURCE_MISSING_SELECT_ON_VIEW:" + view);
+ }
+ }
+ }
+ for (String view : REQUIRED_SOURCE_VIEWS) {
+ try (ResultSet ignored = statement.executeQuery(
+ "SELECT TOP (0) * FROM [shadow_ro].[" + view + "]")) {
+ // Query compilation verifies that the approved view path is usable.
+ }
+ }
+ try (ResultSet permissions = statement.executeQuery(
+ "SELECT permission_name FROM fn_my_permissions(NULL, 'DATABASE')")) {
+ while (permissions.next()) {
+ String permission = permissions.getString("permission_name");
+ if (permission != null && FORBIDDEN_DATABASE_PERMISSIONS
+ .contains(permission.toUpperCase(Locale.ROOT))) {
+ return PermissionCheckResult.rejected(
+ "SOURCE_HAS_" + reason(permission)
+ + "_PERMISSION");
+ }
+ }
+ }
+ try (ResultSet permissions = statement.executeQuery(
+ EFFECTIVE_SECURABLE_PERMISSION_SQL)) {
+ while (permissions.next()) {
+ String scope = permissions.getString("scope_type");
+ String securable = permissions.getString(
+ "securable_name");
+ String permission = permissions.getString(
+ "permission_name");
+ if (!isApprovedRead(scope, securable, permission)) {
+ return PermissionCheckResult.rejected(
+ "SOURCE_HAS_EFFECTIVE_" + reason(permission)
+ + "_PERMISSION:" + scope + ':'
+ + securable);
+ }
+ }
+ }
+ try (ResultSet permissions = statement.executeQuery(
+ "SELECT permission_name FROM "
+ + "fn_my_permissions(NULL, 'SERVER')")) {
+ while (permissions.next()) {
+ String permission = permissions.getString(
+ "permission_name");
+ String normalized = reason(permission);
+ if (!"CONNECT_SQL".equals(normalized)
+ && !"VIEW_ANY_DATABASE".equals(normalized)) {
+ return PermissionCheckResult.rejected(
+ "SOURCE_HAS_EFFECTIVE_SERVER_PERMISSION:"
+ + normalized);
+ }
+ }
+ }
+ }
+ return PermissionCheckResult.accepted(
+ "SOURCE_PERMISSIONS_VERIFIED");
+ }
+
+ public PermissionCheckResult verifyEvidence(Connection connection)
+ throws SQLException {
+ requireSqlServer(connection);
+ connection.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED);
+ try (Statement statement = connection.createStatement()) {
+ statement.setQueryTimeout(3);
+ try (ResultSet row = statement.executeQuery(evidencePermissionSql())) {
+ if (!row.next()) {
+ return PermissionCheckResult.rejected(
+ "EVIDENCE_PERMISSION_PROBE_RETURNED_NO_ROW");
+ }
+ for (String table : REQUIRED_EVIDENCE_TABLES) {
+ for (String operation : Arrays.asList(
+ "SELECT", "INSERT", "UPDATE", "DELETE")) {
+ PermissionValue value = permissionValue(
+ row, alias(operation, table));
+ if (!value.isResolved()) {
+ return PermissionCheckResult.rejected(
+ "EVIDENCE_PERMISSION_PROBE_UNRESOLVED:"
+ + value.getAlias());
+ }
+ if (value.getValue() != 1) {
+ return PermissionCheckResult.rejected(
+ "EVIDENCE_MISSING_" + operation
+ + "_ON_TABLE:" + table);
+ }
+ }
+ }
+ PermissionValue sysadmin = permissionValue(
+ row, "role_sysadmin");
+ if (!sysadmin.isResolved()) {
+ return PermissionCheckResult.rejected(
+ "EVIDENCE_PERMISSION_PROBE_UNRESOLVED:role_sysadmin");
+ }
+ if (sysadmin.getValue() != 0) {
+ return PermissionCheckResult.rejected(
+ "EVIDENCE_IS_MEMBER_OF_SERVER_ROLE:sysadmin");
+ }
+ }
+ try (ResultSet roles = statement.executeQuery(
+ DATABASE_ROLE_MEMBERSHIP_SQL)) {
+ if (roles.next()) {
+ return PermissionCheckResult.rejected(
+ "EVIDENCE_IS_MEMBER_OF_DATABASE_ROLE:"
+ + roles.getString("role_name"));
+ }
+ }
+ try (ResultSet permissions = statement.executeQuery(
+ "SELECT permission_name FROM fn_my_permissions(NULL, 'DATABASE')")) {
+ while (permissions.next()) {
+ String permission = permissions.getString("permission_name");
+ if (!"CONNECT".equalsIgnoreCase(permission)) {
+ return PermissionCheckResult.rejected(
+ "EVIDENCE_HAS_EFFECTIVE_" + reason(permission)
+ + "_DATABASE_PERMISSION");
+ }
+ }
+ }
+ try (ResultSet permissions = statement.executeQuery(
+ EFFECTIVE_SECURABLE_PERMISSION_SQL)) {
+ while (permissions.next()) {
+ String scope = permissions.getString("scope_type");
+ String securable = permissions.getString(
+ "securable_name");
+ String permission = permissions.getString(
+ "permission_name");
+ if (!isApprovedEvidencePermission(
+ scope, securable, permission)) {
+ return PermissionCheckResult.rejected(
+ "EVIDENCE_HAS_EFFECTIVE_" + reason(permission)
+ + "_PERMISSION:" + scope + ':'
+ + securable);
+ }
+ }
+ }
+ try (ResultSet permissions = statement.executeQuery(
+ "SELECT permission_name FROM "
+ + "fn_my_permissions(NULL, 'SERVER')")) {
+ while (permissions.next()) {
+ String permission = permissions.getString(
+ "permission_name");
+ String normalized = reason(permission);
+ if (!"CONNECT_SQL".equals(normalized)
+ && !"VIEW_ANY_DATABASE".equals(normalized)) {
+ return PermissionCheckResult.rejected(
+ "EVIDENCE_HAS_EFFECTIVE_SERVER_PERMISSION:"
+ + normalized);
+ }
+ }
+ }
+ }
+ return PermissionCheckResult.accepted(
+ "EVIDENCE_DATABASE_VERIFIED");
+ }
+
+ private static String sourcePermissionSql() {
+ StringBuilder sql = new StringBuilder("SELECT ");
+ Set expressions = new LinkedHashSet();
+ for (String role : FORBIDDEN_DATABASE_ROLES) {
+ expressions.add("IS_MEMBER(N'" + role + "') AS role_" + role);
+ }
+ expressions.add("IS_SRVROLEMEMBER(N'sysadmin') AS role_sysadmin");
+ for (String operation : FORBIDDEN_DATABASE_PERMISSIONS) {
+ expressions.add(permissionExpression("DB_NAME()", "DATABASE",
+ operation, alias(operation, "database")));
+ }
+ for (String table : SOURCE_BASE_TABLES) {
+ expressions.add(permissionExpression("N'dbo." + table + "'",
+ "OBJECT", "SELECT", alias("SELECT", table)));
+ for (String operation : Arrays.asList(
+ "INSERT", "UPDATE", "DELETE", "ALTER", "CONTROL")) {
+ expressions.add(permissionExpression("N'dbo." + table + "'",
+ "OBJECT", operation, alias(operation, table)));
+ }
+ }
+ for (String view : REQUIRED_SOURCE_VIEWS) {
+ expressions.add(permissionExpression("N'shadow_ro." + view + "'",
+ "OBJECT", "SELECT", alias("SELECT", view)));
+ }
+ appendExpressions(sql, expressions);
+ return sql.toString();
+ }
+
+ private static String evidencePermissionSql() {
+ StringBuilder sql = new StringBuilder("SELECT ");
+ Set expressions = new LinkedHashSet();
+ for (String table : REQUIRED_EVIDENCE_TABLES) {
+ for (String operation : EVIDENCE_TABLE_PERMISSIONS) {
+ expressions.add(permissionExpression("N'dbo." + table + "'",
+ "OBJECT", operation, alias(operation, table)));
+ }
+ }
+ expressions.add("IS_SRVROLEMEMBER(N'sysadmin') AS role_sysadmin");
+ appendExpressions(sql, expressions);
+ return sql.toString();
+ }
+
+ private static String permissionExpression(
+ String securable, String securableClass, String permission,
+ String alias) {
+ return "HAS_PERMS_BY_NAME(" + securable + ", '" + securableClass
+ + "', '" + permission + "') AS " + alias;
+ }
+
+ private static void appendExpressions(StringBuilder sql,
+ Set expressions) {
+ boolean first = true;
+ for (String expression : expressions) {
+ if (!first) {
+ sql.append(", ");
+ }
+ sql.append(expression);
+ first = false;
+ }
+ }
+
+ private static String alias(String operation, String object) {
+ return operation.toLowerCase(Locale.ROOT).replace(' ', '_')
+ + '_' + object.toLowerCase(Locale.ROOT);
+ }
+
+ private static String reason(String permission) {
+ return permission == null ? "UNRESOLVED"
+ : permission.trim().toUpperCase(Locale.ROOT)
+ .replace(' ', '_');
+ }
+
+ private static PermissionValue permissionValue(ResultSet row,
+ String alias)
+ throws SQLException {
+ Object value = row.getObject(alias);
+ if (!(value instanceof Number)) {
+ return PermissionValue.unresolved(alias);
+ }
+ return PermissionValue.resolved(alias, ((Number) value).intValue());
+ }
+
+ private static PermissionCheckResult unresolved(PermissionValue value) {
+ return PermissionCheckResult.rejected(
+ "SOURCE_PERMISSION_PROBE_UNRESOLVED:" + value.getAlias());
+ }
+
+ private static boolean isApprovedRead(String scope, String securable,
+ String permission) {
+ if (!"SELECT".equalsIgnoreCase(permission)) {
+ return false;
+ }
+ String normalizedScope = normalizeIdentifier(scope);
+ String normalizedSecurable = normalizeIdentifier(securable);
+ if ("schema".equals(normalizedScope)) {
+ return "shadow_ro".equals(normalizedSecurable);
+ }
+ if (!"object".equals(normalizedScope)) {
+ return false;
+ }
+ for (String view : REQUIRED_SOURCE_VIEWS) {
+ if (("shadow_ro." + view).equals(normalizedSecurable)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static boolean isApprovedEvidencePermission(
+ String scope, String securable, String permission) {
+ if (!"object".equals(normalizeIdentifier(scope))
+ || !EVIDENCE_TABLE_PERMISSIONS.contains(reason(permission))) {
+ return false;
+ }
+ String normalizedSecurable = normalizeIdentifier(securable);
+ for (String table : REQUIRED_EVIDENCE_TABLES) {
+ if (("dbo." + table).equals(normalizedSecurable)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static String normalizeIdentifier(String value) {
+ return value == null ? "" : value.replace("[", "")
+ .replace("]", "").trim().toLowerCase(Locale.ROOT);
+ }
+
+ private static void requireSqlServer(Connection connection)
+ throws SQLException {
+ DatabaseMetaData metadata = connection.getMetaData();
+ if (!"Microsoft SQL Server".equals(metadata.getDatabaseProductName())) {
+ throw new IllegalStateException(
+ "SHADOW_DATABASE_MUST_BE_SQL_SERVER");
+ }
+ }
+
+ private static final class PermissionValue {
+ private final String alias;
+ private final Integer value;
+
+ private PermissionValue(String alias, Integer value) {
+ this.alias = alias;
+ this.value = value;
+ }
+
+ private static PermissionValue resolved(String alias, int value) {
+ return new PermissionValue(alias, value);
+ }
+
+ private static PermissionValue unresolved(String alias) {
+ return new PermissionValue(alias, null);
+ }
+
+ private boolean isResolved() { return value != null; }
+ private int getValue() { return value; }
+ private String getAlias() { return alias; }
+ }
+}
diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/SqlServerShadowDatabaseGate.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/SqlServerShadowDatabaseGate.java
new file mode 100644
index 0000000..ad960f2
--- /dev/null
+++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/SqlServerShadowDatabaseGate.java
@@ -0,0 +1,165 @@
+package com.ruoyi.shadow.startup;
+
+import com.ruoyi.shadow.config.ShadowProperties;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+import javax.sql.DataSource;
+import java.sql.Connection;
+import java.sql.DatabaseMetaData;
+import java.sql.SQLException;
+import java.sql.ResultSet;
+import java.sql.Statement;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+public final class SqlServerShadowDatabaseGate implements ShadowDatabaseGate {
+ private static final String INSERT_STARTUP_CHECK =
+ "INSERT INTO dbo.shadow_startup_check "
+ + "(check_name, status, checked_at) VALUES (?, ?, ?)";
+ private static final String ACTUAL_DATABASE_IDENTITY_SQL =
+ "SELECT CAST(SERVERPROPERTY('ServerName') AS nvarchar(256)) "
+ + "AS server_name, DB_NAME() AS database_name, "
+ + "ORIGINAL_LOGIN() AS login_name";
+
+ private final DataSource sourceDataSource;
+ private final DataSource evidenceDataSource;
+ private final JdbcTemplate evidenceJdbc;
+ private final SqlServerPermissionProbe permissionProbe;
+
+ public SqlServerShadowDatabaseGate(DataSource sourceDataSource,
+ DataSource evidenceDataSource,
+ JdbcTemplate evidenceJdbc,
+ SqlServerPermissionProbe permissionProbe) {
+ this.sourceDataSource = sourceDataSource;
+ this.evidenceDataSource = evidenceDataSource;
+ this.evidenceJdbc = evidenceJdbc;
+ this.permissionProbe = permissionProbe;
+ }
+
+ @Override
+ public Map verify(ShadowProperties properties) {
+ try (Connection source = sourceDataSource.getConnection();
+ Connection evidence = evidenceDataSource.getConnection()) {
+ ShadowDatabaseIdentity sourceIdentity = actualIdentity(source);
+ ShadowDatabaseIdentity evidenceIdentity = actualIdentity(evidence);
+ ShadowDatabaseIdentity.requirePhysicallyDistinct(
+ sourceIdentity, evidenceIdentity);
+ ShadowDatabaseIdentity.requireDifferentLogins(
+ sourceIdentity, evidenceIdentity);
+ sourceIdentity.requireDatabaseAndLoginMatchConfigured(
+ properties.getSource().getJdbcUrl(),
+ properties.getSource().getUsername(), "SOURCE");
+ evidenceIdentity.requireDatabaseAndLoginMatchConfigured(
+ properties.getEvidence().getJdbcUrl(),
+ properties.getEvidence().getUsername(), "EVIDENCE");
+ identity(source).requireMatchesConfigured(
+ properties.getSource().getJdbcUrl(),
+ properties.getSource().getUsername(), "SOURCE");
+ identity(evidence).requireMatchesConfigured(
+ properties.getEvidence().getJdbcUrl(),
+ properties.getEvidence().getUsername(), "EVIDENCE");
+ if (sourceIdentity.isSameServer(evidenceIdentity)) {
+ requireNoDatabaseAccess(source,
+ evidenceIdentity.getDatabaseName(),
+ "SOURCE_LOGIN_CAN_ACCESS_EVIDENCE_DATABASE");
+ requireNoDatabaseAccess(evidence,
+ sourceIdentity.getDatabaseName(),
+ "EVIDENCE_LOGIN_CAN_ACCESS_SOURCE_DATABASE");
+ }
+
+ PermissionCheckResult sourceResult =
+ permissionProbe.verifySource(source);
+ requireAccepted(sourceResult);
+ PermissionCheckResult evidenceResult =
+ permissionProbe.verifyEvidence(evidence);
+ requireAccepted(evidenceResult);
+
+ Map checks =
+ new LinkedHashMap();
+ checks.put("DATABASE_IDENTITIES_DISTINCT", "PASS");
+ checks.put("DATABASE_LOGINS_DISTINCT", "PASS");
+ checks.put("CROSS_DATABASE_ACCESS_BLOCKED", "PASS");
+ checks.put(sourceResult.getReasonCode(), "PASS");
+ checks.put(evidenceResult.getReasonCode(), "PASS");
+ return checks;
+ } catch (SQLException exception) {
+ throw new IllegalStateException(
+ "SHADOW_DATABASE_STARTUP_PROBE_FAILED", exception);
+ }
+ }
+
+ @Override
+ public void persistChecks(Map checks) {
+ List