feat(ft546): enforce shadow database isolation
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.ruoyi.shadow.startup;
|
||||
|
||||
import com.ruoyi.shadow.config.ShadowProperties;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public interface ShadowDatabaseGate {
|
||||
Map<String, String> verify(ShadowProperties properties);
|
||||
|
||||
void persistChecks(Map<String, String> checks);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String> 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<String> 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<String> 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<String> FORBIDDEN_DATABASE_PERMISSIONS =
|
||||
Arrays.asList("SELECT", "INSERT", "UPDATE", "DELETE",
|
||||
"EXECUTE", "ALTER", "CONTROL", "TAKE OWNERSHIP");
|
||||
private static final List<String> FORBIDDEN_DATABASE_ROLES =
|
||||
Arrays.asList("db_owner", "db_datareader", "db_datawriter",
|
||||
"db_ddladmin", "db_securityadmin", "db_accessadmin");
|
||||
private static final List<String> 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<String> expressions = new LinkedHashSet<String>();
|
||||
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<String> expressions = new LinkedHashSet<String>();
|
||||
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<String> 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; }
|
||||
}
|
||||
}
|
||||
@@ -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<String, String> 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<String, String> checks =
|
||||
new LinkedHashMap<String, String>();
|
||||
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<String, String> checks) {
|
||||
List<Object[]> rows = new ArrayList<Object[]>();
|
||||
Instant checkedAt = Instant.now();
|
||||
for (Map.Entry<String, String> 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,5 +8,9 @@ shadow:
|
||||
enabled: false
|
||||
source:
|
||||
jdbc-url:
|
||||
username:
|
||||
password:
|
||||
evidence:
|
||||
jdbc-url:
|
||||
username:
|
||||
password:
|
||||
|
||||
@@ -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<String, String> 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<String, String> persistedChecks;
|
||||
|
||||
@Override
|
||||
public Map<String, String> verify(
|
||||
com.ruoyi.shadow.config.ShadowProperties properties) {
|
||||
Map<String, String> checks =
|
||||
new LinkedHashMap<String, String>();
|
||||
checks.put("SOURCE_PERMISSIONS_VERIFIED", "PASS");
|
||||
checks.put("EVIDENCE_DATABASE_VERIFIED", "PASS");
|
||||
return checks;
|
||||
}
|
||||
|
||||
public void persistChecks(Map<String, String> checks) {
|
||||
persistedChecks = new LinkedHashMap<String, String>(checks);
|
||||
}
|
||||
|
||||
Map<String, String> getPersistedChecks() {
|
||||
return persistedChecks;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<String, Integer> evidence = acceptedSourcePermissions();
|
||||
evidence.put("update_database", 1);
|
||||
Connection connection = connectionWith(evidence,
|
||||
new ArrayList<String>());
|
||||
|
||||
PermissionCheckResult result = probe.verifySource(connection);
|
||||
|
||||
assertFalse(result.isAccepted());
|
||||
assertEquals("SOURCE_HAS_UPDATE_PERMISSION", result.getReasonCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sourceProbeRejectsMissingSelectOnAnApprovedView()
|
||||
throws Exception {
|
||||
Map<String, Integer> evidence = acceptedSourcePermissions();
|
||||
evidence.put("select_v_device_srgv", 0);
|
||||
|
||||
PermissionCheckResult result = probe.verifySource(
|
||||
connectionWith(evidence, new ArrayList<String>()));
|
||||
|
||||
assertFalse(result.isAccepted());
|
||||
assertEquals("SOURCE_MISSING_SELECT_ON_VIEW:v_device_srgv",
|
||||
result.getReasonCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sourceProbeRejectsDirectSelectOnAnyProductionBaseTable()
|
||||
throws Exception {
|
||||
Map<String, Integer> evidence = acceptedSourcePermissions();
|
||||
evidence.put("select_device_srgv", 1);
|
||||
|
||||
PermissionCheckResult result = probe.verifySource(
|
||||
connectionWith(evidence, new ArrayList<String>()));
|
||||
|
||||
assertFalse(result.isAccepted());
|
||||
assertEquals("SOURCE_HAS_DIRECT_SELECT_PERMISSION:device_srgv",
|
||||
result.getReasonCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sourceProbeRejectsPrivilegedDatabaseRoleMembership()
|
||||
throws Exception {
|
||||
Map<String, Integer> evidence = acceptedSourcePermissions();
|
||||
evidence.put("role_db_datawriter", 1);
|
||||
|
||||
PermissionCheckResult result = probe.verifySource(
|
||||
connectionWith(evidence, new ArrayList<String>()));
|
||||
|
||||
assertFalse(result.isAccepted());
|
||||
assertEquals("SOURCE_IS_MEMBER_OF_FORBIDDEN_ROLE:db_datawriter",
|
||||
result.getReasonCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sourceProbeRejectsDbDataReaderMembership() throws Exception {
|
||||
Map<String, Integer> evidence = acceptedSourcePermissions();
|
||||
evidence.put("role_db_datareader", 1);
|
||||
|
||||
PermissionCheckResult result = probe.verifySource(
|
||||
connectionWith(evidence, new ArrayList<String>()));
|
||||
|
||||
assertFalse(result.isAccepted());
|
||||
assertEquals(
|
||||
"SOURCE_IS_MEMBER_OF_FORBIDDEN_ROLE:db_datareader",
|
||||
result.getReasonCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sourceProbeRejectsUnresolvedPermissionEvidence() throws Exception {
|
||||
Set<String> unresolved = new HashSet<String>();
|
||||
unresolved.add("update_database");
|
||||
|
||||
PermissionCheckResult result = probe.verifySource(connectionWith(
|
||||
acceptedSourcePermissions(), new ArrayList<String>(),
|
||||
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<String>(),
|
||||
new HashSet<String>(), 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<String>(),
|
||||
new HashSet<String>(), null, serverPermission));
|
||||
|
||||
assertFalse(result.isAccepted());
|
||||
assertEquals("SOURCE_HAS_EFFECTIVE_SERVER_PERMISSION:CONTROL_SERVER",
|
||||
result.getReasonCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sourceProbeSetsReadOnlyIsolationAndExecutesSelectOnly()
|
||||
throws Exception {
|
||||
List<String> executedSql = new ArrayList<String>();
|
||||
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<String> executedSql = new ArrayList<String>();
|
||||
|
||||
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<String, Integer> permissions = acceptedEvidencePermissions();
|
||||
permissions.put("insert_shadow_cycle", 0);
|
||||
|
||||
PermissionCheckResult result = probe.verifyEvidence(
|
||||
connectionWith(permissions, new ArrayList<String>()));
|
||||
|
||||
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<String, Integer> values,
|
||||
List<String> executedSql) throws Exception {
|
||||
return connectionWith(values, executedSql,
|
||||
new HashSet<String>(), null, null);
|
||||
}
|
||||
|
||||
private static Connection connectionWith(
|
||||
Map<String, Integer> values,
|
||||
List<String> executedSql,
|
||||
Set<String> unresolvedAliases,
|
||||
EffectivePermission effectivePermission) throws Exception {
|
||||
return connectionWith(values, executedSql, unresolvedAliases,
|
||||
effectivePermission, null);
|
||||
}
|
||||
|
||||
private static Connection connectionWith(
|
||||
Map<String, Integer> values,
|
||||
List<String> executedSql,
|
||||
Set<String> unresolvedAliases,
|
||||
EffectivePermission effectivePermission,
|
||||
EffectivePermission serverPermission) throws Exception {
|
||||
return connectionWith(values, executedSql, unresolvedAliases,
|
||||
effectivePermission, serverPermission, null);
|
||||
}
|
||||
|
||||
private static Connection connectionWithEvidenceExtras(
|
||||
Map<String, Integer> values,
|
||||
EffectivePermission effectivePermission,
|
||||
EffectivePermission serverPermission,
|
||||
String databaseRole) throws Exception {
|
||||
return connectionWith(values, new ArrayList<String>(),
|
||||
new HashSet<String>(), effectivePermission, serverPermission,
|
||||
databaseRole);
|
||||
}
|
||||
|
||||
private static Connection connectionWith(
|
||||
Map<String, Integer> values,
|
||||
List<String> executedSql,
|
||||
Set<String> 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<String, Integer> acceptedSourcePermissions() {
|
||||
Map<String, Integer> values = new HashMap<String, Integer>();
|
||||
for (String view : SqlServerPermissionProbe.REQUIRED_SOURCE_VIEWS) {
|
||||
values.put("select_" + view, 1);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
private static Map<String, Integer> acceptedEvidencePermissions() {
|
||||
Map<String, Integer> values = new HashMap<String, Integer>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<String, String> 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.<String>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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user