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 rows = new ArrayList(); + Instant checkedAt = Instant.now(); + for (Map.Entry check : checks.entrySet()) { + rows.add(new Object[]{check.getKey(), check.getValue(), + java.sql.Timestamp.from(checkedAt)}); + } + evidenceJdbc.batchUpdate(INSERT_STARTUP_CHECK, rows); + } + + private static ShadowDatabaseIdentity identity(Connection connection) + throws SQLException { + DatabaseMetaData metadata = connection.getMetaData(); + return ShadowDatabaseIdentity.fromJdbcUrl( + metadata.getURL(), metadata.getUserName()); + } + + private static ShadowDatabaseIdentity actualIdentity(Connection connection) + throws SQLException { + try (Statement statement = connection.createStatement()) { + statement.setQueryTimeout(3); + try (ResultSet row = statement.executeQuery( + ACTUAL_DATABASE_IDENTITY_SQL)) { + if (!row.next()) { + throw new IllegalStateException( + "ACTUAL_DATABASE_IDENTITY_UNRESOLVED"); + } + return ShadowDatabaseIdentity.fromActualDatabase( + row.getString("server_name"), + row.getString("database_name"), + row.getString("login_name")); + } + } + } + + private static void requireNoDatabaseAccess(Connection connection, + String databaseName, + String reasonCode) + throws SQLException { + String escapedDatabase = databaseName.replace("'", "''"); + String sql = "SELECT HAS_DBACCESS(N'" + escapedDatabase + + "') AS has_access"; + try (Statement statement = connection.createStatement()) { + statement.setQueryTimeout(3); + try (ResultSet row = statement.executeQuery(sql)) { + if (!row.next()) { + throw new IllegalStateException( + "CROSS_DATABASE_ACCESS_PROBE_RETURNED_NO_ROW"); + } + Object value = row.getObject("has_access"); + if (!(value instanceof Number)) { + throw new IllegalStateException( + "CROSS_DATABASE_ACCESS_PROBE_UNRESOLVED"); + } + if (((Number) value).intValue() != 0) { + throw new IllegalStateException(reasonCode); + } + } + } + } + + private static void requireAccepted(PermissionCheckResult result) { + if (result == null) { + throw new IllegalStateException( + "SHADOW_DATABASE_PERMISSION_RESULT_REQUIRED"); + } + if (!result.isAccepted()) { + throw new IllegalStateException(result.getReasonCode()); + } + } +} diff --git a/ruoyi-wcs-shadow/src/main/resources/application-shadow.yml b/ruoyi-wcs-shadow/src/main/resources/application-shadow.yml index 4d2f772..264147d 100644 --- a/ruoyi-wcs-shadow/src/main/resources/application-shadow.yml +++ b/ruoyi-wcs-shadow/src/main/resources/application-shadow.yml @@ -8,5 +8,9 @@ shadow: enabled: false source: jdbc-url: + username: + password: evidence: jdbc-url: + username: + password: diff --git a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/Ft546ShadowApplicationTest.java b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/Ft546ShadowApplicationTest.java index 0385a5b..78dbe9f 100644 --- a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/Ft546ShadowApplicationTest.java +++ b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/Ft546ShadowApplicationTest.java @@ -1,6 +1,7 @@ package com.ruoyi.shadow; import com.ruoyi.project.shadowtest.ForbiddenFactoryBean; +import com.ruoyi.shadow.startup.ShadowDatabaseGate; import com.ruoyi.shadow.startup.ShadowStartupReport; import com.ruoyi.shadow.web.ShadowStatusController; import com.ruoyi.wcs.application.dispatch.DispatchCoordinator; @@ -11,9 +12,13 @@ import org.springframework.boot.test.context.ConfigDataApplicationContextInitial import org.springframework.boot.test.context.runner.WebApplicationContextRunner; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import java.util.LinkedHashMap; +import java.util.Map; + import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -26,9 +31,10 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. class Ft546ShadowApplicationTest { private final WebApplicationContextRunner contextRunner = new WebApplicationContextRunner() - .withInitializer( - new ConfigDataApplicationContextInitializer()) - .withUserConfiguration(Ft546ShadowApplication.class); + .withInitializer( + new ConfigDataApplicationContextInitializer()) + .withUserConfiguration(Ft546ShadowApplication.class, + TestShadowDatabaseGateConfiguration.class); @Test void explicitReadOnlyShadowConfigurationStartsTheNarrowContext() { @@ -52,12 +58,17 @@ class Ft546ShadowApplicationTest { .getStartupChecks().get("SHADOW_ENABLED")); assertEquals("PASS", context.getBean(ShadowStartupReport.class) .getStartupChecks().get("SIDE_EFFECT_BEANS_ABSENT")); - assertEquals("PENDING", context.getBean(ShadowStartupReport.class) + assertEquals("PASS", context.getBean(ShadowStartupReport.class) .getStartupChecks().get("SOURCE_PERMISSIONS_VERIFIED")); - assertEquals("PENDING", context.getBean(ShadowStartupReport.class) + assertEquals("PASS", context.getBean(ShadowStartupReport.class) .getStartupChecks().get("EVIDENCE_DATABASE_VERIFIED")); assertNotNull(context.getBean(DispatchCoordinator.class)); assertNotNull(context.getBean(ShadowStatusController.class)); + Map persisted = context.getBean( + RecordingShadowDatabaseGate.class).getPersistedChecks(); + assertNotNull(persisted); + assertEquals(context.getBean(ShadowStartupReport.class) + .getStartupChecks(), persisted); assertTrue(context.getBeansOfType( DeviceCommandGateway.class).isEmpty()); assertTrue(context.getBeansOfType( @@ -98,18 +109,30 @@ class Ft546ShadowApplicationTest { assertStartupFailure(contextRunner.withPropertyValues( "spring.profiles.active=shadow", "shadow.enabled=true", - "shadow.evidence.jdbc-url=jdbc:h2:mem:evidence"), + "shadow.evidence.jdbc-url=jdbc:h2:mem:evidence", + "shadow.source.username=source", + "shadow.evidence.username=evidence"), "SHADOW_SOURCE_JDBC_URL_REQUIRED"); assertStartupFailure(contextRunner.withPropertyValues( "spring.profiles.active=shadow", "shadow.enabled=true", - "shadow.source.jdbc-url=jdbc:h2:mem:source"), + "shadow.source.jdbc-url=jdbc:h2:mem:source", + "shadow.source.username=source", + "shadow.evidence.username=evidence"), "SHADOW_EVIDENCE_JDBC_URL_REQUIRED"); + assertStartupFailure(configured(contextRunner).withPropertyValues( + "shadow.source.username="), + "SHADOW_SOURCE_USERNAME_REQUIRED"); + assertStartupFailure(configured(contextRunner).withPropertyValues( + "shadow.evidence.username="), + "SHADOW_EVIDENCE_USERNAME_REQUIRED"); assertStartupFailure(contextRunner.withPropertyValues( "spring.profiles.active=shadow", "shadow.enabled=true", "shadow.source.jdbc-url=jdbc:h2:mem:source", "shadow.evidence.jdbc-url=jdbc:h2:mem:evidence", + "shadow.source.username=source", + "shadow.evidence.username=evidence", "server.address=0.0.0.0"), "SHADOW_STATUS_ADDRESS_MUST_BE_LOOPBACK"); } @@ -145,7 +168,9 @@ class Ft546ShadowApplicationTest { "spring.profiles.active=shadow", "shadow.enabled=true", "shadow.source.jdbc-url=jdbc:h2:mem:source", - "shadow.evidence.jdbc-url=jdbc:h2:mem:evidence"); + "shadow.evidence.jdbc-url=jdbc:h2:mem:evidence", + "shadow.source.username=source", + "shadow.evidence.username=evidence"); } private static void assertStartupFailure( @@ -197,4 +222,36 @@ class Ft546ShadowApplicationTest { return new ForbiddenFactoryBean(); } } + + @Configuration(proxyBeanMethods = false) + static class TestShadowDatabaseGateConfiguration { + @Bean + @Primary + RecordingShadowDatabaseGate testShadowDatabaseGate() { + return new RecordingShadowDatabaseGate(); + } + } + + static final class RecordingShadowDatabaseGate + implements ShadowDatabaseGate { + private Map persistedChecks; + + @Override + public Map verify( + com.ruoyi.shadow.config.ShadowProperties properties) { + Map checks = + new LinkedHashMap(); + checks.put("SOURCE_PERMISSIONS_VERIFIED", "PASS"); + checks.put("EVIDENCE_DATABASE_VERIFIED", "PASS"); + return checks; + } + + public void persistChecks(Map checks) { + persistedChecks = new LinkedHashMap(checks); + } + + Map getPersistedChecks() { + return persistedChecks; + } + } } diff --git a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/config/ShadowDataSourceIsolationTest.java b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/config/ShadowDataSourceIsolationTest.java new file mode 100644 index 0000000..581f370 --- /dev/null +++ b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/config/ShadowDataSourceIsolationTest.java @@ -0,0 +1,148 @@ +package com.ruoyi.shadow.config; + +import com.ruoyi.shadow.startup.ShadowDatabaseIdentity; +import com.zaxxer.hikari.HikariDataSource; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; + +import javax.sql.DataSource; +import java.sql.Connection; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotSame; +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.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.springframework.transaction.support.TransactionTemplate; + +class ShadowDataSourceIsolationTest { + @Test + void sourcePoolIsReadOnlyBoundedAndFailsFast() { + ShadowProperties properties = properties(); + ShadowSourceConfiguration configuration = + new ShadowSourceConfiguration(); + + HikariDataSource source = (HikariDataSource) + configuration.shadowSourceDataSource(properties); + try { + assertTrue(source.isReadOnly()); + assertEquals(2, source.getMaximumPoolSize()); + assertEquals(0, source.getMinimumIdle()); + assertEquals(2_000L, source.getConnectionTimeout()); + assertEquals("SET TRANSACTION ISOLATION LEVEL READ COMMITTED", + source.getConnectionInitSql()); + + JdbcTemplate jdbc = configuration.shadowSourceJdbc(source); + assertEquals(3, jdbc.getQueryTimeout()); + + DataSourceTransactionManager tx = + configuration.shadowSourceTx(source); + assertTrue(tx.isEnforceReadOnly()); + } finally { + source.close(); + } + } + + @Test + void sourceAndEvidenceUseDifferentPoolsAndTransactionManagers() { + ShadowProperties properties = properties(); + ShadowSourceConfiguration sourceConfiguration = + new ShadowSourceConfiguration(); + ShadowEvidenceConfiguration evidenceConfiguration = + new ShadowEvidenceConfiguration(); + + HikariDataSource source = (HikariDataSource) + sourceConfiguration.shadowSourceDataSource(properties); + HikariDataSource evidence = (HikariDataSource) + evidenceConfiguration.shadowEvidenceDataSource(properties); + try { + assertNotSame(source, evidence); + assertTrue(source.isReadOnly()); + assertFalse(evidence.isReadOnly()); + assertNotSame(sourceConfiguration.shadowSourceTx(source), + evidenceConfiguration.shadowEvidenceTx(evidence)); + } finally { + source.close(); + evidence.close(); + } + } + + @Test + void sourceReadOnlyTransactionDoesNotEmitUnsupportedSqlServerSyntax() + throws Exception { + DataSource source = mock(DataSource.class); + Connection connection = mock(Connection.class); + when(source.getConnection()).thenReturn(connection); + when(connection.getAutoCommit()).thenReturn(true); + + DataSourceTransactionManager tx = + new ShadowSourceConfiguration().shadowSourceTx(source); + TransactionTemplate transaction = new TransactionTemplate(tx); + transaction.setReadOnly(true); + + transaction.execute(status -> null); + + verify(connection).setReadOnly(true); + verify(connection, never()).createStatement(); + } + + @Test + void sourceAndEvidenceCannotResolveToTheSamePhysicalDatabase() { + ShadowDatabaseIdentity source = ShadowDatabaseIdentity.fromJdbcUrl( + "jdbc:sqlserver://prod-db:1433;databaseName=FT546-wcs", + "ft546_shadow_ro"); + ShadowDatabaseIdentity evidence = ShadowDatabaseIdentity.fromJdbcUrl( + "jdbc:sqlserver://PROD-DB:1433;databaseName=ft546-WCS", + "ft546_shadow_evidence"); + + IllegalStateException failure = assertThrows( + IllegalStateException.class, + () -> ShadowDatabaseIdentity.requirePhysicallyDistinct( + source, evidence)); + + assertEquals("SOURCE_AND_EVIDENCE_DATABASE_MUST_DIFFER", + failure.getMessage()); + } + + @Test + void distinctDatabasesOnTheSameServerAreAccepted() { + ShadowDatabaseIdentity source = ShadowDatabaseIdentity.fromJdbcUrl( + "jdbc:sqlserver://prod-db:1433;databaseName=FT546-wcs", + "ft546_shadow_ro"); + ShadowDatabaseIdentity evidence = ShadowDatabaseIdentity.fromJdbcUrl( + "jdbc:sqlserver://prod-db:1433;databaseName=FT546-shadow", + "ft546_shadow_evidence"); + + ShadowDatabaseIdentity.requirePhysicallyDistinct(source, evidence); + } + + @Test + void jdbcIdentityRequiresAnExplicitServerName() { + IllegalStateException failure = assertThrows( + IllegalStateException.class, + () -> ShadowDatabaseIdentity.fromJdbcUrl( + "jdbc:sqlserver://;databaseName=FT546-wcs", + "ft546_shadow_ro")); + + assertEquals("JDBC_SERVER_NAME_REQUIRED", failure.getMessage()); + } + + private static ShadowProperties properties() { + ShadowProperties properties = new ShadowProperties(); + properties.getSource().setJdbcUrl( + "jdbc:h2:mem:source;DB_CLOSE_DELAY=-1"); + properties.getSource().setUsername("source_user"); + properties.getSource().setPassword("source_password"); + properties.getEvidence().setJdbcUrl( + "jdbc:h2:mem:evidence;DB_CLOSE_DELAY=-1"); + properties.getEvidence().setUsername("evidence_user"); + properties.getEvidence().setPassword("evidence_password"); + return properties; + } +} diff --git a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/startup/SqlServerPermissionProbeTest.java b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/startup/SqlServerPermissionProbeTest.java new file mode 100644 index 0000000..9023482 --- /dev/null +++ b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/startup/SqlServerPermissionProbeTest.java @@ -0,0 +1,377 @@ +package com.ruoyi.shadow.startup; + +import org.junit.jupiter.api.Test; + +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.HashSet; +import java.util.Set; + +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.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class SqlServerPermissionProbeTest { + private final SqlServerPermissionProbe probe = + new SqlServerPermissionProbe(); + + @Test + void sourceProbeRejectsAnyProductionWritePermission() throws Exception { + Map evidence = acceptedSourcePermissions(); + evidence.put("update_database", 1); + Connection connection = connectionWith(evidence, + new ArrayList()); + + PermissionCheckResult result = probe.verifySource(connection); + + assertFalse(result.isAccepted()); + assertEquals("SOURCE_HAS_UPDATE_PERMISSION", result.getReasonCode()); + } + + @Test + void sourceProbeRejectsMissingSelectOnAnApprovedView() + throws Exception { + Map evidence = acceptedSourcePermissions(); + evidence.put("select_v_device_srgv", 0); + + PermissionCheckResult result = probe.verifySource( + connectionWith(evidence, new ArrayList())); + + assertFalse(result.isAccepted()); + assertEquals("SOURCE_MISSING_SELECT_ON_VIEW:v_device_srgv", + result.getReasonCode()); + } + + @Test + void sourceProbeRejectsDirectSelectOnAnyProductionBaseTable() + throws Exception { + Map evidence = acceptedSourcePermissions(); + evidence.put("select_device_srgv", 1); + + PermissionCheckResult result = probe.verifySource( + connectionWith(evidence, new ArrayList())); + + assertFalse(result.isAccepted()); + assertEquals("SOURCE_HAS_DIRECT_SELECT_PERMISSION:device_srgv", + result.getReasonCode()); + } + + @Test + void sourceProbeRejectsPrivilegedDatabaseRoleMembership() + throws Exception { + Map evidence = acceptedSourcePermissions(); + evidence.put("role_db_datawriter", 1); + + PermissionCheckResult result = probe.verifySource( + connectionWith(evidence, new ArrayList())); + + assertFalse(result.isAccepted()); + assertEquals("SOURCE_IS_MEMBER_OF_FORBIDDEN_ROLE:db_datawriter", + result.getReasonCode()); + } + + @Test + void sourceProbeRejectsDbDataReaderMembership() throws Exception { + Map evidence = acceptedSourcePermissions(); + evidence.put("role_db_datareader", 1); + + PermissionCheckResult result = probe.verifySource( + connectionWith(evidence, new ArrayList())); + + assertFalse(result.isAccepted()); + assertEquals( + "SOURCE_IS_MEMBER_OF_FORBIDDEN_ROLE:db_datareader", + result.getReasonCode()); + } + + @Test + void sourceProbeRejectsUnresolvedPermissionEvidence() throws Exception { + Set unresolved = new HashSet(); + unresolved.add("update_database"); + + PermissionCheckResult result = probe.verifySource(connectionWith( + acceptedSourcePermissions(), new ArrayList(), + unresolved, null)); + + assertFalse(result.isAccepted()); + assertEquals( + "SOURCE_PERMISSION_PROBE_UNRESOLVED:update_database", + result.getReasonCode()); + } + + @Test + void sourceProbeRejectsEffectiveWritePermissionOnAnyOtherObject() + throws Exception { + EffectivePermission permission = new EffectivePermission( + "OBJECT", "dbo.unapproved_table", "UPDATE"); + + PermissionCheckResult result = probe.verifySource(connectionWith( + acceptedSourcePermissions(), new ArrayList(), + new HashSet(), permission)); + + assertFalse(result.isAccepted()); + assertEquals( + "SOURCE_HAS_EFFECTIVE_UPDATE_PERMISSION:OBJECT:" + + "dbo.unapproved_table", + result.getReasonCode()); + } + + @Test + void sourceProbeRejectsUnexpectedServerPermission() throws Exception { + EffectivePermission serverPermission = new EffectivePermission( + "SERVER", "SERVER", "CONTROL SERVER"); + + PermissionCheckResult result = probe.verifySource(connectionWith( + acceptedSourcePermissions(), new ArrayList(), + new HashSet(), null, serverPermission)); + + assertFalse(result.isAccepted()); + assertEquals("SOURCE_HAS_EFFECTIVE_SERVER_PERMISSION:CONTROL_SERVER", + result.getReasonCode()); + } + + @Test + void sourceProbeSetsReadOnlyIsolationAndExecutesSelectOnly() + throws Exception { + List executedSql = new ArrayList(); + Connection connection = connectionWith(acceptedSourcePermissions(), + executedSql); + + PermissionCheckResult result = probe.verifySource(connection); + + assertTrue(result.isAccepted()); + assertEquals("SOURCE_PERMISSIONS_VERIFIED", result.getReasonCode()); + verify(connection).setReadOnly(true); + verify(connection).setTransactionIsolation( + Connection.TRANSACTION_READ_COMMITTED); + for (String statement : executedSql) { + assertTrue(statement.trim().toUpperCase().startsWith("SELECT"), + statement); + } + } + + @Test + void sourceProbeExecutesEveryApprovedViewToValidateTheReadPath() + throws Exception { + List executedSql = new ArrayList(); + + PermissionCheckResult result = probe.verifySource(connectionWith( + acceptedSourcePermissions(), executedSql)); + + assertTrue(result.isAccepted()); + for (String view : SqlServerPermissionProbe.REQUIRED_SOURCE_VIEWS) { + assertTrue(executedSql.contains( + "SELECT TOP (0) * FROM [shadow_ro].[" + view + "]"), + "approved view was not executed: " + view); + } + } + + @Test + void evidenceProbeRequiresWritePermissionOnEveryEvidenceTable() + throws Exception { + Map permissions = acceptedEvidencePermissions(); + permissions.put("insert_shadow_cycle", 0); + + PermissionCheckResult result = probe.verifyEvidence( + connectionWith(permissions, new ArrayList())); + + assertFalse(result.isAccepted()); + assertEquals("EVIDENCE_MISSING_INSERT_ON_TABLE:shadow_cycle", + result.getReasonCode()); + } + + @Test + void evidenceProbeRejectsAnyExplicitDatabaseRoleMembership() + throws Exception { + PermissionCheckResult result = probe.verifyEvidence( + connectionWithEvidenceExtras(acceptedEvidencePermissions(), + null, null, "custom_shadow_writer")); + + assertFalse(result.isAccepted()); + assertEquals("EVIDENCE_IS_MEMBER_OF_DATABASE_ROLE:custom_shadow_writer", + result.getReasonCode()); + } + + @Test + void evidenceProbeRejectsEffectivePermissionOnAnyOtherObject() + throws Exception { + EffectivePermission permission = new EffectivePermission( + "OBJECT", "dbo.unapproved_table", "SELECT"); + + PermissionCheckResult result = probe.verifyEvidence( + connectionWithEvidenceExtras(acceptedEvidencePermissions(), + permission, null, null)); + + assertFalse(result.isAccepted()); + assertEquals( + "EVIDENCE_HAS_EFFECTIVE_SELECT_PERMISSION:OBJECT:" + + "dbo.unapproved_table", + result.getReasonCode()); + } + + @Test + void evidenceProbeRejectsUnexpectedServerPermission() throws Exception { + EffectivePermission permission = new EffectivePermission( + "SERVER", "SERVER", "CONTROL SERVER"); + + PermissionCheckResult result = probe.verifyEvidence( + connectionWithEvidenceExtras(acceptedEvidencePermissions(), + null, permission, null)); + + assertFalse(result.isAccepted()); + assertEquals( + "EVIDENCE_HAS_EFFECTIVE_SERVER_PERMISSION:CONTROL_SERVER", + result.getReasonCode()); + } + + private static Connection connectionWith( + Map values, + List executedSql) throws Exception { + return connectionWith(values, executedSql, + new HashSet(), null, null); + } + + private static Connection connectionWith( + Map values, + List executedSql, + Set unresolvedAliases, + EffectivePermission effectivePermission) throws Exception { + return connectionWith(values, executedSql, unresolvedAliases, + effectivePermission, null); + } + + private static Connection connectionWith( + Map values, + List executedSql, + Set unresolvedAliases, + EffectivePermission effectivePermission, + EffectivePermission serverPermission) throws Exception { + return connectionWith(values, executedSql, unresolvedAliases, + effectivePermission, serverPermission, null); + } + + private static Connection connectionWithEvidenceExtras( + Map values, + EffectivePermission effectivePermission, + EffectivePermission serverPermission, + String databaseRole) throws Exception { + return connectionWith(values, new ArrayList(), + new HashSet(), effectivePermission, serverPermission, + databaseRole); + } + + private static Connection connectionWith( + Map values, + List executedSql, + Set unresolvedAliases, + EffectivePermission effectivePermission, + EffectivePermission serverPermission, + String databaseRole) throws Exception { + Connection connection = mock(Connection.class); + DatabaseMetaData metadata = mock(DatabaseMetaData.class); + Statement statement = mock(Statement.class); + ResultSet permissionRow = mock(ResultSet.class); + ResultSet databasePermissionRows = mock(ResultSet.class); + ResultSet effectivePermissionRows = mock(ResultSet.class); + ResultSet serverPermissionRows = mock(ResultSet.class); + ResultSet databaseRoleRows = mock(ResultSet.class); + + when(connection.getMetaData()).thenReturn(metadata); + when(metadata.getDatabaseProductName()) + .thenReturn("Microsoft SQL Server"); + when(metadata.getURL()).thenReturn( + "jdbc:sqlserver://prod-db:1433;databaseName=FT546-wcs"); + when(metadata.getUserName()).thenReturn("ft546_shadow_ro"); + when(connection.createStatement()).thenReturn(statement); + when(statement.executeQuery(anyString())).thenAnswer(invocation -> { + String sql = invocation.getArgument(0); + executedSql.add(sql); + if (sql.contains("fn_my_permissions(NULL, 'SERVER')")) { + return serverPermissionRows; + } + if (sql.contains("sys.database_role_members")) { + return databaseRoleRows; + } + if (sql.contains("fn_my_permissions")) { + if (sql.contains("scope_type")) { + return effectivePermissionRows; + } + return databasePermissionRows; + } + return permissionRow; + }); + when(permissionRow.next()).thenReturn(true, false); + when(permissionRow.getInt(anyString())).thenAnswer(invocation -> + values.getOrDefault(invocation.getArgument(0), 0)); + when(permissionRow.getObject(anyString())).thenAnswer(invocation -> { + String alias = invocation.getArgument(0); + return unresolvedAliases.contains(alias) + ? null : values.getOrDefault(alias, 0); + }); + when(databasePermissionRows.next()).thenReturn(false); + when(effectivePermissionRows.next()).thenReturn( + effectivePermission != null, false); + if (effectivePermission != null) { + when(effectivePermissionRows.getString("scope_type")) + .thenReturn(effectivePermission.scopeType); + when(effectivePermissionRows.getString("securable_name")) + .thenReturn(effectivePermission.securableName); + when(effectivePermissionRows.getString("permission_name")) + .thenReturn(effectivePermission.permissionName); + } + when(serverPermissionRows.next()).thenReturn( + serverPermission != null, false); + if (serverPermission != null) { + when(serverPermissionRows.getString("permission_name")) + .thenReturn(serverPermission.permissionName); + } + when(databaseRoleRows.next()).thenReturn(databaseRole != null, false); + if (databaseRole != null) { + when(databaseRoleRows.getString("role_name")) + .thenReturn(databaseRole); + } + return connection; + } + + private static final class EffectivePermission { + private final String scopeType; + private final String securableName; + private final String permissionName; + + private EffectivePermission(String scopeType, String securableName, + String permissionName) { + this.scopeType = scopeType; + this.securableName = securableName; + this.permissionName = permissionName; + } + } + + private static Map acceptedSourcePermissions() { + Map values = new HashMap(); + for (String view : SqlServerPermissionProbe.REQUIRED_SOURCE_VIEWS) { + values.put("select_" + view, 1); + } + return values; + } + + private static Map acceptedEvidencePermissions() { + Map values = new HashMap(); + for (String table : SqlServerPermissionProbe.REQUIRED_EVIDENCE_TABLES) { + values.put("select_" + table, 1); + values.put("insert_" + table, 1); + values.put("update_" + table, 1); + values.put("delete_" + table, 1); + } + return values; + } +} diff --git a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/startup/SqlServerShadowDatabaseGateTest.java b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/startup/SqlServerShadowDatabaseGateTest.java new file mode 100644 index 0000000..390bf08 --- /dev/null +++ b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/startup/SqlServerShadowDatabaseGateTest.java @@ -0,0 +1,224 @@ +package com.ruoyi.shadow.startup; + +import com.ruoyi.shadow.config.ShadowProperties; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class SqlServerShadowDatabaseGateTest { + @Test + void actualConnectionMetadataCannotResolveToTheSameDatabase() + throws Exception { + Connection source = connection( + "jdbc:sqlserver://prod:1433;databaseName=FT546-wcs", + "shadow_ro"); + Connection evidence = connection( + "jdbc:sqlserver://PROD:1433;databaseName=ft546-WCS", + "shadow_evidence"); + SqlServerPermissionProbe probe = mock(SqlServerPermissionProbe.class); + JdbcTemplate evidenceJdbc = mock(JdbcTemplate.class); + SqlServerShadowDatabaseGate gate = new SqlServerShadowDatabaseGate( + dataSource(source), dataSource(evidence), evidenceJdbc, probe); + + IllegalStateException failure = assertThrows( + IllegalStateException.class, + () -> gate.verify(properties())); + + assertEquals("SOURCE_AND_EVIDENCE_DATABASE_MUST_DIFFER", + failure.getMessage()); + verify(probe, never()).verifySource(any(Connection.class)); + } + + @Test + void differentJdbcAliasesCannotHideTheSamePhysicalDatabase() + throws Exception { + Connection source = connection( + "jdbc:sqlserver://prod-alias-a:1433;" + + "databaseName=FT546-wcs", + "shadow_ro", "FT546-SQL\\WCS", "FT546-wcs"); + Connection evidence = connection( + "jdbc:sqlserver://prod-alias-b:1433;" + + "databaseName=FT546-wcs", + "shadow_evidence", "FT546-SQL\\WCS", + "FT546-wcs"); + SqlServerPermissionProbe probe = mock(SqlServerPermissionProbe.class); + SqlServerShadowDatabaseGate gate = new SqlServerShadowDatabaseGate( + dataSource(source), dataSource(evidence), + mock(JdbcTemplate.class), probe); + ShadowProperties properties = properties(); + properties.getSource().setJdbcUrl( + "jdbc:sqlserver://prod-alias-a:1433;" + + "databaseName=FT546-wcs"); + properties.getEvidence().setJdbcUrl( + "jdbc:sqlserver://prod-alias-b:1433;" + + "databaseName=FT546-wcs"); + + IllegalStateException failure = assertThrows( + IllegalStateException.class, () -> gate.verify(properties)); + + assertEquals("SOURCE_AND_EVIDENCE_DATABASE_MUST_DIFFER", + failure.getMessage()); + verify(probe, never()).verifySource(any(Connection.class)); + } + + @Test + void evidenceLoginCannotAccessSourceDatabaseOnTheSameServer() + throws Exception { + Connection source = connection( + "jdbc:sqlserver://prod:1433;databaseName=FT546-wcs", + "shadow_ro", "FT546-SQL", "FT546-wcs", 0); + Connection evidence = connection( + "jdbc:sqlserver://prod:1433;databaseName=FT546-shadow", + "shadow_evidence", "FT546-SQL", "FT546-shadow", + 1); + SqlServerPermissionProbe probe = mock(SqlServerPermissionProbe.class); + SqlServerShadowDatabaseGate gate = new SqlServerShadowDatabaseGate( + dataSource(source), dataSource(evidence), + mock(JdbcTemplate.class), probe); + ShadowProperties properties = properties(); + properties.getEvidence().setJdbcUrl( + "jdbc:sqlserver://prod:1433;databaseName=FT546-shadow"); + + IllegalStateException failure = assertThrows( + IllegalStateException.class, () -> gate.verify(properties)); + + assertEquals("EVIDENCE_LOGIN_CAN_ACCESS_SOURCE_DATABASE", + failure.getMessage()); + verify(probe, never()).verifySource(any(Connection.class)); + } + + @Test + void rejectedSourcePermissionFailsStartupWithoutEvidenceWrites() + throws Exception { + Connection source = connection( + "jdbc:sqlserver://prod:1433;databaseName=FT546-wcs", + "shadow_ro"); + Connection evidence = connection( + "jdbc:sqlserver://evidence:1433;databaseName=FT546-shadow", + "shadow_evidence"); + SqlServerPermissionProbe probe = mock(SqlServerPermissionProbe.class); + when(probe.verifySource(source)).thenReturn( + PermissionCheckResult.rejected( + "SOURCE_HAS_UPDATE_PERMISSION")); + JdbcTemplate evidenceJdbc = mock(JdbcTemplate.class); + SqlServerShadowDatabaseGate gate = new SqlServerShadowDatabaseGate( + dataSource(source), dataSource(evidence), evidenceJdbc, probe); + + IllegalStateException failure = assertThrows( + IllegalStateException.class, + () -> gate.verify(properties())); + + assertEquals("SOURCE_HAS_UPDATE_PERMISSION", failure.getMessage()); + verify(evidenceJdbc, never()).update(anyString(), any(), any(), any()); + } + + @Test + void acceptedDatabasesPersistAllSuppliedStartupChecksInEvidenceDatabase() + throws Exception { + Connection source = connection( + "jdbc:sqlserver://prod:1433;databaseName=FT546-wcs", + "shadow_ro"); + Connection evidence = connection( + "jdbc:sqlserver://evidence:1433;databaseName=FT546-shadow", + "shadow_evidence"); + SqlServerPermissionProbe probe = mock(SqlServerPermissionProbe.class); + when(probe.verifySource(source)).thenReturn( + PermissionCheckResult.accepted( + "SOURCE_PERMISSIONS_VERIFIED")); + when(probe.verifyEvidence(evidence)).thenReturn( + PermissionCheckResult.accepted( + "EVIDENCE_DATABASE_VERIFIED")); + JdbcTemplate evidenceJdbc = mock(JdbcTemplate.class); + SqlServerShadowDatabaseGate gate = new SqlServerShadowDatabaseGate( + dataSource(source), dataSource(evidence), evidenceJdbc, probe); + + Map checks = gate.verify(properties()); + checks.put("SHADOW_ENABLED", "PASS"); + gate.persistChecks(checks); + + assertEquals("PASS", checks.get("DATABASE_IDENTITIES_DISTINCT")); + assertEquals("PASS", checks.get("DATABASE_LOGINS_DISTINCT")); + assertEquals("PASS", checks.get("SOURCE_PERMISSIONS_VERIFIED")); + assertEquals("PASS", checks.get("EVIDENCE_DATABASE_VERIFIED")); + assertEquals("PASS", checks.get("SHADOW_ENABLED")); + verify(evidenceJdbc).batchUpdate(anyString(), + any(java.util.List.class)); + } + + private static DataSource dataSource(Connection connection) + throws Exception { + DataSource dataSource = mock(DataSource.class); + when(dataSource.getConnection()).thenReturn(connection); + return dataSource; + } + + private static Connection connection(String url, String username) + throws Exception { + String database = url.substring( + url.toLowerCase().indexOf("databasename=") + 13); + return connection(url, username, + url.substring(url.indexOf("//") + 2, url.indexOf(':', + url.indexOf("//") + 2)), database); + } + + private static Connection connection(String url, String username, + String actualServer, + String actualDatabase) + throws Exception { + return connection(url, username, actualServer, actualDatabase, 0); + } + + private static Connection connection(String url, String username, + String actualServer, + String actualDatabase, + int crossDatabaseAccess) + throws Exception { + Connection connection = mock(Connection.class); + DatabaseMetaData metadata = mock(DatabaseMetaData.class); + Statement statement = mock(Statement.class); + ResultSet identity = mock(ResultSet.class); + ResultSet access = mock(ResultSet.class); + when(connection.getMetaData()).thenReturn(metadata); + when(metadata.getURL()).thenReturn(url); + when(metadata.getUserName()).thenReturn(username); + when(connection.createStatement()).thenReturn(statement); + when(statement.executeQuery(anyString())).thenAnswer(invocation -> + invocation.getArgument(0).contains("HAS_DBACCESS") + ? access : identity); + when(identity.next()).thenReturn(true, false); + when(identity.getString("server_name")).thenReturn(actualServer); + when(identity.getString("database_name")) + .thenReturn(actualDatabase); + when(identity.getString("login_name")).thenReturn(username); + when(access.next()).thenReturn(true, false); + when(access.getObject("has_access")) + .thenReturn(crossDatabaseAccess); + return connection; + } + + private static ShadowProperties properties() { + ShadowProperties properties = new ShadowProperties(); + properties.getSource().setJdbcUrl( + "jdbc:sqlserver://prod:1433;databaseName=FT546-wcs"); + properties.getSource().setUsername("shadow_ro"); + properties.getEvidence().setJdbcUrl( + "jdbc:sqlserver://evidence:1433;databaseName=FT546-shadow"); + properties.getEvidence().setUsername("shadow_evidence"); + return properties; + } +} diff --git a/ruoyi-wcs-shadow/src/test/resources/application-shadow-test.yml b/ruoyi-wcs-shadow/src/test/resources/application-shadow-test.yml index 00bc1d0..ba7594e 100644 --- a/ruoyi-wcs-shadow/src/test/resources/application-shadow-test.yml +++ b/ruoyi-wcs-shadow/src/test/resources/application-shadow-test.yml @@ -5,5 +5,7 @@ shadow: enabled: true source: jdbc-url: jdbc:h2:mem:source + username: source evidence: jdbc-url: jdbc:h2:mem:evidence + username: evidence diff --git a/sql/shadow/V001__ft546_shadow_read_views.sql b/sql/shadow/V001__ft546_shadow_read_views.sql new file mode 100644 index 0000000..aa2761e --- /dev/null +++ b/sql/shadow/V001__ft546_shadow_read_views.sql @@ -0,0 +1,77 @@ +:setvar ShadowSourceDatabase "FT546-wcs" + +SET NOCOUNT ON; +SET XACT_ABORT ON; +GO + +IF DB_ID() <> DB_ID(N'$(ShadowSourceDatabase)') + THROW 51000, 'Run the shadow read-view script only in ShadowSourceDatabase.', 1; +GO + +IF SCHEMA_ID(N'shadow_ro') IS NULL + EXEC(N'CREATE SCHEMA shadow_ro AUTHORIZATION dbo'); +GO + +CREATE OR ALTER VIEW shadow_ro.v_wcs_srgv_queue +AS +SELECT guid, task_no, task_type, priority, cargo, issued, path_info, + execution_status, cargo_pick_site, cargo_place_site, start_site, + end_site, the_floor, trun_path, up_down_site, create_date, + start_date, finish_date, agv_id, default02, default03, default04, + default05, default06, default07, default08 +FROM dbo.wcs_srgv_queue; +GO + +CREATE OR ALTER VIEW shadow_ro.v_wcs_srgv_queue_finish +AS +SELECT guid, task_no, task_type, priority, cargo, issued, path_info, + execution_status, cargo_pick_site, cargo_place_site, start_site, + end_site, the_floor, trun_path, up_down_site, create_date, + start_date, finish_date, agv_id, default02, default03, default04, + default05, default06, default07, default08 +FROM dbo.wcs_srgv_queue_finish; +GO + +CREATE OR ALTER VIEW shadow_ro.v_device_srgv +AS +SELECT guid, device_id, device_name, type, set_floor, power_time, + last_time, agv_status, task_no, src_site, tar_site, curr_site, + task_type, battery, work_mode, alarm, status, finish, charge_flag, + work_phase, err_msg, ssj_request, change_layer, cargo_flag, + manual_charge, path, allow_send, curr_floor, offline_site, locked, + spare1, spare2, spare3, spare4, spare5 +FROM dbo.device_srgv; +GO + +CREATE OR ALTER VIEW shadow_ro.v_wcs_srgv_map +AS +SELECT guid, x, y, z, axy, value, ux, uy, uz, uxy, space_name, remark, + cargo_space_id, the_row, the_column, the_layer, site_id, plc_row, + plc_column, plc_layer, warehouse, space_area +FROM dbo.wcs_srgv_map; +GO + +CREATE OR ALTER VIEW shadow_ro.v_bas_cargo_space +AS +SELECT cargo_space_id, warehouse_id, roadway_id, cargo_area_id, the_type, + the_type_name, cargo_space_name, the_platoon, the_column, the_floor, + status, locked, tray_code, cargo_hight, iss_double, group_coding_no, + group_coding, cargo_space_logo, this_cargo_area, wms_cargo_space_id +FROM dbo.bas_cargo_space; +GO + +CREATE OR ALTER VIEW shadow_ro.v_tsj_execution +AS +SELECT guid, device_id, device_name, task_no, src_row, src_column, + src_layer, tar_row, tar_column, tar_layer, src_site, tar_site, + send_flag, send_type, task_type, create_date, start_date, + finish_date, spare1, spare2, spare3, rwlx +FROM dbo.tsj_device_execution_queue; +GO + +CREATE OR ALTER VIEW shadow_ro.v_current_database_clock +AS +SELECT SYSUTCDATETIME() AS database_utc_time, + DB_NAME() AS database_name, + @@SERVERNAME AS server_name; +GO diff --git a/sql/shadow/V002__ft546_shadow_read_login.sql b/sql/shadow/V002__ft546_shadow_read_login.sql new file mode 100644 index 0000000..95a8cb6 --- /dev/null +++ b/sql/shadow/V002__ft546_shadow_read_login.sql @@ -0,0 +1,192 @@ +:setvar ShadowSourceLogin "ft546_shadow_ro" +:setvar ShadowSourceUser "ft546_shadow_ro" +:setvar ShadowSourcePassword "REPLACE_AT_DEPLOY_TIME" +:setvar ShadowSourceDatabase "FT546-wcs" + +SET NOCOUNT ON; +SET XACT_ABORT ON; + +IF N'$(ShadowSourcePassword)' = N'REPLACE_AT_DEPLOY_TIME' + THROW 51000, 'Supply ShadowSourcePassword through the approved secret channel.', 1; + +IF DB_ID() <> DB_ID(N'$(ShadowSourceDatabase)') + THROW 51001, 'Run the shadow source-login script only in ShadowSourceDatabase.', 1; + +DECLARE @sourceLoginCreated bit = 0; +IF NOT EXISTS (SELECT 1 FROM sys.server_principals + WHERE name = N'$(ShadowSourceLogin)') +BEGIN + DECLARE @createLogin nvarchar(max) = + N'CREATE LOGIN ' + QUOTENAME(N'$(ShadowSourceLogin)') + + N' WITH PASSWORD = N''' + + REPLACE(N'$(ShadowSourcePassword)', N'''', N'''''') + + N''', CHECK_POLICY = ON, CHECK_EXPIRATION = ON'; + EXEC sys.sp_executesql @createLogin; + SET @sourceLoginCreated = 1; +END; + +BEGIN TRY +IF EXISTS ( + SELECT 1 + FROM sys.server_role_members rm + JOIN sys.server_principals role_principal + ON role_principal.principal_id = rm.role_principal_id + JOIN sys.server_principals login_principal + ON login_principal.principal_id = rm.member_principal_id + WHERE login_principal.name = N'$(ShadowSourceLogin)' +) + THROW 51002, 'The shadow source login must not belong to a server role.', 1; + +DECLARE @serverContextSet bit = 0; +BEGIN TRY + EXECUTE AS LOGIN = N'$(ShadowSourceLogin)'; + SET @serverContextSet = 1; + IF EXISTS ( + SELECT 1 + FROM sys.fn_my_permissions(NULL, N'SERVER') + WHERE permission_name NOT IN (N'CONNECT SQL', N'VIEW ANY DATABASE') + ) + THROW 51007, 'The shadow source login retains forbidden server permissions.', 1; + REVERT; + SET @serverContextSet = 0; +END TRY +BEGIN CATCH + IF @serverContextSet = 1 + REVERT; + THROW; +END CATCH; + +BEGIN TRANSACTION; + +IF NOT EXISTS (SELECT 1 FROM sys.database_principals + WHERE name = N'$(ShadowSourceUser)') +BEGIN + DECLARE @createUser nvarchar(max) = + N'CREATE USER ' + QUOTENAME(N'$(ShadowSourceUser)') + + N' FOR LOGIN ' + QUOTENAME(N'$(ShadowSourceLogin)'); + EXEC sys.sp_executesql @createUser; +END +ELSE IF SUSER_SID(N'$(ShadowSourceLogin)') <> USER_SID(N'$(ShadowSourceUser)') + THROW 51003, 'The existing shadow source user maps to a different login.', 1; + +DECLARE @principal nvarchar(258) = QUOTENAME(N'$(ShadowSourceUser)'); +DECLARE @permissions nvarchar(max) = + N'GRANT CONNECT TO ' + @principal + N';' + + N'REVOKE CONTROL FROM ' + @principal + N';' + + N'REVOKE SELECT FROM ' + @principal + N';' + + N'GRANT SELECT ON SCHEMA::shadow_ro TO ' + @principal + N';' + + N'DENY INSERT, UPDATE, DELETE, EXECUTE, ALTER, TAKE OWNERSHIP ' + + N'TO ' + @principal + N';' + + N'DENY CONTROL ON OBJECT::dbo.wcs_srgv_queue TO ' + @principal + N';' + + N'DENY CONTROL ON OBJECT::dbo.wcs_srgv_queue_finish TO ' + @principal + N';' + + N'DENY CONTROL ON OBJECT::dbo.device_srgv TO ' + @principal + N';' + + N'DENY CONTROL ON OBJECT::dbo.wcs_srgv_map TO ' + @principal + N';' + + N'DENY CONTROL ON OBJECT::dbo.bas_cargo_space TO ' + @principal + N';' + + N'DENY CONTROL ON OBJECT::dbo.tsj_device_execution_queue TO ' + + @principal + N';' + + N'DENY SELECT ON OBJECT::dbo.wcs_srgv_queue TO ' + @principal + N';' + + N'DENY SELECT ON OBJECT::dbo.wcs_srgv_queue_finish TO ' + @principal + N';' + + N'DENY SELECT ON OBJECT::dbo.device_srgv TO ' + @principal + N';' + + N'DENY SELECT ON OBJECT::dbo.wcs_srgv_map TO ' + @principal + N';' + + N'DENY SELECT ON OBJECT::dbo.bas_cargo_space TO ' + @principal + N';' + + N'DENY SELECT ON OBJECT::dbo.tsj_device_execution_queue TO ' + + @principal + N';'; +EXEC sys.sp_executesql @permissions; + +DECLARE @role sysname; +DECLARE forbidden_roles CURSOR LOCAL FAST_FORWARD FOR +SELECT role_principal.name +FROM sys.database_principals role_principal +JOIN sys.database_role_members rm + ON rm.role_principal_id = role_principal.principal_id +JOIN sys.database_principals member_principal + ON member_principal.principal_id = rm.member_principal_id +WHERE role_principal.type = 'R' + AND member_principal.name = N'$(ShadowSourceUser)'; + +OPEN forbidden_roles; +FETCH NEXT FROM forbidden_roles INTO @role; +WHILE @@FETCH_STATUS = 0 +BEGIN + IF IS_ROLEMEMBER(@role, N'$(ShadowSourceUser)') = 1 + BEGIN + DECLARE @dropRole nvarchar(max) = N'ALTER ROLE ' + + QUOTENAME(@role) + N' DROP MEMBER ' + @principal; + EXEC sys.sp_executesql @dropRole; + END; + FETCH NEXT FROM forbidden_roles INTO @role; +END; +CLOSE forbidden_roles; +DEALLOCATE forbidden_roles; + +DECLARE @databaseContextSet bit = 0; +BEGIN TRY + EXECUTE AS USER = N'$(ShadowSourceUser)'; + SET @databaseContextSet = 1; + IF HAS_PERMS_BY_NAME(DB_NAME(), N'DATABASE', N'SELECT') <> 0 + OR HAS_PERMS_BY_NAME(DB_NAME(), N'DATABASE', N'INSERT') <> 0 + OR HAS_PERMS_BY_NAME(DB_NAME(), N'DATABASE', N'UPDATE') <> 0 + OR HAS_PERMS_BY_NAME(DB_NAME(), N'DATABASE', N'DELETE') <> 0 + OR HAS_PERMS_BY_NAME(DB_NAME(), N'DATABASE', N'EXECUTE') <> 0 + OR HAS_PERMS_BY_NAME(DB_NAME(), N'DATABASE', N'ALTER') <> 0 + OR HAS_PERMS_BY_NAME(DB_NAME(), N'DATABASE', N'CONTROL') <> 0 + OR HAS_PERMS_BY_NAME(DB_NAME(), N'DATABASE', N'TAKE OWNERSHIP') <> 0 + THROW 51004, 'The shadow source login retains forbidden database permissions.', 1; + + IF EXISTS ( + SELECT 1 + FROM sys.schemas s + CROSS APPLY sys.fn_my_permissions(QUOTENAME(s.name), N'SCHEMA') p + WHERE s.name NOT IN (N'sys', N'INFORMATION_SCHEMA') + AND NOT (s.name = N'shadow_ro' AND p.permission_name = N'SELECT') + ) + THROW 51005, 'The shadow source login retains forbidden schema permissions.', 1; + + IF EXISTS ( + SELECT 1 + 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), N'OBJECT') p + WHERE o.is_ms_shipped = 0 + AND NOT ( + s.name = N'shadow_ro' + AND o.name IN ( + N'v_wcs_srgv_queue', N'v_wcs_srgv_queue_finish', + N'v_device_srgv', N'v_wcs_srgv_map', + N'v_bas_cargo_space', N'v_tsj_execution', + N'v_current_database_clock') + AND p.permission_name = N'SELECT' + ) + ) + THROW 51006, 'The shadow source login retains forbidden object permissions.', 1; + + SELECT TOP (0) * FROM shadow_ro.v_wcs_srgv_queue; + SELECT TOP (0) * FROM shadow_ro.v_wcs_srgv_queue_finish; + SELECT TOP (0) * FROM shadow_ro.v_device_srgv; + SELECT TOP (0) * FROM shadow_ro.v_wcs_srgv_map; + SELECT TOP (0) * FROM shadow_ro.v_bas_cargo_space; + SELECT TOP (0) * FROM shadow_ro.v_tsj_execution; + SELECT TOP (0) * FROM shadow_ro.v_current_database_clock; + REVERT; + SET @databaseContextSet = 0; +END TRY +BEGIN CATCH + IF @databaseContextSet = 1 + REVERT; + THROW; +END CATCH; + +COMMIT TRANSACTION; +END TRY +BEGIN CATCH + IF XACT_STATE() <> 0 + ROLLBACK TRANSACTION; + IF @sourceLoginCreated = 1 + BEGIN + DECLARE @dropSourceLogin nvarchar(max) = + N'DROP LOGIN ' + QUOTENAME(N'$(ShadowSourceLogin)'); + EXEC sys.sp_executesql @dropSourceLogin; + END; + THROW; +END CATCH; diff --git a/sql/shadow/V003__ft546_shadow_evidence.sql b/sql/shadow/V003__ft546_shadow_evidence.sql new file mode 100644 index 0000000..ef7e824 --- /dev/null +++ b/sql/shadow/V003__ft546_shadow_evidence.sql @@ -0,0 +1,312 @@ +:setvar ShadowSourceDatabase "FT546-wcs" +:setvar ShadowEvidenceLogin "ft546_shadow_evidence" +:setvar ShadowEvidenceUser "ft546_shadow_evidence" +:setvar ShadowEvidencePassword "REPLACE_AT_DEPLOY_TIME" + +SET NOCOUNT ON; +SET XACT_ABORT ON; + +IF DB_ID() = DB_ID(N'$(ShadowSourceDatabase)') + THROW 51001, 'Evidence objects must be installed in a separate database.', 1; + +IF N'$(ShadowEvidencePassword)' = N'REPLACE_AT_DEPLOY_TIME' + THROW 51002, 'Supply ShadowEvidencePassword through the approved secret channel.', 1; + +DECLARE @evidenceLoginCreated bit = 0; +IF NOT EXISTS (SELECT 1 FROM sys.server_principals + WHERE name = N'$(ShadowEvidenceLogin)') +BEGIN + DECLARE @createEvidenceLogin nvarchar(max) = + N'CREATE LOGIN ' + QUOTENAME(N'$(ShadowEvidenceLogin)') + + N' WITH PASSWORD = N''' + + REPLACE(N'$(ShadowEvidencePassword)', N'''', N'''''') + + N''', CHECK_POLICY = ON, CHECK_EXPIRATION = ON'; + EXEC sys.sp_executesql @createEvidenceLogin; + SET @evidenceLoginCreated = 1; +END; + +BEGIN TRY +IF EXISTS ( + SELECT 1 + FROM sys.server_role_members rm + JOIN sys.server_principals login_principal + ON login_principal.principal_id = rm.member_principal_id + WHERE login_principal.name = N'$(ShadowEvidenceLogin)' +) + THROW 51003, 'The shadow evidence login must not belong to a server role.', 1; + +DECLARE @serverContextSet bit = 0; +BEGIN TRY + EXECUTE AS LOGIN = N'$(ShadowEvidenceLogin)'; + SET @serverContextSet = 1; + IF EXISTS ( + SELECT 1 FROM sys.fn_my_permissions(NULL, N'SERVER') + WHERE permission_name NOT IN (N'CONNECT SQL', N'VIEW ANY DATABASE') + ) + THROW 51009, 'The shadow evidence login retains forbidden server permissions.', 1; + REVERT; + SET @serverContextSet = 0; +END TRY +BEGIN CATCH + IF @serverContextSet = 1 + REVERT; + THROW; +END CATCH; + +BEGIN TRANSACTION; + +IF NOT EXISTS (SELECT 1 FROM sys.database_principals + WHERE name = N'$(ShadowEvidenceUser)') +BEGIN + DECLARE @createEvidenceUser nvarchar(max) = + N'CREATE USER ' + QUOTENAME(N'$(ShadowEvidenceUser)') + + N' FOR LOGIN ' + QUOTENAME(N'$(ShadowEvidenceLogin)'); + EXEC sys.sp_executesql @createEvidenceUser; +END +ELSE IF SUSER_SID(N'$(ShadowEvidenceLogin)') + <> USER_SID(N'$(ShadowEvidenceUser)') + THROW 51004, 'The existing shadow evidence user maps to a different login.', 1; + +DECLARE @evidencePrincipal nvarchar(258) = + QUOTENAME(N'$(ShadowEvidenceUser)'); +DECLARE @evidenceRole sysname; +DECLARE evidence_roles CURSOR LOCAL FAST_FORWARD FOR +SELECT role_principal.name +FROM sys.database_principals role_principal +JOIN sys.database_role_members rm + ON rm.role_principal_id = role_principal.principal_id +JOIN sys.database_principals member_principal + ON member_principal.principal_id = rm.member_principal_id +WHERE role_principal.type = 'R' + AND member_principal.name = N'$(ShadowEvidenceUser)'; + +OPEN evidence_roles; +FETCH NEXT FROM evidence_roles INTO @evidenceRole; +WHILE @@FETCH_STATUS = 0 +BEGIN + DECLARE @dropEvidenceRole nvarchar(max) = N'ALTER ROLE ' + + QUOTENAME(@evidenceRole) + N' DROP MEMBER ' + @evidencePrincipal; + EXEC sys.sp_executesql @dropEvidenceRole; + FETCH NEXT FROM evidence_roles INTO @evidenceRole; +END; +CLOSE evidence_roles; +DEALLOCATE evidence_roles; + +DECLARE @revokeEvidenceDatabase nvarchar(max) = + N'REVOKE CONTROL, ALTER, TAKE OWNERSHIP, SELECT, INSERT, UPDATE, DELETE, ' + + N'EXECUTE FROM ' + @evidencePrincipal + N';'; +EXEC sys.sp_executesql @revokeEvidenceDatabase; + +IF OBJECT_ID(N'dbo.shadow_run', N'U') IS NULL +CREATE TABLE dbo.shadow_run ( + run_id uniqueidentifier NOT NULL PRIMARY KEY, + started_at datetime2(3) NOT NULL, + artifact_sha256 char(64) NOT NULL, + configuration_sha256 char(64) NOT NULL, + source_server nvarchar(256) NOT NULL, + source_database sysname NOT NULL, + source_login sysname NOT NULL, + status varchar(32) NOT NULL +); + +IF OBJECT_ID(N'dbo.shadow_cycle', N'U') IS NULL +CREATE TABLE dbo.shadow_cycle ( + cycle_id uniqueidentifier NOT NULL PRIMARY KEY, + run_id uniqueidentifier NOT NULL, + input_batch_hash char(64) NOT NULL, + parent_task_id nvarchar(128) NOT NULL, + phase varchar(64) NOT NULL, + route_version bigint NOT NULL, + status varchar(64) NOT NULL, + reason_code nvarchar(512) NULL, + database_captured_at datetime2(3) NULL, + completed_at datetime2(3) NOT NULL, + evidence_hash char(64) NOT NULL, + CONSTRAINT fk_shadow_cycle_run FOREIGN KEY (run_id) + REFERENCES dbo.shadow_run(run_id), + CONSTRAINT uq_shadow_cycle_identity UNIQUE + (run_id, input_batch_hash, parent_task_id, phase, route_version) +); + +IF OBJECT_ID(N'dbo.shadow_input_batch', N'U') IS NULL +CREATE TABLE dbo.shadow_input_batch ( + cycle_id uniqueidentifier NOT NULL PRIMARY KEY, + content_hash char(64) NOT NULL, + captured_json nvarchar(max) NOT NULL, + CONSTRAINT fk_shadow_input_cycle FOREIGN KEY (cycle_id) + REFERENCES dbo.shadow_cycle(cycle_id) +); + +IF OBJECT_ID(N'dbo.shadow_legacy_queue', N'U') IS NULL +CREATE TABLE dbo.shadow_legacy_queue ( + cycle_id uniqueidentifier NOT NULL PRIMARY KEY, + vehicle_id nvarchar(64) NULL, + path_info nvarchar(max) NULL, + result_status varchar(64) NOT NULL, + result_json nvarchar(max) NOT NULL, + content_hash char(64) NOT NULL, + CONSTRAINT fk_shadow_legacy_cycle FOREIGN KEY (cycle_id) + REFERENCES dbo.shadow_cycle(cycle_id) +); + +IF OBJECT_ID(N'dbo.shadow_decision', N'U') IS NULL +CREATE TABLE dbo.shadow_decision ( + cycle_id uniqueidentifier NOT NULL PRIMARY KEY, + decision varchar(64) NOT NULL, + vehicle_id nvarchar(64) NULL, + reason_code nvarchar(512) NULL, + decision_json nvarchar(max) NOT NULL, + content_hash char(64) NOT NULL, + CONSTRAINT fk_shadow_decision_cycle FOREIGN KEY (cycle_id) + REFERENCES dbo.shadow_cycle(cycle_id) +); + +IF OBJECT_ID(N'dbo.shadow_encoded_write', N'U') IS NULL +CREATE TABLE dbo.shadow_encoded_write ( + cycle_id uniqueidentifier NOT NULL, + write_sequence int NOT NULL, + register_address varchar(16) NOT NULL, + data_type varchar(16) NOT NULL, + values_json nvarchar(max) NOT NULL, + trigger_value bigint NULL, + CONSTRAINT pk_shadow_encoded_write PRIMARY KEY + (cycle_id, write_sequence), + CONSTRAINT fk_shadow_write_cycle FOREIGN KEY (cycle_id) + REFERENCES dbo.shadow_cycle(cycle_id) +); + +IF OBJECT_ID(N'dbo.shadow_difference', N'U') IS NULL +CREATE TABLE dbo.shadow_difference ( + difference_id bigint IDENTITY(1, 1) NOT NULL PRIMARY KEY, + cycle_id uniqueidentifier NOT NULL, + category varchar(64) NOT NULL, + reason_code nvarchar(512) NOT NULL, + expected_json nvarchar(max) NULL, + actual_json nvarchar(max) NULL, + CONSTRAINT fk_shadow_difference_cycle FOREIGN KEY (cycle_id) + REFERENCES dbo.shadow_cycle(cycle_id) +); + +IF OBJECT_ID(N'dbo.shadow_traffic_state', N'U') IS NULL +CREATE TABLE dbo.shadow_traffic_state ( + state_key nvarchar(256) NOT NULL PRIMARY KEY, + snapshot_version bigint NOT NULL, + state_json nvarchar(max) NOT NULL, + updated_at datetime2(3) NOT NULL +); + +IF OBJECT_ID(N'dbo.shadow_hold_history', N'U') IS NULL +CREATE TABLE dbo.shadow_hold_history ( + parent_task_id nvarchar(128) NOT NULL, + vehicle_id nvarchar(64) NOT NULL, + resource_key nvarchar(256) NOT NULL, + reason_code nvarchar(512) NOT NULL, + consecutive_count int NOT NULL, + updated_at datetime2(3) NOT NULL, + CONSTRAINT pk_shadow_hold_history PRIMARY KEY + (parent_task_id, vehicle_id, resource_key, reason_code) +); + +IF OBJECT_ID(N'dbo.shadow_startup_check', N'U') IS NULL +CREATE TABLE dbo.shadow_startup_check ( + check_id bigint IDENTITY(1, 1) NOT NULL PRIMARY KEY, + check_name varchar(128) NOT NULL, + status varchar(32) NOT NULL, + checked_at datetime2(3) NOT NULL +); + +DECLARE @grantEvidence nvarchar(max) = N'GRANT CONNECT TO ' + + @evidencePrincipal + N';'; +DECLARE @evidenceTable sysname; +DECLARE evidence_tables CURSOR LOCAL FAST_FORWARD FOR +SELECT name +FROM sys.tables +WHERE schema_id = SCHEMA_ID(N'dbo') + AND name IN (N'shadow_run', N'shadow_cycle', N'shadow_input_batch', + N'shadow_legacy_queue', N'shadow_decision', + N'shadow_encoded_write', N'shadow_difference', + N'shadow_traffic_state', N'shadow_hold_history', + N'shadow_startup_check'); +OPEN evidence_tables; +FETCH NEXT FROM evidence_tables INTO @evidenceTable; +WHILE @@FETCH_STATUS = 0 +BEGIN + SET @grantEvidence += N'GRANT SELECT, INSERT, UPDATE, DELETE ON OBJECT::dbo.' + + QUOTENAME(@evidenceTable) + N' TO ' + @evidencePrincipal + N';'; + FETCH NEXT FROM evidence_tables INTO @evidenceTable; +END; +CLOSE evidence_tables; +DEALLOCATE evidence_tables; +EXEC sys.sp_executesql @grantEvidence; + +DECLARE @databaseContextSet bit = 0; +BEGIN TRY + EXECUTE AS USER = N'$(ShadowEvidenceUser)'; + SET @databaseContextSet = 1; + + IF EXISTS ( + SELECT 1 + FROM sys.database_role_members rm + JOIN sys.database_principals member_principal + ON member_principal.principal_id = rm.member_principal_id + WHERE member_principal.name = USER_NAME() + ) + THROW 51005, 'The shadow evidence login retains a database role.', 1; + + IF EXISTS ( + SELECT 1 FROM sys.fn_my_permissions(NULL, N'DATABASE') + WHERE permission_name <> N'CONNECT' + ) + THROW 51006, 'The shadow evidence login retains forbidden database permissions.', 1; + + IF EXISTS ( + SELECT 1 + FROM sys.schemas s + CROSS APPLY sys.fn_my_permissions(QUOTENAME(s.name), N'SCHEMA') p + WHERE s.name NOT IN (N'sys', N'INFORMATION_SCHEMA') + ) + THROW 51007, 'The shadow evidence login retains forbidden schema permissions.', 1; + + IF EXISTS ( + SELECT 1 + 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), N'OBJECT') p + WHERE o.is_ms_shipped = 0 + AND NOT ( + s.name = N'dbo' + AND o.name IN ( + N'shadow_run', N'shadow_cycle', N'shadow_input_batch', + N'shadow_legacy_queue', N'shadow_decision', + N'shadow_encoded_write', N'shadow_difference', + N'shadow_traffic_state', N'shadow_hold_history', + N'shadow_startup_check') + AND p.permission_name IN ( + N'SELECT', N'INSERT', N'UPDATE', N'DELETE') + ) + ) + THROW 51008, 'The shadow evidence login retains forbidden object permissions.', 1; + + REVERT; + SET @databaseContextSet = 0; +END TRY +BEGIN CATCH + IF @databaseContextSet = 1 + REVERT; + THROW; +END CATCH; + +COMMIT TRANSACTION; +END TRY +BEGIN CATCH + IF XACT_STATE() <> 0 + ROLLBACK TRANSACTION; + IF @evidenceLoginCreated = 1 + BEGIN + DECLARE @dropEvidenceLogin nvarchar(max) = + N'DROP LOGIN ' + QUOTENAME(N'$(ShadowEvidenceLogin)'); + EXEC sys.sp_executesql @dropEvidenceLogin; + END; + THROW; +END CATCH; diff --git a/sql/shadow/verify_ft546_shadow_permissions.sql b/sql/shadow/verify_ft546_shadow_permissions.sql new file mode 100644 index 0000000..d55ec14 --- /dev/null +++ b/sql/shadow/verify_ft546_shadow_permissions.sql @@ -0,0 +1,137 @@ +:setvar ShadowSourceLogin "ft546_shadow_ro" +:setvar ShadowSourceDatabase "FT546-wcs" + +SET NOCOUNT ON; +SET XACT_ABORT ON; + +IF DB_ID() <> DB_ID(N'$(ShadowSourceDatabase)') + THROW 51010, 'Run source permission verification only in ShadowSourceDatabase.', 1; + +IF SUSER_ID(N'$(ShadowSourceLogin)') IS NULL + THROW 51011, 'ShadowSourceLogin does not exist.', 1; + +DECLARE @checks TABLE ( + check_name nvarchar(512) NOT NULL, + expected_value int NOT NULL, + actual_value int NULL +); + +INSERT INTO @checks (check_name, expected_value, actual_value) +SELECT N'server_role_membership_count', 0, COUNT(*) +FROM sys.server_role_members rm +JOIN sys.server_principals member_principal + ON member_principal.principal_id = rm.member_principal_id +WHERE member_principal.name = N'$(ShadowSourceLogin)'; + +BEGIN TRY + EXECUTE AS LOGIN = N'$(ShadowSourceLogin)'; + + DECLARE @objects TABLE ( + object_name nvarchar(512) NOT NULL, + expected_select int NOT NULL, + is_base_table bit NOT NULL + ); + + INSERT INTO @objects (object_name, expected_select, is_base_table) + VALUES + (N'shadow_ro.v_wcs_srgv_queue', 1, 0), + (N'shadow_ro.v_wcs_srgv_queue_finish', 1, 0), + (N'shadow_ro.v_device_srgv', 1, 0), + (N'shadow_ro.v_wcs_srgv_map', 1, 0), + (N'shadow_ro.v_bas_cargo_space', 1, 0), + (N'shadow_ro.v_tsj_execution', 1, 0), + (N'shadow_ro.v_current_database_clock', 1, 0), + (N'dbo.wcs_srgv_queue', 0, 1), + (N'dbo.wcs_srgv_queue_finish', 0, 1), + (N'dbo.device_srgv', 0, 1), + (N'dbo.wcs_srgv_map', 0, 1), + (N'dbo.bas_cargo_space', 0, 1), + (N'dbo.tsj_device_execution_queue', 0, 1); + + INSERT INTO @checks (check_name, expected_value, actual_value) + SELECT N'select:' + object_name, expected_select, + HAS_PERMS_BY_NAME(object_name, N'OBJECT', N'SELECT') + FROM @objects; + + INSERT INTO @checks (check_name, expected_value, actual_value) + SELECT LOWER(permission_name) + N':' + object_name, 0, + HAS_PERMS_BY_NAME(object_name, N'OBJECT', permission_name) + FROM @objects + CROSS JOIN (VALUES (N'INSERT'), (N'UPDATE'), (N'DELETE'), + (N'ALTER'), (N'CONTROL')) AS operations(permission_name) + WHERE is_base_table = 1; + + INSERT INTO @checks (check_name, expected_value, actual_value) + SELECT N'database_permission:' + permission_name, 0, + HAS_PERMS_BY_NAME(DB_NAME(), N'DATABASE', permission_name) + FROM (VALUES (N'SELECT'), (N'INSERT'), (N'UPDATE'), (N'DELETE'), + (N'EXECUTE'), (N'ALTER'), (N'CONTROL'), + (N'TAKE OWNERSHIP')) AS permissions(permission_name); + + INSERT INTO @checks (check_name, expected_value, actual_value) + SELECT N'database_role:' + role_name, 0, IS_MEMBER(role_name) + FROM (VALUES (N'db_owner'), (N'db_datareader'), + (N'db_datawriter'), (N'db_ddladmin'), + (N'db_securityadmin'), (N'db_accessadmin')) + AS roles(role_name); + + INSERT INTO @checks (check_name, expected_value, actual_value) + SELECT N'forbidden_effective_database_permission_count', 0, COUNT(*) + FROM sys.fn_my_permissions(NULL, N'DATABASE') + WHERE permission_name IN (N'SELECT', N'INSERT', N'UPDATE', N'DELETE', + N'EXECUTE', N'ALTER', N'CONTROL', + N'TAKE OWNERSHIP'); + + INSERT INTO @checks (check_name, expected_value, actual_value) + SELECT N'forbidden_effective_server_permission_count', 0, COUNT(*) + FROM sys.fn_my_permissions(NULL, N'SERVER') + WHERE permission_name NOT IN (N'CONNECT SQL', N'VIEW ANY DATABASE'); + + INSERT INTO @checks (check_name, expected_value, actual_value) + SELECT N'forbidden_effective_schema_permission_count', 0, COUNT(*) + FROM sys.schemas s + CROSS APPLY sys.fn_my_permissions(QUOTENAME(s.name), N'SCHEMA') p + WHERE s.name NOT IN (N'sys', N'INFORMATION_SCHEMA') + AND NOT (s.name = N'shadow_ro' AND p.permission_name = N'SELECT'); + + INSERT INTO @checks (check_name, expected_value, actual_value) + SELECT N'forbidden_effective_object_permission_count', 0, COUNT(*) + 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), N'OBJECT') p + WHERE o.is_ms_shipped = 0 + AND NOT ( + s.name = N'shadow_ro' + AND o.name IN (N'v_wcs_srgv_queue', N'v_wcs_srgv_queue_finish', + N'v_device_srgv', N'v_wcs_srgv_map', + N'v_bas_cargo_space', N'v_tsj_execution', + N'v_current_database_clock') + AND p.permission_name = N'SELECT' + ); + + SELECT TOP (0) * FROM shadow_ro.v_wcs_srgv_queue; + SELECT TOP (0) * FROM shadow_ro.v_wcs_srgv_queue_finish; + SELECT TOP (0) * FROM shadow_ro.v_device_srgv; + SELECT TOP (0) * FROM shadow_ro.v_wcs_srgv_map; + SELECT TOP (0) * FROM shadow_ro.v_bas_cargo_space; + SELECT TOP (0) * FROM shadow_ro.v_tsj_execution; + SELECT TOP (0) * FROM shadow_ro.v_current_database_clock; + + REVERT; +END TRY +BEGIN CATCH + IF ORIGINAL_LOGIN() <> SUSER_SNAME() + REVERT; + THROW; +END CATCH; + +SELECT check_name, expected_value, actual_value, + CASE WHEN actual_value = expected_value THEN N'PASS' ELSE N'FAIL' END + AS status +FROM @checks +ORDER BY check_name; + +IF EXISTS (SELECT 1 FROM @checks + WHERE actual_value IS NULL OR actual_value <> expected_value) + THROW 51012, 'FT546 shadow source permission verification failed.', 1;