feat(readonly): add read-only mode for all database connections
## Background Add a read-only toggle for database connections that blocks all write operations (INSERT, UPDATE, DELETE, DROP, etc.) when enabled, allowing only read queries (SELECT, SHOW, EXPLAIN, etc.). Closes #889. ## Changes ### Data Model - Added `read_only: bool` field to ConnectionConfig (Rust struct, TS interfaces, and ConnectionConfigData deserialization layer) - Used `#[serde(default, skip_serializing_if = "is_false")]` for backward/forward compatibility with existing configs ### Frontend UI - Added "Read Only" checkbox in connection dialog - 7-language i18n support (en/es/it/pt-BR/zh-CN/zh-TW) ### SQL Classification (query_execution_sql.rs) - Added `is_write_sql()` with two-layer defense: - Layer 1: First-keyword check (must start with known read keyword) - Layer 2: Embedded dangerous keyword detection (catches CTE-wrapped writes like `WITH ... AS (DELETE FROM ...)`) - `FROM` keyword supported as DuckDB SELECT-less syntax indicator - Added `check_read_only()` returning descriptive error with connection name - Added 16 unit tests covering: pure reads/writes, CTE, case insensitivity, string literal masking, comment stripping, stored procedure calls, edge cases ### SQL Execution Guards (query.rs / transfer.rs) - Added `check_read_only_for_connection()` and `_multi()` helper functions with lazy name clone (only allocates when read_only is true) - 6 interception points: `do_execute`, MySQL batch, SQL Server batch, DuckDB batch, transaction execution, transfer execution ### Non-SQL Write Guards — Tauri Commands - Added `ensure_connection_writable()` helper (connection.rs) - Mongo: 6 write entry points (insert/update/delete, single + batch) - Redis: 15 write entry points (SET, DEL, HSET, HDEL, LPUSH, LSET, LREM, SADD, SREM, ZADD, ZREM, EXPIRE, FLUSHDB, delete_keys, execute_command) - execute_command uses RedisCommandSafety classification to allow safe read commands through raw command interface - etcd: 2 write entry points (put, delete) - sql_file: SQL file execution guarded - MCP Bridge: 4 write entry points (Insert/Update/Delete/SQL query) ### Non-SQL Write Guards — Web API - Local `ensure_writable()` helper in each route module - Redis: 12 write endpoints including classified execute_command - Mongo: 6 write endpoints - etcd: 2 write endpoints - sql_file, table_import, transfer: early rejection ### Test Updates - Updated ConnectionConfig construction in 7 test files with `read_only: false` initialization ## Defense-in-Depth - Layer 1: Command/Route-level early rejection (saves resources) - Layer 2: Core SQL classifier (`is_write_sql` — first keyword + embedded scan) - Layer 3: Core execution-time interception (do_execute/transfer/transaction)
This commit is contained in:
1 parent
7798f2231e
commit
a440415e86
35 files changed
+597
-24
No files matched your search
@@ -629,3 +629,19 @@ pub async fn refresh_connections(state: State<'_, Arc<AppState>>) -> Result<(),
|
||||
state.refresh_connections().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check whether a connection has read-only protection enabled.
|
||||
/// Returns an error if the connection is read-only, preventing write operations.
|
||||
pub async fn ensure_connection_writable(
|
||||
state: &Arc<AppState>,
|
||||
connection_id: &str,
|
||||
action: &str,
|
||||
) -> Result<(), String> {
|
||||
if let Some(name) = dbx_core::query::connection_readonly_name(state, connection_id).await {
|
||||
return Err(format!(
|
||||
"Read-only mode: connection '{}' has read-only protection enabled. {} blocked.",
|
||||
name, action
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
|
||||
use crate::commands::connection::AppState;
|
||||
use crate::commands::connection::{ensure_connection_writable, AppState};
|
||||
use dbx_core::agent_kv::{KvDeleteResponse, KvGetResponse, KvListPrefixResponse, KvPutResponse, KvValue};
|
||||
|
||||
#[tauri::command]
|
||||
@@ -32,6 +32,7 @@ pub async fn etcd_put(
|
||||
value: KvValue,
|
||||
lease: Option<i64>,
|
||||
) -> Result<KvPutResponse, String> {
|
||||
ensure_connection_writable(&state, &connection_id, "Put").await?;
|
||||
dbx_core::agent_kv::kv_put_core(&state, &connection_id, &key, value, lease).await
|
||||
}
|
||||
|
||||
@@ -41,5 +42,6 @@ pub async fn etcd_delete(
|
||||
connection_id: String,
|
||||
key: String,
|
||||
) -> Result<KvDeleteResponse, String> {
|
||||
ensure_connection_writable(&state, &connection_id, "Delete").await?;
|
||||
dbx_core::agent_kv::kv_delete_core(&state, &connection_id, &key).await
|
||||
}
|
||||
@@ -6,6 +6,8 @@ use tokio::net::TcpListener;
|
||||
|
||||
use super::connection::AppState;
|
||||
|
||||
use super::connection::ensure_connection_writable;
|
||||
|
||||
const BIND_ADDR: &str = "127.0.0.1:0";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -225,7 +227,7 @@ async fn resolve_mongo_pool_key(
|
||||
connection_name: &str,
|
||||
database: Option<String>,
|
||||
stream: &mut tokio::net::TcpStream,
|
||||
) -> Option<(String, String)> {
|
||||
) -> Option<(String, String, String)> {
|
||||
let config = match resolve_connection(state, connection_name).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
@@ -233,6 +235,7 @@ async fn resolve_mongo_pool_key(
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let connection_id = config.id.clone();
|
||||
let database = database.unwrap_or_else(|| config.database.clone().unwrap_or_default());
|
||||
let pool_key = match state.get_or_create_pool(&config.id, Some(&database)).await {
|
||||
Ok(key) => key,
|
||||
@@ -241,7 +244,7 @@ async fn resolve_mongo_pool_key(
|
||||
return None;
|
||||
}
|
||||
};
|
||||
Some((pool_key, database))
|
||||
Some((pool_key, database, connection_id))
|
||||
}
|
||||
|
||||
async fn handle_open_table(app: &AppHandle, state: &Arc<AppState>, body: &str, stream: &mut tokio::net::TcpStream) {
|
||||
@@ -365,7 +368,8 @@ async fn handle_mongo_list_collections_data(state: &Arc<AppState>, body: &str, s
|
||||
return;
|
||||
}
|
||||
};
|
||||
let Some((pool_key, database)) = resolve_mongo_pool_key(state, &req.connection_name, req.database, stream).await
|
||||
let Some((pool_key, database, _connection_id)) =
|
||||
resolve_mongo_pool_key(state, &req.connection_name, req.database, stream).await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
@@ -383,7 +387,8 @@ async fn handle_mongo_find_documents_data(state: &Arc<AppState>, body: &str, str
|
||||
return;
|
||||
}
|
||||
};
|
||||
let Some((pool_key, database)) = resolve_mongo_pool_key(state, &req.connection_name, req.database, stream).await
|
||||
let Some((pool_key, database, _connection_id)) =
|
||||
resolve_mongo_pool_key(state, &req.connection_name, req.database, stream).await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
@@ -412,7 +417,8 @@ async fn handle_mongo_aggregate_documents_data(state: &Arc<AppState>, body: &str
|
||||
return;
|
||||
}
|
||||
};
|
||||
let Some((pool_key, database)) = resolve_mongo_pool_key(state, &req.connection_name, req.database, stream).await
|
||||
let Some((pool_key, database, _connection_id)) =
|
||||
resolve_mongo_pool_key(state, &req.connection_name, req.database, stream).await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
@@ -439,10 +445,15 @@ async fn handle_mongo_insert_documents_data(state: &Arc<AppState>, body: &str, s
|
||||
return;
|
||||
}
|
||||
};
|
||||
let Some((pool_key, database)) = resolve_mongo_pool_key(state, &req.connection_name, req.database, stream).await
|
||||
let Some((pool_key, database, connection_id)) =
|
||||
resolve_mongo_pool_key(state, &req.connection_name, req.database, stream).await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = ensure_connection_writable(state, &connection_id, "Insert").await {
|
||||
respond_error(stream, "403 Forbidden", &e).await;
|
||||
return;
|
||||
}
|
||||
match dbx_core::mongo_ops::mongo_insert_documents_core(state, &pool_key, &database, &req.collection, &req.docs_json)
|
||||
.await
|
||||
{
|
||||
@@ -459,10 +470,15 @@ async fn handle_mongo_update_documents_data(state: &Arc<AppState>, body: &str, s
|
||||
return;
|
||||
}
|
||||
};
|
||||
let Some((pool_key, database)) = resolve_mongo_pool_key(state, &req.connection_name, req.database, stream).await
|
||||
let Some((pool_key, database, connection_id)) =
|
||||
resolve_mongo_pool_key(state, &req.connection_name, req.database, stream).await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = ensure_connection_writable(state, &connection_id, "Update").await {
|
||||
respond_error(stream, "403 Forbidden", &e).await;
|
||||
return;
|
||||
}
|
||||
match dbx_core::mongo_ops::mongo_update_documents_core(
|
||||
state,
|
||||
&pool_key,
|
||||
@@ -487,10 +503,15 @@ async fn handle_mongo_delete_documents_data(state: &Arc<AppState>, body: &str, s
|
||||
return;
|
||||
}
|
||||
};
|
||||
let Some((pool_key, database)) = resolve_mongo_pool_key(state, &req.connection_name, req.database, stream).await
|
||||
let Some((pool_key, database, connection_id)) =
|
||||
resolve_mongo_pool_key(state, &req.connection_name, req.database, stream).await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = ensure_connection_writable(state, &connection_id, "Delete").await {
|
||||
respond_error(stream, "403 Forbidden", &e).await;
|
||||
return;
|
||||
}
|
||||
match dbx_core::mongo_ops::mongo_delete_documents_core(
|
||||
state,
|
||||
&pool_key,
|
||||
@@ -526,6 +547,11 @@ async fn handle_execute_query_data(state: &Arc<AppState>, body: &str, stream: &m
|
||||
respond_error(stream, "403 Forbidden", &e).await;
|
||||
return;
|
||||
}
|
||||
// Read-only check: reject if the connection has read-only protection and the SQL is a write
|
||||
if let Err(e) = dbx_core::query::check_read_only_for_connection(state, &config.id, &req.sql).await {
|
||||
respond_error(stream, "403 Forbidden", &e).await;
|
||||
return;
|
||||
}
|
||||
match dbx_core::query::execute_sql_statement(state, &config.id, &database, &req.sql, req.schema.as_deref(), None)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
|
||||
use crate::commands::connection::AppState;
|
||||
use crate::commands::connection::{ensure_connection_writable, AppState};
|
||||
use dbx_core::db::mongo_driver::MongoDocumentResult;
|
||||
|
||||
#[tauri::command]
|
||||
@@ -74,6 +74,7 @@ pub async fn mongo_insert_document(
|
||||
collection: String,
|
||||
doc_json: String,
|
||||
) -> Result<String, String> {
|
||||
ensure_connection_writable(&state, &connection_id, "Insert").await?;
|
||||
dbx_core::mongo_ops::mongo_insert_document_core(&state, &connection_id, &database, &collection, &doc_json).await
|
||||
}
|
||||
|
||||
@@ -85,6 +86,7 @@ pub async fn mongo_insert_documents(
|
||||
collection: String,
|
||||
docs_json: String,
|
||||
) -> Result<u64, String> {
|
||||
ensure_connection_writable(&state, &connection_id, "Insert").await?;
|
||||
dbx_core::mongo_ops::mongo_insert_documents_core(&state, &connection_id, &database, &collection, &docs_json).await
|
||||
}
|
||||
|
||||
@@ -97,6 +99,7 @@ pub async fn mongo_update_document(
|
||||
id: String,
|
||||
doc_json: String,
|
||||
) -> Result<u64, String> {
|
||||
ensure_connection_writable(&state, &connection_id, "Update").await?;
|
||||
dbx_core::mongo_ops::mongo_update_document_core(&state, &connection_id, &database, &collection, &id, &doc_json)
|
||||
.await
|
||||
}
|
||||
@@ -111,6 +114,7 @@ pub async fn mongo_update_documents(
|
||||
update_json: String,
|
||||
many: bool,
|
||||
) -> Result<u64, String> {
|
||||
ensure_connection_writable(&state, &connection_id, "Update").await?;
|
||||
dbx_core::mongo_ops::mongo_update_documents_core(
|
||||
&state,
|
||||
&connection_id,
|
||||
@@ -131,6 +135,7 @@ pub async fn mongo_delete_document(
|
||||
collection: String,
|
||||
id: String,
|
||||
) -> Result<u64, String> {
|
||||
ensure_connection_writable(&state, &connection_id, "Delete").await?;
|
||||
dbx_core::mongo_ops::mongo_delete_document_core(&state, &connection_id, &database, &collection, &id).await
|
||||
}
|
||||
|
||||
@@ -143,6 +148,7 @@ pub async fn mongo_delete_documents(
|
||||
filter_json: String,
|
||||
many: bool,
|
||||
) -> Result<u64, String> {
|
||||
ensure_connection_writable(&state, &connection_id, "Delete").await?;
|
||||
dbx_core::mongo_ops::mongo_delete_documents_core(&state, &connection_id, &database, &collection, &filter_json, many)
|
||||
.await
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
|
||||
use crate::commands::connection::AppState;
|
||||
use dbx_core::db::redis_driver::{RedisCommandResult, RedisDatabaseInfo, RedisScanResult, RedisValue};
|
||||
use crate::commands::connection::{ensure_connection_writable, AppState};
|
||||
use dbx_core::db::redis_driver::{
|
||||
RedisCommandResult, RedisCommandSafety, RedisDatabaseInfo, RedisScanResult, RedisValue,
|
||||
};
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn redis_list_databases(
|
||||
@@ -56,6 +58,7 @@ pub async fn redis_set_string(
|
||||
value: String,
|
||||
ttl: Option<i64>,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(&state, &connection_id, "SET").await?;
|
||||
dbx_core::redis_ops::redis_set_string_in_db_core(&state, &connection_id, db, &key_raw, &value, ttl).await
|
||||
}
|
||||
|
||||
@@ -66,6 +69,7 @@ pub async fn redis_delete_key(
|
||||
db: u32,
|
||||
key_raw: String,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(&state, &connection_id, "Delete key").await?;
|
||||
dbx_core::redis_ops::redis_delete_key_in_db_core(&state, &connection_id, db, &key_raw).await
|
||||
}
|
||||
|
||||
@@ -78,6 +82,7 @@ pub async fn redis_hash_set(
|
||||
field: String,
|
||||
value: String,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(&state, &connection_id, "HSET").await?;
|
||||
dbx_core::redis_ops::redis_hash_set_in_db_core(&state, &connection_id, db, &key_raw, &field, &value).await
|
||||
}
|
||||
|
||||
@@ -89,6 +94,7 @@ pub async fn redis_hash_del(
|
||||
key_raw: String,
|
||||
field: String,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(&state, &connection_id, "HDEL").await?;
|
||||
dbx_core::redis_ops::redis_hash_del_in_db_core(&state, &connection_id, db, &key_raw, &field).await
|
||||
}
|
||||
|
||||
@@ -100,6 +106,7 @@ pub async fn redis_list_push(
|
||||
key_raw: String,
|
||||
value: String,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(&state, &connection_id, "LPUSH").await?;
|
||||
dbx_core::redis_ops::redis_list_push_in_db_core(&state, &connection_id, db, &key_raw, &value).await
|
||||
}
|
||||
|
||||
@@ -112,6 +119,7 @@ pub async fn redis_list_set(
|
||||
index: i64,
|
||||
value: String,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(&state, &connection_id, "LSET").await?;
|
||||
dbx_core::redis_ops::redis_list_set_in_db_core(&state, &connection_id, db, &key_raw, index, &value).await
|
||||
}
|
||||
|
||||
@@ -123,6 +131,7 @@ pub async fn redis_list_remove(
|
||||
key_raw: String,
|
||||
index: i64,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(&state, &connection_id, "LREM").await?;
|
||||
dbx_core::redis_ops::redis_list_remove_in_db_core(&state, &connection_id, db, &key_raw, index).await
|
||||
}
|
||||
|
||||
@@ -134,6 +143,7 @@ pub async fn redis_set_add(
|
||||
key_raw: String,
|
||||
member: String,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(&state, &connection_id, "SADD").await?;
|
||||
dbx_core::redis_ops::redis_set_add_in_db_core(&state, &connection_id, db, &key_raw, &member).await
|
||||
}
|
||||
|
||||
@@ -145,6 +155,7 @@ pub async fn redis_set_remove(
|
||||
key_raw: String,
|
||||
member: String,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(&state, &connection_id, "SREM").await?;
|
||||
dbx_core::redis_ops::redis_set_remove_in_db_core(&state, &connection_id, db, &key_raw, &member).await
|
||||
}
|
||||
|
||||
@@ -157,6 +168,7 @@ pub async fn redis_zadd(
|
||||
member: String,
|
||||
score: f64,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(&state, &connection_id, "ZADD").await?;
|
||||
dbx_core::redis_ops::redis_zadd_in_db_core(&state, &connection_id, db, &key_raw, &member, score).await
|
||||
}
|
||||
|
||||
@@ -168,6 +180,7 @@ pub async fn redis_zrem(
|
||||
key_raw: String,
|
||||
member: String,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(&state, &connection_id, "ZREM").await?;
|
||||
dbx_core::redis_ops::redis_zrem_in_db_core(&state, &connection_id, db, &key_raw, &member).await
|
||||
}
|
||||
|
||||
@@ -179,6 +192,7 @@ pub async fn redis_set_ttl(
|
||||
key_raw: String,
|
||||
ttl: i64,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(&state, &connection_id, "EXPIRE").await?;
|
||||
dbx_core::redis_ops::redis_set_ttl_in_db_core(&state, &connection_id, db, &key_raw, ttl).await
|
||||
}
|
||||
|
||||
@@ -189,11 +203,13 @@ pub async fn redis_delete_keys(
|
||||
db: u32,
|
||||
key_raws: Vec<String>,
|
||||
) -> Result<u64, String> {
|
||||
ensure_connection_writable(&state, &connection_id, "Delete keys").await?;
|
||||
dbx_core::redis_ops::redis_delete_keys_in_db_core(&state, &connection_id, db, &key_raws).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn redis_flush_db(state: State<'_, Arc<AppState>>, connection_id: String, db: u32) -> Result<(), String> {
|
||||
ensure_connection_writable(&state, &connection_id, "FLUSHDB").await?;
|
||||
dbx_core::redis_ops::redis_flush_db_core(&state, &connection_id, db).await
|
||||
}
|
||||
|
||||
@@ -204,6 +220,16 @@ pub async fn redis_execute_command(
|
||||
db: u32,
|
||||
command: String,
|
||||
) -> Result<RedisCommandResult, String> {
|
||||
// In read-only mode, only allow safe read commands through the raw command interface
|
||||
if let Some(name) = dbx_core::query::connection_readonly_name(&state, &connection_id).await {
|
||||
let cmd_name = command.split_whitespace().next().unwrap_or("");
|
||||
if dbx_core::db::redis_driver::classify_command(cmd_name) != RedisCommandSafety::Allowed {
|
||||
return Err(format!(
|
||||
"Read-only mode: connection '{}' has read-only protection enabled. Command '{}' blocked.",
|
||||
name, cmd_name
|
||||
));
|
||||
}
|
||||
}
|
||||
dbx_core::redis_ops::redis_execute_command_core(&state, &connection_id, db, &command).await
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ use tauri::{AppHandle, Emitter, State};
|
||||
use tokio::sync::RwLock;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::commands::connection::AppState;
|
||||
use crate::commands::connection::{ensure_connection_writable, AppState};
|
||||
use dbx_core::sql_file_import::{execute_sql_file_content, sql_file_error_progress, sql_file_progress};
|
||||
|
||||
pub use dbx_core::sql::{decode_sql_file_bytes, SqlFilePreview, SqlFileRequest, SqlFileStatus};
|
||||
@@ -48,6 +48,8 @@ pub async fn execute_sql_file(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
request: SqlFileRequest,
|
||||
) -> Result<(), String> {
|
||||
// Fast-fail: reject early if the connection is read-only (individual statements are also checked in do_execute)
|
||||
ensure_connection_writable(&state, &request.connection_id, "SQL file execution").await?;
|
||||
let token = CancellationToken::new();
|
||||
{
|
||||
let mut executions = sql_file_executions().write().await;
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::sync::{Arc, OnceLock};
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::commands::connection::AppState;
|
||||
use crate::commands::connection::{ensure_connection_writable, AppState};
|
||||
use crate::commands::transfer::get_db_type;
|
||||
|
||||
// Re-export types for backward compatibility
|
||||
@@ -40,6 +40,8 @@ pub async fn import_table_file(
|
||||
request: TableImportRequest,
|
||||
) -> Result<TableImportSummary, String> {
|
||||
clear_cancelled(&request.import_id).await;
|
||||
// Reject import early if the connection is read-only — importing is inherently a write operation
|
||||
ensure_connection_writable(&state, &request.connection_id, "Import").await?;
|
||||
let db_type = get_db_type(&state, &request.connection_id).await?;
|
||||
let pool_key = if request.database.is_empty() {
|
||||
request.connection_id.clone()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
use crate::commands::connection::AppState;
|
||||
use crate::commands::connection::{ensure_connection_writable, AppState};
|
||||
|
||||
// Re-export types and functions used by other modules
|
||||
pub use dbx_core::transfer::{get_db_type, TransferProgress, TransferRequest, TransferStatus};
|
||||
@@ -19,6 +19,9 @@ pub async fn start_transfer(
|
||||
let state = state.inner().clone();
|
||||
let transfer_id = request.transfer_id.clone();
|
||||
|
||||
// Reject transfer early if the target connection is read-only — writing to it is inherently required
|
||||
ensure_connection_writable(&state, &request.target_connection_id, "Transfer").await?;
|
||||
|
||||
// Validate connections exist
|
||||
let source_db_type = get_db_type(&state, &request.source_connection_id).await?;
|
||||
let target_db_type = get_db_type(&state, &request.target_connection_id).await?;
|
||||
|
||||
Reference in new issue
Block a user