feat(ft546): enforce shadow database isolation
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user