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

@@ -33,6 +33,14 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>com.microsoft.sqlserver</groupId>
<artifactId>mssql-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>

View File

@@ -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) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,5 +8,9 @@ shadow:
enabled: false
source:
jdbc-url:
username:
password:
evidence:
jdbc-url:
username:
password:

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

View File

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

View File

@@ -0,0 +1,77 @@
:setvar ShadowSourceDatabase "FT546-wcs"
SET NOCOUNT ON;
SET XACT_ABORT ON;
GO
IF DB_ID() <> DB_ID(N'$(ShadowSourceDatabase)')
THROW 51000, 'Run the shadow read-view script only in ShadowSourceDatabase.', 1;
GO
IF SCHEMA_ID(N'shadow_ro') IS NULL
EXEC(N'CREATE SCHEMA shadow_ro AUTHORIZATION dbo');
GO
CREATE OR ALTER VIEW shadow_ro.v_wcs_srgv_queue
AS
SELECT guid, task_no, task_type, priority, cargo, issued, path_info,
execution_status, cargo_pick_site, cargo_place_site, start_site,
end_site, the_floor, trun_path, up_down_site, create_date,
start_date, finish_date, agv_id, default02, default03, default04,
default05, default06, default07, default08
FROM dbo.wcs_srgv_queue;
GO
CREATE OR ALTER VIEW shadow_ro.v_wcs_srgv_queue_finish
AS
SELECT guid, task_no, task_type, priority, cargo, issued, path_info,
execution_status, cargo_pick_site, cargo_place_site, start_site,
end_site, the_floor, trun_path, up_down_site, create_date,
start_date, finish_date, agv_id, default02, default03, default04,
default05, default06, default07, default08
FROM dbo.wcs_srgv_queue_finish;
GO
CREATE OR ALTER VIEW shadow_ro.v_device_srgv
AS
SELECT guid, device_id, device_name, type, set_floor, power_time,
last_time, agv_status, task_no, src_site, tar_site, curr_site,
task_type, battery, work_mode, alarm, status, finish, charge_flag,
work_phase, err_msg, ssj_request, change_layer, cargo_flag,
manual_charge, path, allow_send, curr_floor, offline_site, locked,
spare1, spare2, spare3, spare4, spare5
FROM dbo.device_srgv;
GO
CREATE OR ALTER VIEW shadow_ro.v_wcs_srgv_map
AS
SELECT guid, x, y, z, axy, value, ux, uy, uz, uxy, space_name, remark,
cargo_space_id, the_row, the_column, the_layer, site_id, plc_row,
plc_column, plc_layer, warehouse, space_area
FROM dbo.wcs_srgv_map;
GO
CREATE OR ALTER VIEW shadow_ro.v_bas_cargo_space
AS
SELECT cargo_space_id, warehouse_id, roadway_id, cargo_area_id, the_type,
the_type_name, cargo_space_name, the_platoon, the_column, the_floor,
status, locked, tray_code, cargo_hight, iss_double, group_coding_no,
group_coding, cargo_space_logo, this_cargo_area, wms_cargo_space_id
FROM dbo.bas_cargo_space;
GO
CREATE OR ALTER VIEW shadow_ro.v_tsj_execution
AS
SELECT guid, device_id, device_name, task_no, src_row, src_column,
src_layer, tar_row, tar_column, tar_layer, src_site, tar_site,
send_flag, send_type, task_type, create_date, start_date,
finish_date, spare1, spare2, spare3, rwlx
FROM dbo.tsj_device_execution_queue;
GO
CREATE OR ALTER VIEW shadow_ro.v_current_database_clock
AS
SELECT SYSUTCDATETIME() AS database_utc_time,
DB_NAME() AS database_name,
@@SERVERNAME AS server_name;
GO

View File

