feat(ft546): enforce shadow database isolation

This commit is contained in:
zgw
2026-08-13 14:23:50 +08:00
parent 364668c939
commit b364831c4a
23 changed files with 2516 additions and 14 deletions

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}