@@ -0,0 +1,192 @@
:setvar ShadowSourceLogin "ft546_shadow_ro"
:setvar ShadowSourceUser "ft546_shadow_ro"
:setvar ShadowSourcePassword "REPLACE_AT_DEPLOY_TIME"
:setvar ShadowSourceDatabase "FT546-wcs"
SET NOCOUNT ON;
SET XACT_ABORT ON;
IF N'$(ShadowSourcePassword)' = N'REPLACE_AT_DEPLOY_TIME'
THROW 51000, 'Supply ShadowSourcePassword through the approved secret channel.', 1;
IF DB_ID() <> DB_ID(N'$(ShadowSourceDatabase)')
THROW 51001, 'Run the shadow source-login script only in ShadowSourceDatabase.', 1;
DECLARE @sourceLoginCreated bit = 0;
IF NOT EXISTS (SELECT 1 FROM sys.server_principals
WHERE name = N'$(ShadowSourceLogin)')
BEGIN
DECLARE @createLogin nvarchar(max) =
N'CREATE LOGIN ' + QUOTENAME(N'$(ShadowSourceLogin)')
+ N' WITH PASSWORD = N'''
+ REPLACE(N'$(ShadowSourcePassword)', N'''', N'''''')
+ N''', CHECK_POLICY = ON, CHECK_EXPIRATION = ON';
EXEC sys.sp_executesql @createLogin;
SET @sourceLoginCreated = 1;
END;
BEGIN TRY
IF EXISTS (
SELECT 1
FROM sys.server_role_members rm
JOIN sys.server_principals role_principal
ON role_principal.principal_id = rm.role_principal_id
JOIN sys.server_principals login_principal
ON login_principal.principal_id = rm.member_principal_id
WHERE login_principal.name = N'$(ShadowSourceLogin)'
)
THROW 51002, 'The shadow source login must not belong to a server role.', 1;
DECLARE @serverContextSet bit = 0;
BEGIN TRY
EXECUTE AS LOGIN = N'$(ShadowSourceLogin)';
SET @serverContextSet = 1;
IF EXISTS (
SELECT 1
FROM sys.fn_my_permissions(NULL, N'SERVER')
WHERE permission_name NOT IN (N'CONNECT SQL', N'VIEW ANY DATABASE')
)
THROW 51007, 'The shadow source login retains forbidden server permissions.', 1;
REVERT;
SET @serverContextSet = 0;
END TRY
BEGIN CATCH
IF @serverContextSet = 1
REVERT;
THROW;
END CATCH;
BEGIN TRANSACTION;
IF NOT EXISTS (SELECT 1 FROM sys.database_principals
WHERE name = N'$(ShadowSourceUser)')
BEGIN
DECLARE @createUser nvarchar(max) =
N'CREATE USER ' + QUOTENAME(N'$(ShadowSourceUser)')
+ N' FOR LOGIN ' + QUOTENAME(N'$(ShadowSourceLogin)');
EXEC sys.sp_executesql @createUser;
END
ELSE IF SUSER_SID(N'$(ShadowSourceLogin)') <> USER_SID(N'$(ShadowSourceUser)')
THROW 51003, 'The existing shadow source user maps to a different login.', 1;
DECLARE @principal nvarchar(258) = QUOTENAME(N'$(ShadowSourceUser)');
DECLARE @permissions nvarchar(max) =
N'GRANT CONNECT TO ' + @principal + N';'
+ N'REVOKE CONTROL FROM ' + @principal + N';'
+ N'REVOKE SELECT FROM ' + @principal + N';'
+ N'GRANT SELECT ON SCHEMA::shadow_ro TO ' + @principal + N';'
+ N'DENY INSERT, UPDATE, DELETE, EXECUTE, ALTER, TAKE OWNERSHIP '
+ N'TO ' + @principal + N';'
+ N'DENY CONTROL ON OBJECT::dbo.wcs_srgv_queue TO ' + @principal + N';'
+ N'DENY CONTROL ON OBJECT::dbo.wcs_srgv_queue_finish TO ' + @principal + N';'
+ N'DENY CONTROL ON OBJECT::dbo.device_srgv TO ' + @principal + N';'
+ N'DENY CONTROL ON OBJECT::dbo.wcs_srgv_map TO ' + @principal + N';'
+ N'DENY CONTROL ON OBJECT::dbo.bas_cargo_space TO ' + @principal + N';'
+ N'DENY CONTROL ON OBJECT::dbo.tsj_device_execution_queue TO '
+ @principal + N';'
+ N'DENY SELECT ON OBJECT::dbo.wcs_srgv_queue TO ' + @principal + N';'
+ N'DENY SELECT ON OBJECT::dbo.wcs_srgv_queue_finish TO ' + @principal + N';'
+ N'DENY SELECT ON OBJECT::dbo.device_srgv TO ' + @principal + N';'
+ N'DENY SELECT ON OBJECT::dbo.wcs_srgv_map TO ' + @principal + N';'
+ N'DENY SELECT ON OBJECT::dbo.bas_cargo_space TO ' + @principal + N';'
+ N'DENY SELECT ON OBJECT::dbo.tsj_device_execution_queue TO '
+ @principal + N';';
EXEC sys.sp_executesql @permissions;
DECLARE @role sysname;
DECLARE forbidden_roles CURSOR LOCAL FAST_FORWARD FOR
SELECT role_principal.name
FROM sys.database_principals role_principal
JOIN sys.database_role_members rm
ON rm.role_principal_id = role_principal.principal_id
JOIN sys.database_principals member_principal
ON member_principal.principal_id = rm.member_principal_id
WHERE role_principal.type = 'R'
AND member_principal.name = N'$(ShadowSourceUser)';
OPEN forbidden_roles;
FETCH NEXT FROM forbidden_roles INTO @role;
WHILE @@FETCH_STATUS = 0
BEGIN
IF IS_ROLEMEMBER(@role, N'$(ShadowSourceUser)') = 1
BEGIN
DECLARE @dropRole nvarchar(max) = N'ALTER ROLE '
+ QUOTENAME(@role) + N' DROP MEMBER ' + @principal;
EXEC sys.sp_executesql @dropRole;
END;
FETCH NEXT FROM forbidden_roles INTO @role;
END;
CLOSE forbidden_roles;
DEALLOCATE forbidden_roles;
DECLARE @databaseContextSet bit = 0;
BEGIN TRY
EXECUTE AS USER = N'$(ShadowSourceUser)';
SET @databaseContextSet = 1;
IF HAS_PERMS_BY_NAME(DB_NAME(), N'DATABASE', N'SELECT') <> 0
OR HAS_PERMS_BY_NAME(DB_NAME(), N'DATABASE', N'INSERT') <> 0
OR HAS_PERMS_BY_NAME(DB_NAME(), N'DATABASE', N'UPDATE') <> 0
OR HAS_PERMS_BY_NAME(DB_NAME(), N'DATABASE', N'DELETE') <> 0
OR HAS_PERMS_BY_NAME(DB_NAME(), N'DATABASE', N'EXECUTE') <> 0
OR HAS_PERMS_BY_NAME(DB_NAME(), N'DATABASE', N'ALTER') <> 0
OR HAS_PERMS_BY_NAME(DB_NAME(), N'DATABASE', N'CONTROL') <> 0
OR HAS_PERMS_BY_NAME(DB_NAME(), N'DATABASE', N'TAKE OWNERSHIP') <> 0
THROW 51004, 'The shadow source login retains forbidden database permissions.', 1;
IF EXISTS (
SELECT 1
FROM sys.schemas s
CROSS APPLY sys.fn_my_permissions(QUOTENAME(s.name), N'SCHEMA') p
WHERE s.name NOT IN (N'sys', N'INFORMATION_SCHEMA')
AND NOT (s.name = N'shadow_ro' AND p.permission_name = N'SELECT')
)
THROW 51005, 'The shadow source login retains forbidden schema permissions.', 1;
IF EXISTS (
SELECT 1
FROM sys.objects o
JOIN sys.schemas s ON s.schema_id = o.schema_id
CROSS APPLY sys.fn_my_permissions(
QUOTENAME(s.name) + N'.' + QUOTENAME(o.name), N'OBJECT') p
WHERE o.is_ms_shipped = 0
AND NOT (
s.name = N'shadow_ro'
AND o.name IN (
N'v_wcs_srgv_queue', N'v_wcs_srgv_queue_finish',
N'v_device_srgv', N'v_wcs_srgv_map',
N'v_bas_cargo_space', N'v_tsj_execution',
N'v_current_database_clock')
AND p.permission_name = N'SELECT'
)
)
THROW 51006, 'The shadow source login retains forbidden object permissions.', 1;
SELECT TOP (0) * FROM shadow_ro.v_wcs_srgv_queue;
SELECT TOP (0) * FROM shadow_ro.v_wcs_srgv_queue_finish;
SELECT TOP (0) * FROM shadow_ro.v_device_srgv;
SELECT TOP (0) * FROM shadow_ro.v_wcs_srgv_map;
SELECT TOP (0) * FROM shadow_ro.v_bas_cargo_space;
SELECT TOP (0) * FROM shadow_ro.v_tsj_execution;
SELECT TOP (0) * FROM shadow_ro.v_current_database_clock;
REVERT;
SET @databaseContextSet = 0;
END TRY
BEGIN CATCH
IF @databaseContextSet = 1
REVERT;
THROW;
END CATCH;
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0
ROLLBACK TRANSACTION;
IF @sourceLoginCreated = 1
BEGIN
DECLARE @dropSourceLogin nvarchar(max) =
N'DROP LOGIN ' + QUOTENAME(N'$(ShadowSourceLogin)');
EXEC sys.sp_executesql @dropSourceLogin;
END;
THROW;
END CATCH;

View File

@@ -0,0 +1,312 @@
:setvar ShadowSourceDatabase "FT546-wcs"
:setvar ShadowEvidenceLogin "ft546_shadow_evidence"
:setvar ShadowEvidenceUser "ft546_shadow_evidence"
:setvar ShadowEvidencePassword "REPLACE_AT_DEPLOY_TIME"
SET NOCOUNT ON;
SET XACT_ABORT ON;
IF DB_ID() = DB_ID(N'$(ShadowSourceDatabase)')
THROW 51001, 'Evidence objects must be installed in a separate database.', 1;
IF N'$(ShadowEvidencePassword)' = N'REPLACE_AT_DEPLOY_TIME'
THROW 51002, 'Supply ShadowEvidencePassword through the approved secret channel.', 1;
DECLARE @evidenceLoginCreated bit = 0;
IF NOT EXISTS (SELECT 1 FROM sys.server_principals
WHERE name = N'$(ShadowEvidenceLogin)')
BEGIN
DECLARE @createEvidenceLogin nvarchar(max) =
N'CREATE LOGIN ' + QUOTENAME(N'$(ShadowEvidenceLogin)')
+ N' WITH PASSWORD = N'''
+ REPLACE(N'$(ShadowEvidencePassword)', N'''', N'''''')
+ N''', CHECK_POLICY = ON, CHECK_EXPIRATION = ON';
EXEC sys.sp_executesql @createEvidenceLogin;
SET @evidenceLoginCreated = 1;
END;
BEGIN TRY
IF EXISTS (
SELECT 1
FROM sys.server_role_members rm
JOIN sys.server_principals login_principal
ON login_principal.principal_id = rm.member_principal_id
WHERE login_principal.name = N'$(ShadowEvidenceLogin)'
)
THROW 51003, 'The shadow evidence login must not belong to a server role.', 1;
DECLARE @serverContextSet bit = 0;
BEGIN TRY
EXECUTE AS LOGIN = N'$(ShadowEvidenceLogin)';
SET @serverContextSet = 1;
IF EXISTS (
SELECT 1 FROM sys.fn_my_permissions(NULL, N'SERVER')
WHERE permission_name NOT IN (N'CONNECT SQL', N'VIEW ANY DATABASE')
)
THROW 51009, 'The shadow evidence login retains forbidden server permissions.', 1;
REVERT;
SET @serverContextSet = 0;
END TRY
BEGIN CATCH
IF @serverContextSet = 1
REVERT;
THROW;
END CATCH;
BEGIN TRANSACTION;
IF NOT EXISTS (SELECT 1 FROM sys.database_principals
WHERE name = N'$(ShadowEvidenceUser)')
BEGIN
DECLARE @createEvidenceUser nvarchar(max) =
N'CREATE USER ' + QUOTENAME(N'$(ShadowEvidenceUser)')
+ N' FOR LOGIN ' + QUOTENAME(N'$(ShadowEvidenceLogin)');
EXEC sys.sp_executesql @createEvidenceUser;
END
ELSE IF SUSER_SID(N'$(ShadowEvidenceLogin)')
<> USER_SID(N'$(ShadowEvidenceUser)')
THROW 51004, 'The existing shadow evidence user maps to a different login.', 1;
DECLARE @evidencePrincipal nvarchar(258) =
QUOTENAME(N'$(ShadowEvidenceUser)');
DECLARE @evidenceRole sysname;
DECLARE evidence_roles CURSOR LOCAL FAST_FORWARD FOR
SELECT role_principal.name
FROM sys.database_principals role_principal
JOIN sys.database_role_members rm
ON rm.role_principal_id = role_principal.principal_id
JOIN sys.database_principals member_principal
ON member_principal.principal_id = rm.member_principal_id
WHERE role_principal.type = 'R'
AND member_principal.name = N'$(ShadowEvidenceUser)';
OPEN evidence_roles;
FETCH NEXT FROM evidence_roles INTO @evidenceRole;
WHILE @@FETCH_STATUS = 0
BEGIN
DECLARE @dropEvidenceRole nvarchar(max) = N'ALTER ROLE '
+ QUOTENAME(@evidenceRole) + N' DROP MEMBER ' + @evidencePrincipal;
EXEC sys.sp_executesql @dropEvidenceRole;
FETCH NEXT FROM evidence_roles INTO @evidenceRole;
END;
CLOSE evidence_roles;
DEALLOCATE evidence_roles;
DECLARE @revokeEvidenceDatabase nvarchar(max) =
N'REVOKE CONTROL, ALTER, TAKE OWNERSHIP, SELECT, INSERT, UPDATE, DELETE, '
+ N'EXECUTE FROM ' + @evidencePrincipal + N';';
EXEC sys.sp_executesql @revokeEvidenceDatabase;
IF OBJECT_ID(N'dbo.shadow_run', N'U') IS NULL
CREATE TABLE dbo.shadow_run (
run_id uniqueidentifier NOT NULL PRIMARY KEY,
started_at datetime2(3) NOT NULL,
artifact_sha256 char(64) NOT NULL,
configuration_sha256 char(64) NOT NULL,
source_server nvarchar(256) NOT NULL,
source_database sysname NOT NULL,
source_login sysname NOT NULL,
status varchar(32) NOT NULL
);
IF OBJECT_ID(N'dbo.shadow_cycle', N'U') IS NULL
CREATE TABLE dbo.shadow_cycle (
cycle_id uniqueidentifier NOT NULL PRIMARY KEY,
run_id uniqueidentifier NOT NULL,
input_batch_hash char(64) NOT NULL,
parent_task_id nvarchar(128) NOT NULL,
phase varchar(64) NOT NULL,
route_version bigint NOT NULL,
status varchar(64) NOT NULL,
reason_code nvarchar(512) NULL,
database_captured_at datetime2(3) NULL,
completed_at datetime2(3) NOT NULL,
evidence_hash char(64) NOT NULL,
CONSTRAINT fk_shadow_cycle_run FOREIGN KEY (run_id)
REFERENCES dbo.shadow_run(run_id),
CONSTRAINT uq_shadow_cycle_identity UNIQUE
(run_id, input_batch_hash, parent_task_id, phase, route_version)
);
IF OBJECT_ID(N'dbo.shadow_input_batch', N'U') IS NULL
CREATE TABLE dbo.shadow_input_batch (
cycle_id uniqueidentifier NOT NULL PRIMARY KEY,
content_hash char(64) NOT NULL,
captured_json nvarchar(max) NOT NULL,
CONSTRAINT fk_shadow_input_cycle FOREIGN KEY (cycle_id)
REFERENCES dbo.shadow_cycle(cycle_id)
);
IF OBJECT_ID(N'dbo.shadow_legacy_queue', N'U') IS NULL
CREATE TABLE dbo.shadow_legacy_queue (
cycle_id uniqueidentifier NOT NULL PRIMARY KEY,
vehicle_id nvarchar(64) NULL,
path_info nvarchar(max) NULL,
result_status varchar(64) NOT NULL,
result_json nvarchar(max) NOT NULL,
content_hash char(64) NOT NULL,
CONSTRAINT fk_shadow_legacy_cycle FOREIGN KEY (cycle_id)
REFERENCES dbo.shadow_cycle(cycle_id)
);
IF OBJECT_ID(N'dbo.shadow_decision', N'U') IS NULL
CREATE TABLE dbo.shadow_decision (
cycle_id uniqueidentifier NOT NULL PRIMARY KEY,
decision varchar(64) NOT NULL,
vehicle_id nvarchar(64) NULL,
reason_code nvarchar(512) NULL,
decision_json nvarchar(max) NOT NULL,
content_hash char(64) NOT NULL,
CONSTRAINT fk_shadow_decision_cycle FOREIGN KEY (cycle_id)
REFERENCES dbo.shadow_cycle(cycle_id)
);
IF OBJECT_ID(N'dbo.shadow_encoded_write', N'U') IS NULL
CREATE TABLE dbo.shadow_encoded_write (
cycle_id uniqueidentifier NOT NULL,
write_sequence int NOT NULL,
register_address varchar(16) NOT NULL,
data_type varchar(16) NOT NULL,
values_json nvarchar(max) NOT NULL,
trigger_value bigint NULL,
CONSTRAINT pk_shadow_encoded_write PRIMARY KEY
(cycle_id, write_sequence),
CONSTRAINT fk_shadow_write_cycle FOREIGN KEY (cycle_id)
REFERENCES dbo.shadow_cycle(cycle_id)
);
IF OBJECT_ID(N'dbo.shadow_difference', N'U') IS NULL
CREATE TABLE dbo.shadow_difference (
difference_id bigint IDENTITY(1, 1) NOT NULL PRIMARY KEY,
cycle_id uniqueidentifier NOT NULL,
category varchar(64) NOT NULL,
reason_code nvarchar(512) NOT NULL,
expected_json nvarchar(max) NULL,
actual_json nvarchar(max) NULL,
CONSTRAINT fk_shadow_difference_cycle FOREIGN KEY (cycle_id)
REFERENCES dbo.shadow_cycle(cycle_id)
);
IF OBJECT_ID(N'dbo.shadow_traffic_state', N'U') IS NULL
CREATE TABLE dbo.shadow_traffic_state (
state_key nvarchar(256) NOT NULL PRIMARY KEY,
snapshot_version bigint NOT NULL,
state_json nvarchar(max) NOT NULL,
updated_at datetime2(3) NOT NULL
);
IF OBJECT_ID(N'dbo.shadow_hold_history', N'U') IS NULL
CREATE TABLE dbo.shadow_hold_history (
parent_task_id nvarchar(128) NOT NULL,
vehicle_id nvarchar(64) NOT NULL,
resource_key nvarchar(256) NOT NULL,
reason_code nvarchar(512) NOT NULL,
consecutive_count int NOT NULL,
updated_at datetime2(3) NOT NULL,
CONSTRAINT pk_shadow_hold_history PRIMARY KEY
(parent_task_id, vehicle_id, resource_key, reason_code)
);
IF OBJECT_ID(N'dbo.shadow_startup_check', N'U') IS NULL
CREATE TABLE dbo.shadow_startup_check (
check_id bigint IDENTITY(1, 1) NOT NULL PRIMARY KEY,
check_name varchar(128) NOT NULL,
status varchar(32) NOT NULL,
checked_at datetime2(3) NOT NULL
);
DECLARE @grantEvidence nvarchar(max) = N'GRANT CONNECT TO '
+ @evidencePrincipal + N';';
DECLARE @evidenceTable sysname;
DECLARE evidence_tables CURSOR LOCAL FAST_FORWARD FOR
SELECT name
FROM sys.tables
WHERE schema_id = SCHEMA_ID(N'dbo')
AND name IN (N'shadow_run', N'shadow_cycle', N'shadow_input_batch',
N'shadow_legacy_queue', N'shadow_decision',
N'shadow_encoded_write', N'shadow_difference',
N'shadow_traffic_state', N'shadow_hold_history',
N'shadow_startup_check');
OPEN evidence_tables;
FETCH NEXT FROM evidence_tables INTO @evidenceTable;
WHILE @@FETCH_STATUS = 0
BEGIN
SET @grantEvidence += N'GRANT SELECT, INSERT, UPDATE, DELETE ON OBJECT::dbo.'
+ QUOTENAME(@evidenceTable) + N' TO ' + @evidencePrincipal + N';';
FETCH NEXT FROM evidence_tables INTO @evidenceTable;
END;
CLOSE evidence_tables;
DEALLOCATE evidence_tables;
EXEC sys.sp_executesql @grantEvidence;
DECLARE @databaseContextSet bit = 0;
BEGIN TRY
EXECUTE AS USER = N'$(ShadowEvidenceUser)';
SET @databaseContextSet = 1;
IF EXISTS (
SELECT 1
FROM sys.database_role_members rm
JOIN sys.database_principals member_principal
ON member_principal.principal_id = rm.member_principal_id
WHERE member_principal.name = USER_NAME()
)
THROW 51005, 'The shadow evidence login retains a database role.', 1;
IF EXISTS (
SELECT 1 FROM sys.fn_my_permissions(NULL, N'DATABASE')
WHERE permission_name <> N'CONNECT'
)
THROW 51006, 'The shadow evidence login retains forbidden database permissions.', 1;
IF EXISTS (
SELECT 1
FROM sys.schemas s
CROSS APPLY sys.fn_my_permissions(QUOTENAME(s.name), N'SCHEMA') p
WHERE s.name NOT IN (N'sys', N'INFORMATION_SCHEMA')
)
THROW 51007, 'The shadow evidence login retains forbidden schema permissions.', 1;
IF EXISTS (
SELECT 1
FROM sys.objects o
JOIN sys.schemas s ON s.schema_id = o.schema_id
CROSS APPLY sys.fn_my_permissions(
QUOTENAME(s.name) + N'.' + QUOTENAME(o.name), N'OBJECT') p
WHERE o.is_ms_shipped = 0
AND NOT (
s.name = N'dbo'
AND o.name IN (
N'shadow_run', N'shadow_cycle', N'shadow_input_batch',
N'shadow_legacy_queue', N'shadow_decision',
N'shadow_encoded_write', N'shadow_difference',
N'shadow_traffic_state', N'shadow_hold_history',
N'shadow_startup_check')
AND p.permission_name IN (
N'SELECT', N'INSERT', N'UPDATE', N'DELETE')
)
)
THROW 51008, 'The shadow evidence login retains forbidden object permissions.', 1;
REVERT;
SET @databaseContextSet = 0;
END TRY
BEGIN CATCH
IF @databaseContextSet = 1
REVERT;
THROW;
END CATCH;
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0
ROLLBACK TRANSACTION;
IF @evidenceLoginCreated = 1
BEGIN
DECLARE @dropEvidenceLogin nvarchar(max) =
N'DROP LOGIN ' + QUOTENAME(N'$(ShadowEvidenceLogin)');
EXEC sys.sp_executesql @dropEvidenceLogin;
END;
THROW;
END CATCH;

View File

@@ -0,0 +1,137 @@
:setvar ShadowSourceLogin "ft546_shadow_ro"
:setvar ShadowSourceDatabase "FT546-wcs"
SET NOCOUNT ON;
SET XACT_ABORT ON;
IF DB_ID() <> DB_ID(N'$(ShadowSourceDatabase)')
THROW 51010, 'Run source permission verification only in ShadowSourceDatabase.', 1;
IF SUSER_ID(N'$(ShadowSourceLogin)') IS NULL
THROW 51011, 'ShadowSourceLogin does not exist.', 1;
DECLARE @checks TABLE (
check_name nvarchar(512) NOT NULL,
expected_value int NOT NULL,
actual_value int NULL
);
INSERT INTO @checks (check_name, expected_value, actual_value)
SELECT N'server_role_membership_count', 0, COUNT(*)
FROM sys.server_role_members rm
JOIN sys.server_principals member_principal
ON member_principal.principal_id = rm.member_principal_id
WHERE member_principal.name = N'$(ShadowSourceLogin)';
BEGIN TRY
EXECUTE AS LOGIN = N'$(ShadowSourceLogin)';
DECLARE @objects TABLE (
object_name nvarchar(512) NOT NULL,
expected_select int NOT NULL,
is_base_table bit NOT NULL
);
INSERT INTO @objects (object_name, expected_select, is_base_table)
VALUES
(N'shadow_ro.v_wcs_srgv_queue', 1, 0),
(N'shadow_ro.v_wcs_srgv_queue_finish', 1, 0),
(N'shadow_ro.v_device_srgv', 1, 0),
(N'shadow_ro.v_wcs_srgv_map', 1, 0),
(N'shadow_ro.v_bas_cargo_space', 1, 0),
(N'shadow_ro.v_tsj_execution', 1, 0),
(N'shadow_ro.v_current_database_clock', 1, 0),
(N'dbo.wcs_srgv_queue', 0, 1),
(N'dbo.wcs_srgv_queue_finish', 0, 1),
(N'dbo.device_srgv', 0, 1),
(N'dbo.wcs_srgv_map', 0, 1),
(N'dbo.bas_cargo_space', 0, 1),
(N'dbo.tsj_device_execution_queue', 0, 1);
INSERT INTO @checks (check_name, expected_value, actual_value)
SELECT N'select:' + object_name, expected_select,
HAS_PERMS_BY_NAME(object_name, N'OBJECT', N'SELECT')
FROM @objects;
INSERT INTO @checks (check_name, expected_value, actual_value)
SELECT LOWER(permission_name) + N':' + object_name, 0,
HAS_PERMS_BY_NAME(object_name, N'OBJECT', permission_name)
FROM @objects
CROSS JOIN (VALUES (N'INSERT'), (N'UPDATE'), (N'DELETE'),
(N'ALTER'), (N'CONTROL')) AS operations(permission_name)
WHERE is_base_table = 1;
INSERT INTO @checks (check_name, expected_value, actual_value)
SELECT N'database_permission:' + permission_name, 0,
HAS_PERMS_BY_NAME(DB_NAME(), N'DATABASE', permission_name)
FROM (VALUES (N'SELECT'), (N'INSERT'), (N'UPDATE'), (N'DELETE'),
(N'EXECUTE'), (N'ALTER'), (N'CONTROL'),
(N'TAKE OWNERSHIP')) AS permissions(permission_name);
INSERT INTO @checks (check_name, expected_value, actual_value)
SELECT N'database_role:' + role_name, 0, IS_MEMBER(role_name)
FROM (VALUES (N'db_owner'), (N'db_datareader'),
(N'db_datawriter'), (N'db_ddladmin'),
(N'db_securityadmin'), (N'db_accessadmin'))
AS roles(role_name);
INSERT INTO @checks (check_name, expected_value, actual_value)
SELECT N'forbidden_effective_database_permission_count', 0, COUNT(*)
FROM sys.fn_my_permissions(NULL, N'DATABASE')
WHERE permission_name IN (N'SELECT', N'INSERT', N'UPDATE', N'DELETE',
N'EXECUTE', N'ALTER', N'CONTROL',
N'TAKE OWNERSHIP');
INSERT INTO @checks (check_name, expected_value, actual_value)
SELECT N'forbidden_effective_server_permission_count', 0, COUNT(*)
FROM sys.fn_my_permissions(NULL, N'SERVER')
WHERE permission_name NOT IN (N'CONNECT SQL', N'VIEW ANY DATABASE');
INSERT INTO @checks (check_name, expected_value, actual_value)
SELECT N'forbidden_effective_schema_permission_count', 0, COUNT(*)
FROM sys.schemas s
CROSS APPLY sys.fn_my_permissions(QUOTENAME(s.name), N'SCHEMA') p
WHERE s.name NOT IN (N'sys', N'INFORMATION_SCHEMA')
AND NOT (s.name = N'shadow_ro' AND p.permission_name = N'SELECT');
INSERT INTO @checks (check_name, expected_value, actual_value)
SELECT N'forbidden_effective_object_permission_count', 0, COUNT(*)
FROM sys.objects o
JOIN sys.schemas s ON s.schema_id = o.schema_id
CROSS APPLY sys.fn_my_permissions(
QUOTENAME(s.name) + N'.' + QUOTENAME(o.name), N'OBJECT') p
WHERE o.is_ms_shipped = 0
AND NOT (
s.name = N'shadow_ro'
AND o.name IN (N'v_wcs_srgv_queue', N'v_wcs_srgv_queue_finish',
N'v_device_srgv', N'v_wcs_srgv_map',
N'v_bas_cargo_space', N'v_tsj_execution',
N'v_current_database_clock')
AND p.permission_name = N'SELECT'
);
SELECT TOP (0) * FROM shadow_ro.v_wcs_srgv_queue;
SELECT TOP (0) * FROM shadow_ro.v_wcs_srgv_queue_finish;
SELECT TOP (0) * FROM shadow_ro.v_device_srgv;
SELECT TOP (0) * FROM shadow_ro.v_wcs_srgv_map;
SELECT TOP (0) * FROM shadow_ro.v_bas_cargo_space;
SELECT TOP (0) * FROM shadow_ro.v_tsj_execution;
SELECT TOP (0) * FROM shadow_ro.v_current_database_clock;
REVERT;
END TRY
BEGIN CATCH
IF ORIGINAL_LOGIN() <> SUSER_SNAME()
REVERT;
THROW;
END CATCH;
SELECT check_name, expected_value, actual_value,
CASE WHEN actual_value = expected_value THEN N'PASS' ELSE N'FAIL' END
AS status
FROM @checks
ORDER BY check_name;
IF EXISTS (SELECT 1 FROM @checks
WHERE actual_value IS NULL OR actual_value <> expected_value)
THROW 51012, 'FT546 shadow source permission verification failed.', 1;