feat(mcp): centralize connection access policies
This commit is contained in:
56 files changed
+6393
-617
No files matched your search
@@ -3,7 +3,7 @@ use std::{
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use dbx_core::storage::DesktopSettings;
|
||||
use dbx_core::storage::{DesktopSettings, McpGlobalPolicy, McpGlobalPolicyState};
|
||||
use tauri::{AppHandle, Manager, State, Window};
|
||||
|
||||
use super::connection::AppState;
|
||||
@@ -75,6 +75,16 @@ pub async fn save_pinned_tree_node_ids(state: State<'_, Arc<AppState>>, ids: Vec
|
||||
state.storage.save_pinned_tree_node_ids(&ids).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn load_mcp_global_policy(state: State<'_, Arc<AppState>>) -> Result<McpGlobalPolicyState, String> {
|
||||
state.storage.load_mcp_global_policy().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_mcp_global_policy(state: State<'_, Arc<AppState>>, policy: McpGlobalPolicy) -> Result<(), String> {
|
||||
state.storage.save_mcp_global_policy(&policy).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn load_editor_settings(state: State<'_, Arc<AppState>>) -> Result<Option<serde_json::Value>, String> {
|
||||
state.storage.load_editor_settings().await
|
||||
|
||||
@@ -7,10 +7,12 @@ use tokio::net::TcpListener;
|
||||
|
||||
use super::connection::AppState;
|
||||
|
||||
use super::connection::ensure_connection_writable;
|
||||
use dbx_core::storage::McpGlobalPolicy;
|
||||
|
||||
const BIND_ADDR: &str = "127.0.0.1:0";
|
||||
const MCP_BRIDGE_PORT_FILE: &str = "mcp-bridge-port";
|
||||
const MCP_EXECUTE_AND_SHOW_SQL_ONLY: &str =
|
||||
"UNSUPPORTED_OPERATION: MCP execute-and-show only supports SQL connections.";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OpenTableRequest {
|
||||
@@ -28,8 +30,6 @@ struct ExecuteQueryRequest {
|
||||
database: Option<String>,
|
||||
sql: String,
|
||||
schema: Option<String>,
|
||||
allow_writes: Option<bool>,
|
||||
allow_dangerous: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -174,7 +174,8 @@ struct RedisCommandRequest {
|
||||
connection_id: Option<String>,
|
||||
db: u32,
|
||||
command: String,
|
||||
skip_safety_check: Option<bool>,
|
||||
#[serde(rename = "skip_safety_check")]
|
||||
_skip_safety_check: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
@@ -190,8 +191,7 @@ pub struct McpExecuteQueryEvent {
|
||||
pub connection_id: String,
|
||||
pub database: String,
|
||||
pub sql: String,
|
||||
pub allow_writes: bool,
|
||||
pub allow_dangerous: bool,
|
||||
pub results: Vec<dbx_core::db::QueryResult>,
|
||||
}
|
||||
|
||||
pub fn start(app_handle: AppHandle, state: Arc<AppState>, data_dir: PathBuf) {
|
||||
@@ -290,6 +290,206 @@ fn find_config_by_name<'a>(
|
||||
configs.iter().find(|c| c.name.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
ensure_connection_in_mcp_scope, ensure_mcp_connection_sql_write_allowed, ensure_mcp_execute_and_show_supported,
|
||||
ensure_mcp_sql_database_switch_allowed, mongo_filter_is_effectively_unbounded, mongo_pipeline_has_write_stage,
|
||||
resolve_connection, resolve_mongo_database, resolve_mongo_target_values, write_port_file, AppState,
|
||||
};
|
||||
use dbx_core::models::connection::{ConnectionConfig, DatabaseType};
|
||||
use dbx_core::storage::{McpGlobalPolicy, Storage};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn mysql_config(read_only: bool) -> ConnectionConfig {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"id": "readonly-connection",
|
||||
"name": "Read-only connection",
|
||||
"db_type": "mysql",
|
||||
"host": "localhost",
|
||||
"port": 3306,
|
||||
"username": "tester",
|
||||
"password": "",
|
||||
"database": "test",
|
||||
"read_only": read_only
|
||||
}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_bridge_port_file_to_resolved_data_dir() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"dbx-mcp-bridge-port-test-{}-{}",
|
||||
std::process::id(),
|
||||
uuid::Uuid::new_v4()
|
||||
));
|
||||
let default_data_dir = root.join("default-app-data");
|
||||
let resolved_data_dir = root.join("resolved-data");
|
||||
std::fs::create_dir_all(&default_data_dir).unwrap();
|
||||
|
||||
let port_file = write_port_file(&resolved_data_dir, 49152).unwrap();
|
||||
|
||||
assert_eq!(port_file, resolved_data_dir.join("mcp-bridge-port"));
|
||||
assert_eq!(std::fs::read_to_string(port_file).unwrap(), "49152");
|
||||
assert!(!default_data_dir.join("mcp-bridge-port").exists());
|
||||
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mongo_database_uses_configured_default_for_missing_or_blank_request() {
|
||||
let configured = Some("sample_db".to_string());
|
||||
|
||||
assert_eq!(resolve_mongo_database(None, configured.clone()), "sample_db");
|
||||
assert_eq!(resolve_mongo_database(Some(String::new()), configured.clone()), "sample_db");
|
||||
assert_eq!(resolve_mongo_database(Some(" ".to_string()), configured), "sample_db");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mongo_database_preserves_explicit_target() {
|
||||
assert_eq!(resolve_mongo_database(Some("admin".to_string()), Some("sample_db".to_string())), "admin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mongo_target_keeps_connection_id_separate_from_database() {
|
||||
assert_eq!(
|
||||
resolve_mongo_target_values(
|
||||
"connection-id".to_string(),
|
||||
Some("sample_db".to_string()),
|
||||
Some("default_db".to_string()),
|
||||
),
|
||||
("connection-id".to_string(), "sample_db".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_and_show_accepts_sql_connections_only() {
|
||||
assert!(ensure_mcp_execute_and_show_supported(&DatabaseType::Mysql).is_ok());
|
||||
assert!(ensure_mcp_execute_and_show_supported(&DatabaseType::MongoDb).is_err());
|
||||
assert!(ensure_mcp_execute_and_show_supported(&DatabaseType::Redis).is_err());
|
||||
assert!(ensure_mcp_execute_and_show_supported(&DatabaseType::Elasticsearch).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_sql_checks_supplied_connection_read_only_flag() {
|
||||
let mut config = mysql_config(true);
|
||||
|
||||
assert!(ensure_mcp_connection_sql_write_allowed(&config, false).is_ok());
|
||||
let error = ensure_mcp_connection_sql_write_allowed(&config, true).unwrap_err();
|
||||
assert!(error.starts_with("CONNECTION_READ_ONLY:"));
|
||||
|
||||
config.read_only = false;
|
||||
assert!(ensure_mcp_connection_sql_write_allowed(&config, true).is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_connection_refreshes_read_only_from_storage() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"dbx-mcp-bridge-connection-refresh-test-{}-{}",
|
||||
std::process::id(),
|
||||
uuid::Uuid::new_v4()
|
||||
));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let storage = Storage::open(&root.join("storage.db")).await.unwrap();
|
||||
let mut config = mysql_config(false);
|
||||
storage.save_connections(&[config.clone()]).await.unwrap();
|
||||
let state = Arc::new(AppState::new_with_plugin_dir(storage, root.join("plugins")));
|
||||
|
||||
let initial = resolve_connection(&state, Some(&config.id), &config.name).await.unwrap();
|
||||
assert!(!initial.read_only);
|
||||
|
||||
config.read_only = true;
|
||||
state.storage.save_connections(&[config.clone()]).await.unwrap();
|
||||
let refreshed = resolve_connection(&state, Some(&config.id), &config.name).await.unwrap();
|
||||
assert!(refreshed.read_only);
|
||||
|
||||
drop(state);
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_sql_rejects_persistent_database_switches() {
|
||||
assert!(ensure_mcp_sql_database_switch_allowed(DatabaseType::Mysql, "SELECT 1").is_ok());
|
||||
assert_eq!(
|
||||
ensure_mcp_sql_database_switch_allowed(DatabaseType::Mysql, "USE production").unwrap_err(),
|
||||
"SQL_BLOCKED: MCP does not allow USE or persistent database switching."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_allowlist_distinguishes_all_subset_and_none() {
|
||||
let all = McpGlobalPolicy { read_only: false, allow_dangerous_sql: false, allowed_connection_ids: None };
|
||||
assert!(ensure_connection_in_mcp_scope(&all, "conn-1").is_ok());
|
||||
|
||||
let subset = McpGlobalPolicy {
|
||||
read_only: false,
|
||||
allow_dangerous_sql: false,
|
||||
allowed_connection_ids: Some(vec!["conn-1".to_string()]),
|
||||
};
|
||||
assert!(ensure_connection_in_mcp_scope(&subset, "conn-1").is_ok());
|
||||
assert!(ensure_connection_in_mcp_scope(&subset, "conn-2").unwrap_err().starts_with("CONNECTION_OUT_OF_SCOPE:"));
|
||||
|
||||
let none =
|
||||
McpGlobalPolicy { read_only: false, allow_dangerous_sql: false, allowed_connection_ids: Some(Vec::new()) };
|
||||
assert!(ensure_connection_in_mcp_scope(&none, "conn-1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mongo_aggregate_write_stages_are_detected_structurally() {
|
||||
assert!(mongo_pipeline_has_write_stage(r#"[{"$match":{}},{"$out":"archive"}]"#));
|
||||
assert!(mongo_pipeline_has_write_stage(r#"[{"$merge":{"into":"archive"}}]"#));
|
||||
assert!(!mongo_pipeline_has_write_stage(r#"[{"$project":{"label":"$out"}}]"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mongo_filters_distinguish_guarded_and_unbounded_writes() {
|
||||
for filter in [
|
||||
"{}",
|
||||
r#"{"$comment":"all rows"}"#,
|
||||
r#"{"$expr":true}"#,
|
||||
r#"{"$or":[{}, {"id":1}]}"#,
|
||||
r#"{"$nor":[{"$expr":false}]}"#,
|
||||
r#"{"$or":[{"id":{"$exists":true}},{"id":{"$exists":false}}]}"#,
|
||||
r#"{"$or":[{"id":{"$exists":true}},{"id":{"$not":{"$exists":true}}}]}"#,
|
||||
r#"{"$or":[{"id":{"$eq":1}},{"id":{"$ne":1}}]}"#,
|
||||
r#"{"$or":[{"$and":[{"id":{"$eq":1}}]},{"id":{"$ne":1}}]}"#,
|
||||
r#"{"$or":[{"$and":[{"id":{"$eq":1}},{}]},{"id":{"$ne":1}}]}"#,
|
||||
r#"{"$or":[{"$and":[{"id":{"$eq":1}},{"x":{"$exists":true}}]},{"id":{"$ne":1}},{"x":{"$exists":false}}]}"#,
|
||||
r#"{"$or":[{"id":1},{"id":{"$ne":1}}]}"#,
|
||||
r#"{"$or":[{"id":{"$gt":1}},{"id":{"$lte":1}}]}"#,
|
||||
r#"{"$or":[{"id":{"$gte":1}},{"id":{"$lt":1}}]}"#,
|
||||
r#"{"$or":[{"id":{"$in":[1,2]}},{"id":{"$nin":[2,1]}}]}"#,
|
||||
r#"{"_id":{"$exists":true}}"#,
|
||||
r#"{"id":{"$nin":[]}}"#,
|
||||
r#"{"_id":{"$oid":"not-an-object-id"}}"#,
|
||||
r#"{"sequence":{"$numberLong":"9223372036854775808"}}"#,
|
||||
r#"{"created_at":{"$date":"2026-02-30T00:00:00Z"}}"#,
|
||||
r#"{"name":{"$regex":".*"}}"#,
|
||||
r#"{"$or":[{"_id":{"$oid":"507f1f77bcf86cd799439011"}},{"_id":{"$ne":{"$oid":"507f1f77bcf86cd799439011"}}}]}"#,
|
||||
r#"{"$and":[{"tenant_id":1},{"$nor":[{"archived":true}]}]}"#,
|
||||
r#"{"$or":[]}"#,
|
||||
r#"{"$opaque":[{"id":1}]}"#,
|
||||
] {
|
||||
assert!(mongo_filter_is_effectively_unbounded(filter), "{filter}");
|
||||
}
|
||||
for filter in [
|
||||
r#"{"id":1}"#,
|
||||
r#"{"created_at":{"$gte":"2026-01-01"}}"#,
|
||||
r#"{"$and":[{}, {"tenant_id":1}]}"#,
|
||||
r#"{"$or":[{"tenant_id":1},{"tenant_id":2}]}"#,
|
||||
r#"{"id":{"$ne":1}}"#,
|
||||
r#"{"id":{"$in":[1,2]}}"#,
|
||||
r#"{"id":{"$exists":true}}"#,
|
||||
r#"{"_id":{"$oid":"507f1f77bcf86cd799439011"}}"#,
|
||||
r#"{"sequence":{"$numberLong":"9223372036854775807"}}"#,
|
||||
r#"{"created_at":{"$date":"2026-01-01T00:00:00.000Z"}}"#,
|
||||
r#"{"tenant_id":1,"id":{"$nin":[]}}"#,
|
||||
] {
|
||||
assert!(!mongo_filter_is_effectively_unbounded(filter), "{filter}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn respond(stream: &mut tokio::net::TcpStream, status: &str, body: &str) {
|
||||
let resp = format!("HTTP/1.1 {status}\r\nContent-Length: {}\r\n\r\n{body}", body.len());
|
||||
let _ = stream.write_all(resp.as_bytes()).await;
|
||||
@@ -314,12 +514,14 @@ async fn resolve_connection(
|
||||
connection_id: Option<&str>,
|
||||
connection_name: &str,
|
||||
) -> Result<crate::models::connection::ConnectionConfig, String> {
|
||||
let configs = state.storage.load_connections().await.map_err(|e| e.to_string())?;
|
||||
let policy = load_mcp_policy(state).await?;
|
||||
let configs = state.storage.load_connections().await.map_err(|e| mcp_policy_unavailable(e.to_string()))?;
|
||||
let config = if let Some(id) = connection_id.filter(|s| !s.is_empty()) {
|
||||
configs.iter().find(|c| c.id == id).ok_or_else(|| format!("Connection with id '{}' not found", id))?
|
||||
} else {
|
||||
find_config_by_name(&configs, connection_name).ok_or_else(|| "Connection not found".to_string())?
|
||||
};
|
||||
ensure_connection_in_mcp_scope(&policy, &config.id)?;
|
||||
let mut state_configs = state.configs.write().await;
|
||||
if !state_configs.contains_key(&config.id) {
|
||||
state_configs.insert(config.id.clone(), config.clone());
|
||||
@@ -328,6 +530,422 @@ async fn resolve_connection(
|
||||
Ok(config.clone())
|
||||
}
|
||||
|
||||
async fn load_mcp_policy(state: &Arc<AppState>) -> Result<McpGlobalPolicy, String> {
|
||||
state
|
||||
.storage
|
||||
.load_mcp_global_policy()
|
||||
.await
|
||||
.map(|state| state.policy())
|
||||
.map_err(|error| mcp_policy_unavailable(error.to_string()))
|
||||
}
|
||||
|
||||
fn mcp_policy_unavailable(error: String) -> String {
|
||||
if error.starts_with("MCP_POLICY_UNAVAILABLE:") {
|
||||
error
|
||||
} else {
|
||||
format!("MCP_POLICY_UNAVAILABLE: {error}")
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_connection_in_mcp_scope(policy: &McpGlobalPolicy, connection_id: &str) -> Result<(), String> {
|
||||
if policy.allowed_connection_ids.as_ref().is_some_and(|allowed| !allowed.iter().any(|id| id == connection_id)) {
|
||||
return Err(format!(
|
||||
"CONNECTION_OUT_OF_SCOPE: connection '{connection_id}' is not allowed by DBX MCP settings"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_mcp_write_allowed(
|
||||
state: &Arc<AppState>,
|
||||
config: &crate::models::connection::ConnectionConfig,
|
||||
database: &str,
|
||||
action: &str,
|
||||
) -> Result<(), String> {
|
||||
ensure_mcp_write_allowed_with_risk(state, config, database, action, false).await
|
||||
}
|
||||
|
||||
async fn ensure_mcp_write_allowed_with_risk(
|
||||
state: &Arc<AppState>,
|
||||
config: &crate::models::connection::ConnectionConfig,
|
||||
database: &str,
|
||||
action: &str,
|
||||
dangerous: bool,
|
||||
) -> Result<(), String> {
|
||||
let policy = load_mcp_policy(state).await?;
|
||||
ensure_connection_in_mcp_scope(&policy, &config.id)?;
|
||||
if policy.read_only {
|
||||
return Err(format!("MCP_READ_ONLY: DBX MCP read-only mode is enabled. {action} blocked."));
|
||||
}
|
||||
if dangerous && !policy.allow_dangerous_sql {
|
||||
return Err(format!("SQL_BLOCKED: High-risk operation '{action}' is disabled in DBX MCP settings."));
|
||||
}
|
||||
if config.read_only {
|
||||
return Err(format!(
|
||||
"CONNECTION_READ_ONLY: connection '{}' has read-only protection enabled. {action} blocked.",
|
||||
config.name
|
||||
));
|
||||
}
|
||||
if dbx_core::production_safety::is_production_database(config, database) {
|
||||
return Err(format!("PRODUCTION_DATABASE_READ_ONLY: {action} blocked for production database '{database}'."));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_mcp_read_allowed_by_id(
|
||||
state: &Arc<AppState>,
|
||||
connection_id: &str,
|
||||
database: &str,
|
||||
) -> Result<(), String> {
|
||||
let policy = load_mcp_policy(state).await?;
|
||||
ensure_connection_in_mcp_scope(&policy, connection_id)?;
|
||||
let configs = state.storage.load_connections().await.map_err(|e| format!("MCP_POLICY_UNAVAILABLE: {e}"))?;
|
||||
let config = configs
|
||||
.iter()
|
||||
.find(|config| config.id == connection_id)
|
||||
.ok_or_else(|| format!("Connection with id '{connection_id}' not found"))?;
|
||||
check_visible_database(config, database)
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_mcp_write_allowed_by_id(
|
||||
state: &Arc<AppState>,
|
||||
connection_id: &str,
|
||||
database: &str,
|
||||
action: &str,
|
||||
) -> Result<(), String> {
|
||||
ensure_mcp_write_allowed_by_id_with_risk(state, connection_id, database, action, false).await
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_mcp_dangerous_write_allowed_by_id(
|
||||
state: &Arc<AppState>,
|
||||
connection_id: &str,
|
||||
database: &str,
|
||||
action: &str,
|
||||
) -> Result<(), String> {
|
||||
ensure_mcp_write_allowed_by_id_with_risk(state, connection_id, database, action, true).await
|
||||
}
|
||||
|
||||
async fn ensure_mcp_mongo_pipeline_target_allowed_by_id(
|
||||
state: &Arc<AppState>,
|
||||
connection_id: &str,
|
||||
database: &str,
|
||||
pipeline_json: &str,
|
||||
) -> Result<(), String> {
|
||||
let configs = state.storage.load_connections().await.map_err(|e| format!("MCP_POLICY_UNAVAILABLE: {e}"))?;
|
||||
let config = configs
|
||||
.iter()
|
||||
.find(|config| config.id == connection_id)
|
||||
.ok_or_else(|| format!("Connection with id '{connection_id}' not found"))?;
|
||||
if dbx_core::production_safety::mongo_pipeline_targets_production_database(config, database, pipeline_json) {
|
||||
return Err(
|
||||
"PRODUCTION_DATABASE_READ_ONLY: MongoDB aggregate write targeting production scope is blocked.".to_string()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_mcp_write_allowed_by_id_with_risk(
|
||||
state: &Arc<AppState>,
|
||||
connection_id: &str,
|
||||
database: &str,
|
||||
action: &str,
|
||||
dangerous: bool,
|
||||
) -> Result<(), String> {
|
||||
let configs = state.storage.load_connections().await.map_err(|e| format!("MCP_POLICY_UNAVAILABLE: {e}"))?;
|
||||
let config = configs
|
||||
.iter()
|
||||
.find(|config| config.id == connection_id)
|
||||
.ok_or_else(|| format!("Connection with id '{connection_id}' not found"))?;
|
||||
ensure_mcp_write_allowed_with_risk(state, config, database, action, dangerous).await
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_mcp_mongo_filtered_write_allowed_by_id(
|
||||
state: &Arc<AppState>,
|
||||
connection_id: &str,
|
||||
database: &str,
|
||||
action: &str,
|
||||
filter_json: &str,
|
||||
) -> Result<(), String> {
|
||||
if mongo_filter_is_effectively_unbounded(filter_json) {
|
||||
ensure_mcp_dangerous_write_allowed_by_id(state, connection_id, database, action).await
|
||||
} else {
|
||||
ensure_mcp_write_allowed_by_id(state, connection_id, database, action).await
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_mcp_mongo_aggregate_allowed_by_id(
|
||||
state: &Arc<AppState>,
|
||||
connection_id: &str,
|
||||
database: &str,
|
||||
pipeline_json: &str,
|
||||
) -> Result<(), String> {
|
||||
if !mongo_pipeline_has_write_stage(pipeline_json) {
|
||||
return ensure_mcp_read_allowed_by_id(state, connection_id, database).await;
|
||||
}
|
||||
ensure_mcp_dangerous_write_allowed_by_id(state, connection_id, database, "MongoDB aggregate write").await?;
|
||||
ensure_mcp_mongo_pipeline_target_allowed_by_id(state, connection_id, database, pipeline_json).await
|
||||
}
|
||||
|
||||
fn mongo_pipeline_has_write_stage(pipeline_json: &str) -> bool {
|
||||
serde_json::from_str::<serde_json::Value>(pipeline_json)
|
||||
.ok()
|
||||
.and_then(|value| value.as_array().cloned())
|
||||
.is_some_and(|stages| {
|
||||
stages.iter().any(|stage| {
|
||||
stage
|
||||
.as_object()
|
||||
.is_some_and(|document| document.contains_key("$out") || document.contains_key("$merge"))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn mongo_filter_is_effectively_unbounded(filter_json: &str) -> bool {
|
||||
serde_json::from_str::<serde_json::Value>(filter_json)
|
||||
.ok()
|
||||
.as_ref()
|
||||
.map_or(true, |value| mongo_filter_contains_opaque_logic(value) || mongo_filter_value_is_unbounded(value))
|
||||
}
|
||||
|
||||
fn mongo_filter_contains_opaque_logic(value: &serde_json::Value) -> bool {
|
||||
let Some(filter) = value.as_object() else {
|
||||
return true;
|
||||
};
|
||||
filter.iter().any(|(key, value)| match key.as_str() {
|
||||
"$comment" => false,
|
||||
"$where" | "$expr" | "$nor" => true,
|
||||
"$and" | "$or" => {
|
||||
let Some(clauses) = value.as_array() else {
|
||||
return true;
|
||||
};
|
||||
clauses.is_empty()
|
||||
|| clauses.iter().any(|clause| !clause.is_object() || mongo_filter_contains_opaque_logic(clause))
|
||||
|| (key == "$or"
|
||||
&& clauses
|
||||
.iter()
|
||||
.any(|clause| clause.as_object().is_some_and(|document| document.contains_key("$and"))))
|
||||
|| (key == "$or" && mongo_or_has_complementary_field_clauses(clauses))
|
||||
}
|
||||
_ => key.starts_with('$') || mongo_field_predicate_contains_opaque_logic(value),
|
||||
})
|
||||
}
|
||||
|
||||
fn mongo_field_predicate_contains_opaque_logic(value: &serde_json::Value) -> bool {
|
||||
let Some(predicate) = value.as_object() else {
|
||||
return false;
|
||||
};
|
||||
if mongo_extended_json_scalar_literal_is_valid(value) {
|
||||
return false;
|
||||
}
|
||||
let has_operator = predicate.keys().any(|key| key.starts_with('$'));
|
||||
has_operator
|
||||
&& predicate.keys().any(|key| {
|
||||
!matches!(key.as_str(), "$eq" | "$ne" | "$gt" | "$gte" | "$lt" | "$lte" | "$in" | "$nin" | "$exists")
|
||||
})
|
||||
}
|
||||
|
||||
fn mongo_extended_json_scalar_literal_is_valid(value: &serde_json::Value) -> bool {
|
||||
let Some(wrapper) = value.as_object().filter(|wrapper| wrapper.len() == 1) else {
|
||||
return false;
|
||||
};
|
||||
if let Some(value) = wrapper.get("$oid").and_then(serde_json::Value::as_str) {
|
||||
return value.len() == 24 && value.bytes().all(|byte| byte.is_ascii_hexdigit());
|
||||
}
|
||||
if let Some(value) = wrapper.get("$numberLong").and_then(serde_json::Value::as_str) {
|
||||
return value.parse::<i64>().is_ok();
|
||||
}
|
||||
wrapper
|
||||
.get("$date")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.is_some_and(|value| chrono::DateTime::parse_from_rfc3339(value).is_ok())
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum MongoFieldOperator {
|
||||
Eq,
|
||||
Ne,
|
||||
Gt,
|
||||
Gte,
|
||||
Lt,
|
||||
Lte,
|
||||
In,
|
||||
Nin,
|
||||
Exists,
|
||||
}
|
||||
|
||||
struct MongoPureFieldPredicate<'a> {
|
||||
field: &'a str,
|
||||
operator: MongoFieldOperator,
|
||||
operand: &'a serde_json::Value,
|
||||
}
|
||||
|
||||
fn mongo_or_has_complementary_field_clauses(clauses: &[serde_json::Value]) -> bool {
|
||||
clauses.iter().enumerate().any(|(index, clause)| {
|
||||
let Some(predicate) = mongo_pure_field_predicate(clause) else {
|
||||
return false;
|
||||
};
|
||||
clauses[index + 1..]
|
||||
.iter()
|
||||
.filter_map(mongo_pure_field_predicate)
|
||||
.any(|other| mongo_field_predicates_are_complementary(&predicate, &other))
|
||||
})
|
||||
}
|
||||
|
||||
fn mongo_pure_field_predicate(value: &serde_json::Value) -> Option<MongoPureFieldPredicate<'_>> {
|
||||
let filter = value.as_object()?;
|
||||
let mut entries = filter.iter().filter(|(key, _)| key.as_str() != "$comment");
|
||||
let (field, predicate) = entries.next()?;
|
||||
if entries.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
if field == "$and" {
|
||||
let clauses = predicate.as_array()?;
|
||||
let mut bounded = clauses.iter().filter(|clause| !mongo_filter_value_is_unbounded(clause));
|
||||
let clause = bounded.next()?;
|
||||
if bounded.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
return mongo_pure_field_predicate(clause);
|
||||
}
|
||||
if field == "$or" {
|
||||
let clauses = predicate.as_array()?;
|
||||
return (clauses.len() == 1).then(|| mongo_pure_field_predicate(&clauses[0])).flatten();
|
||||
}
|
||||
if field.starts_with('$') {
|
||||
return None;
|
||||
}
|
||||
let Some(operator_document) = predicate.as_object() else {
|
||||
return Some(MongoPureFieldPredicate { field, operator: MongoFieldOperator::Eq, operand: predicate });
|
||||
};
|
||||
if mongo_extended_json_scalar_literal_is_valid(predicate)
|
||||
|| !operator_document.keys().any(|key| key.starts_with('$'))
|
||||
{
|
||||
return Some(MongoPureFieldPredicate { field, operator: MongoFieldOperator::Eq, operand: predicate });
|
||||
}
|
||||
let mut operators = operator_document.iter();
|
||||
let (operator, operand) = operators.next()?;
|
||||
if operators.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
let operator = match operator.as_str() {
|
||||
"$eq" => MongoFieldOperator::Eq,
|
||||
"$ne" => MongoFieldOperator::Ne,
|
||||
"$gt" => MongoFieldOperator::Gt,
|
||||
"$gte" => MongoFieldOperator::Gte,
|
||||
"$lt" => MongoFieldOperator::Lt,
|
||||
"$lte" => MongoFieldOperator::Lte,
|
||||
"$in" => MongoFieldOperator::In,
|
||||
"$nin" => MongoFieldOperator::Nin,
|
||||
"$exists" => MongoFieldOperator::Exists,
|
||||
_ => return None,
|
||||
};
|
||||
Some(MongoPureFieldPredicate { field, operator, operand })
|
||||
}
|
||||
|
||||
fn mongo_field_predicates_are_complementary(
|
||||
left: &MongoPureFieldPredicate<'_>,
|
||||
right: &MongoPureFieldPredicate<'_>,
|
||||
) -> bool {
|
||||
if left.field != right.field {
|
||||
return false;
|
||||
}
|
||||
use MongoFieldOperator::{Eq, Exists, Gt, Gte, In, Lt, Lte, Ne, Nin};
|
||||
match (left.operator, right.operator) {
|
||||
(Exists, Exists) => {
|
||||
left.operand.as_bool().zip(right.operand.as_bool()).is_some_and(|(left, right)| left != right)
|
||||
}
|
||||
(In, Nin) | (Nin, In) => mongo_json_sets_equal(left.operand, right.operand),
|
||||
(Eq, Ne) | (Ne, Eq) | (Gt, Lte) | (Lte, Gt) | (Gte, Lt) | (Lt, Gte) => left.operand == right.operand,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn mongo_json_sets_equal(left: &serde_json::Value, right: &serde_json::Value) -> bool {
|
||||
let (Some(left), Some(right)) = (left.as_array(), right.as_array()) else {
|
||||
return false;
|
||||
};
|
||||
left.iter().all(|value| right.contains(value)) && right.iter().all(|value| left.contains(value))
|
||||
}
|
||||
|
||||
fn mongo_filter_value_is_unbounded(value: &serde_json::Value) -> bool {
|
||||
let Some(filter) = value.as_object() else {
|
||||
return true;
|
||||
};
|
||||
if filter.is_empty() || filter.contains_key("$where") || filter.contains_key("$expr") {
|
||||
return true;
|
||||
}
|
||||
filter.iter().all(|(key, value)| match key.as_str() {
|
||||
"$comment" => true,
|
||||
"$and" => value
|
||||
.as_array()
|
||||
.map_or(true, |clauses| clauses.is_empty() || clauses.iter().all(mongo_filter_value_is_unbounded)),
|
||||
"$or" => value
|
||||
.as_array()
|
||||
.map_or(true, |clauses| clauses.is_empty() || clauses.iter().any(mongo_filter_value_is_unbounded)),
|
||||
"$nor" => true,
|
||||
_ if mongo_field_predicate_is_empty_nin(value) => true,
|
||||
"_id" if mongo_field_predicate_is_exists_true(value) => true,
|
||||
_ => key.starts_with('$'),
|
||||
})
|
||||
}
|
||||
|
||||
fn mongo_field_predicate_is_empty_nin(value: &serde_json::Value) -> bool {
|
||||
value.as_object().is_some_and(|predicate| {
|
||||
predicate.len() == 1 && predicate.get("$nin").and_then(serde_json::Value::as_array).is_some_and(Vec::is_empty)
|
||||
})
|
||||
}
|
||||
|
||||
fn mongo_field_predicate_is_exists_true(value: &serde_json::Value) -> bool {
|
||||
value.as_object().is_some_and(|predicate| {
|
||||
predicate.len() == 1 && predicate.get("$exists").and_then(serde_json::Value::as_bool) == Some(true)
|
||||
})
|
||||
}
|
||||
|
||||
async fn ensure_mcp_sql_allowed(
|
||||
state: &Arc<AppState>,
|
||||
config: &crate::models::connection::ConnectionConfig,
|
||||
database: &str,
|
||||
sql: &str,
|
||||
) -> Result<(), String> {
|
||||
let policy = load_mcp_policy(state).await?;
|
||||
ensure_connection_in_mcp_scope(&policy, &config.id)?;
|
||||
ensure_mcp_sql_database_switch_allowed(config.db_type, sql)?;
|
||||
let is_write = dbx_core::query_execution_sql::is_write_sql_for_database(sql, config.db_type);
|
||||
if policy.read_only && is_write {
|
||||
return Err("MCP_READ_ONLY: DBX MCP read-only mode is enabled. SQL write blocked.".to_string());
|
||||
}
|
||||
if !policy.allow_dangerous_sql && dbx_core::sql_risk::is_dangerous_sql_for_database(sql, config.db_type) {
|
||||
return Err("SQL_BLOCKED: High-risk SQL is disabled in DBX MCP settings.".to_string());
|
||||
}
|
||||
ensure_mcp_connection_sql_write_allowed(config, is_write)?;
|
||||
if is_write && dbx_core::production_safety::targets_production_database(config, database, sql) {
|
||||
return Err("PRODUCTION_DATABASE_READ_ONLY: SQL write targeting production scope is blocked.".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_mcp_sql_database_switch_allowed(
|
||||
database_type: crate::models::connection::DatabaseType,
|
||||
sql: &str,
|
||||
) -> Result<(), String> {
|
||||
if dbx_core::sql_risk::mcp_sql_has_forbidden_database_switch(sql, database_type) {
|
||||
return Err("SQL_BLOCKED: MCP does not allow USE or persistent database switching.".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_mcp_connection_sql_write_allowed(
|
||||
config: &crate::models::connection::ConnectionConfig,
|
||||
is_write: bool,
|
||||
) -> Result<(), String> {
|
||||
if is_write && config.read_only {
|
||||
return Err(format!(
|
||||
"CONNECTION_READ_ONLY: connection '{}' has read-only protection enabled. SQL write blocked.",
|
||||
config.name
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_visible_database(config: &crate::models::connection::ConnectionConfig, database: &str) -> Result<(), String> {
|
||||
if let Some(ref visible) = config.visible_databases {
|
||||
if !visible.is_empty() && !visible.iter().any(|v| v == database) {
|
||||
@@ -411,17 +1029,60 @@ async fn handle_execute_query(app: &AppHandle, state: &Arc<AppState>, body: &str
|
||||
return;
|
||||
}
|
||||
};
|
||||
let event = McpExecuteQueryEvent {
|
||||
connection_id: config.id.clone(),
|
||||
database: req.database.unwrap_or_else(|| config.database.clone().unwrap_or_default()),
|
||||
sql: req.sql,
|
||||
allow_writes: req.allow_writes.unwrap_or(false),
|
||||
allow_dangerous: req.allow_dangerous.unwrap_or(false),
|
||||
let database = req.database.unwrap_or_else(|| config.database.clone().unwrap_or_default());
|
||||
if let Err(error) = ensure_mcp_execute_and_show_supported(&config.db_type) {
|
||||
respond(stream, "400 Bad Request", error).await;
|
||||
return;
|
||||
}
|
||||
// Check the complete batch first so a session-changing statement cannot
|
||||
// hide a later production write, then recheck every statement immediately
|
||||
// before it reaches the database.
|
||||
if let Err(e) = ensure_mcp_sql_allowed(state, &config, &database, &req.sql).await {
|
||||
respond(stream, "403 Forbidden", &e).await;
|
||||
return;
|
||||
}
|
||||
let statements = if config.db_type == crate::models::connection::DatabaseType::SqlServer {
|
||||
dbx_core::sql::split_sql_batches(&req.sql)
|
||||
} else {
|
||||
dbx_core::sql::split_sql_statements_for_database(&req.sql, config.db_type)
|
||||
};
|
||||
let statements = if statements.is_empty() { vec![req.sql.clone()] } else { statements };
|
||||
let mut results = Vec::with_capacity(statements.len());
|
||||
for statement in statements {
|
||||
let current = match resolve_connection(state, Some(&config.id), &config.name).await {
|
||||
Ok(config) => config,
|
||||
Err(e) => {
|
||||
respond(stream, "403 Forbidden", &e).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Err(e) = ensure_mcp_sql_allowed(state, ¤t, &database, &statement).await {
|
||||
respond(stream, "403 Forbidden", &e).await;
|
||||
return;
|
||||
}
|
||||
match dbx_core::query::execute_sql_statement(state, ¤t.id, &database, &statement, None, None).await {
|
||||
Ok(result) => results.push(result),
|
||||
Err(e) => {
|
||||
respond(stream, "500 Internal Server Error", &format!("QUERY_ERROR: {e}")).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
let event = McpExecuteQueryEvent { connection_id: config.id.clone(), database, sql: req.sql, results };
|
||||
let _ = app.emit("mcp-execute-query", &event);
|
||||
respond(stream, "200 OK", "ok").await;
|
||||
}
|
||||
|
||||
fn ensure_mcp_execute_and_show_supported(
|
||||
database_type: &crate::models::connection::DatabaseType,
|
||||
) -> Result<(), &'static str> {
|
||||
if dbx_core::query_execution_sql::supports_sql_query(*database_type) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(MCP_EXECUTE_AND_SHOW_SQL_ONLY)
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_list_tables_data(state: &Arc<AppState>, body: &str, stream: &mut tokio::net::TcpStream) {
|
||||
let req: ListTablesRequest = match serde_json::from_str(body) {
|
||||
Ok(r) => r,
|
||||
@@ -607,6 +1268,11 @@ async fn handle_mongo_aggregate_documents_data(state: &Arc<AppState>, body: &str
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = ensure_mcp_mongo_aggregate_allowed_by_id(state, &connection_id, &database, &req.pipeline_json).await
|
||||
{
|
||||
respond_error(stream, "403 Forbidden", &e).await;
|
||||
return;
|
||||
}
|
||||
match dbx_core::mongo_ops::mongo_aggregate_documents_core(
|
||||
state,
|
||||
&connection_id,
|
||||
@@ -664,7 +1330,7 @@ async fn handle_mongo_create_index_data(state: &Arc<AppState>, body: &str, strea
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = ensure_connection_writable(state, &connection_id, "Create index").await {
|
||||
if let Err(e) = ensure_mcp_dangerous_write_allowed_by_id(state, &connection_id, &database, "Create index").await {
|
||||
respond_error(stream, "403 Forbidden", &e).await;
|
||||
return;
|
||||
}
|
||||
@@ -696,7 +1362,7 @@ async fn handle_mongo_drop_indexes_data(state: &Arc<AppState>, body: &str, strea
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = ensure_connection_writable(state, &connection_id, "Drop indexes").await {
|
||||
if let Err(e) = ensure_mcp_dangerous_write_allowed_by_id(state, &connection_id, &database, "Drop indexes").await {
|
||||
respond_error(stream, "403 Forbidden", &e).await;
|
||||
return;
|
||||
}
|
||||
@@ -728,7 +1394,8 @@ async fn handle_mongo_drop_collection_data(state: &Arc<AppState>, body: &str, st
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = ensure_connection_writable(state, &connection_id, "Drop collection").await {
|
||||
if let Err(e) = ensure_mcp_dangerous_write_allowed_by_id(state, &connection_id, &database, "Drop collection").await
|
||||
{
|
||||
respond_error(stream, "403 Forbidden", &e).await;
|
||||
return;
|
||||
}
|
||||
@@ -751,7 +1418,7 @@ async fn handle_mongo_insert_documents_data(state: &Arc<AppState>, body: &str, s
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = ensure_connection_writable(state, &connection_id, "Insert").await {
|
||||
if let Err(e) = ensure_mcp_write_allowed_by_id(state, &connection_id, &database, "Insert").await {
|
||||
respond_error(stream, "403 Forbidden", &e).await;
|
||||
return;
|
||||
}
|
||||
@@ -782,7 +1449,12 @@ async fn handle_mongo_update_documents_data(state: &Arc<AppState>, body: &str, s
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = ensure_connection_writable(state, &connection_id, "Update").await {
|
||||
let policy_check = if mongo_filter_is_effectively_unbounded(&req.filter_json) {
|
||||
ensure_mcp_dangerous_write_allowed_by_id(state, &connection_id, &database, "Update").await
|
||||
} else {
|
||||
ensure_mcp_write_allowed_by_id(state, &connection_id, &database, "Update").await
|
||||
};
|
||||
if let Err(e) = policy_check {
|
||||
respond_error(stream, "403 Forbidden", &e).await;
|
||||
return;
|
||||
}
|
||||
@@ -816,7 +1488,12 @@ async fn handle_mongo_delete_documents_data(state: &Arc<AppState>, body: &str, s
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = ensure_connection_writable(state, &connection_id, "Delete").await {
|
||||
let policy_check = if mongo_filter_is_effectively_unbounded(&req.filter_json) {
|
||||
ensure_mcp_dangerous_write_allowed_by_id(state, &connection_id, &database, "Delete").await
|
||||
} else {
|
||||
ensure_mcp_write_allowed_by_id(state, &connection_id, &database, "Delete").await
|
||||
};
|
||||
if let Err(e) = policy_check {
|
||||
respond_error(stream, "403 Forbidden", &e).await;
|
||||
return;
|
||||
}
|
||||
@@ -855,20 +1532,25 @@ async fn handle_redis_execute_command_data(state: &Arc<AppState>, body: &str, st
|
||||
respond_error(stream, "403 Forbidden", &e).await;
|
||||
return;
|
||||
}
|
||||
if let Some(name) = dbx_core::query::connection_readonly_name(state, &config.id).await {
|
||||
let cmd_name = req.command.split_whitespace().next().unwrap_or("");
|
||||
if dbx_core::db::redis_driver::classify_command(cmd_name)
|
||||
!= dbx_core::db::redis_driver::RedisCommandSafety::Allowed
|
||||
{
|
||||
respond_error(
|
||||
stream,
|
||||
"403 Forbidden",
|
||||
&format!(
|
||||
"Read-only mode: connection '{}' has read-only protection enabled. Command '{}' blocked.",
|
||||
name, cmd_name
|
||||
),
|
||||
)
|
||||
.await;
|
||||
let argv = match dbx_core::db::redis_driver::parse_command_argv(&req.command) {
|
||||
Ok(argv) => argv,
|
||||
Err(error) => {
|
||||
respond_error(stream, "400 Bad Request", &format!("Invalid Redis command: {error}")).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let cmd_name = argv[0].to_ascii_uppercase();
|
||||
let safety = dbx_core::db::redis_driver::classify_command(&cmd_name);
|
||||
let centrally_approved_high_risk = safety == dbx_core::db::redis_driver::RedisCommandSafety::Blocked;
|
||||
if safety != dbx_core::db::redis_driver::RedisCommandSafety::Allowed {
|
||||
let policy_check = if centrally_approved_high_risk {
|
||||
ensure_mcp_write_allowed_with_risk(state, &config, &database, &format!("Redis command '{cmd_name}'"), true)
|
||||
.await
|
||||
} else {
|
||||
ensure_mcp_write_allowed(state, &config, &database, &format!("Redis command '{cmd_name}'")).await
|
||||
};
|
||||
if let Err(e) = policy_check {
|
||||
respond_error(stream, "403 Forbidden", &e).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -877,7 +1559,7 @@ async fn handle_redis_execute_command_data(state: &Arc<AppState>, body: &str, st
|
||||
&config.id,
|
||||
req.db,
|
||||
&req.command,
|
||||
req.skip_safety_check.unwrap_or(false),
|
||||
centrally_approved_high_risk,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -906,66 +1588,51 @@ 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 {
|
||||
if let Err(e) = ensure_mcp_sql_allowed(state, &config, &database, &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)
|
||||
let statements = if config.db_type == crate::models::connection::DatabaseType::SqlServer {
|
||||
dbx_core::sql::split_sql_batches(&req.sql)
|
||||
} else {
|
||||
dbx_core::sql::split_sql_statements_for_database(&req.sql, config.db_type)
|
||||
};
|
||||
let statements = if statements.is_empty() { vec![req.sql] } else { statements };
|
||||
let mut last_result = None;
|
||||
for statement in statements {
|
||||
// Re-read both policy and connection settings immediately before every
|
||||
// statement so a settings change can stop the remainder of the batch.
|
||||
let current = match resolve_connection(state, Some(&config.id), &config.name).await {
|
||||
Ok(config) => config,
|
||||
Err(e) => {
|
||||
respond_error(stream, "403 Forbidden", &e).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Err(e) = ensure_mcp_sql_allowed(state, ¤t, &database, &statement).await {
|
||||
respond_error(stream, "403 Forbidden", &e).await;
|
||||
return;
|
||||
}
|
||||
match dbx_core::query::execute_sql_statement(
|
||||
state,
|
||||
¤t.id,
|
||||
&database,
|
||||
&statement,
|
||||
req.schema.as_deref(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => respond_json(stream, &result).await,
|
||||
Err(e) => respond_error(stream, "500 Internal Server Error", &e).await,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{resolve_mongo_database, resolve_mongo_target_values, write_port_file};
|
||||
|
||||
#[test]
|
||||
fn writes_bridge_port_file_to_resolved_data_dir() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"dbx-mcp-bridge-port-test-{}-{}",
|
||||
std::process::id(),
|
||||
uuid::Uuid::new_v4()
|
||||
));
|
||||
let default_data_dir = root.join("default-app-data");
|
||||
let resolved_data_dir = root.join("resolved-data");
|
||||
std::fs::create_dir_all(&default_data_dir).unwrap();
|
||||
|
||||
let port_file = write_port_file(&resolved_data_dir, 49152).unwrap();
|
||||
|
||||
assert_eq!(port_file, resolved_data_dir.join("mcp-bridge-port"));
|
||||
assert_eq!(std::fs::read_to_string(port_file).unwrap(), "49152");
|
||||
assert!(!default_data_dir.join("mcp-bridge-port").exists());
|
||||
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mongo_database_uses_configured_default_for_missing_or_blank_request() {
|
||||
let configured = Some("sample_db".to_string());
|
||||
|
||||
assert_eq!(resolve_mongo_database(None, configured.clone()), "sample_db");
|
||||
assert_eq!(resolve_mongo_database(Some(String::new()), configured.clone()), "sample_db");
|
||||
assert_eq!(resolve_mongo_database(Some(" ".to_string()), configured), "sample_db");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mongo_database_preserves_explicit_target() {
|
||||
assert_eq!(resolve_mongo_database(Some("admin".to_string()), Some("sample_db".to_string())), "admin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mongo_target_keeps_connection_id_separate_from_database() {
|
||||
assert_eq!(
|
||||
resolve_mongo_target_values(
|
||||
"connection-id".to_string(),
|
||||
Some("sample_db".to_string()),
|
||||
Some("default_db".to_string()),
|
||||
),
|
||||
("connection-id".to_string(), "sample_db".to_string())
|
||||
);
|
||||
{
|
||||
Ok(result) => last_result = Some(result),
|
||||
Err(e) => {
|
||||
respond_error(stream, "500 Internal Server Error", &e).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(result) = last_result {
|
||||
respond_json(stream, &result).await;
|
||||
} else {
|
||||
respond_error(stream, "500 Internal Server Error", "No SQL statement to execute").await;
|
||||
}
|
||||
}
|
||||
@@ -99,7 +99,11 @@ pub async fn mongo_find_documents(
|
||||
projection: Option<String>,
|
||||
sort: Option<String>,
|
||||
execution_id: Option<String>,
|
||||
mcp_request: Option<bool>,
|
||||
) -> Result<MongoDocumentResult, String> {
|
||||
if mcp_request == Some(true) {
|
||||
crate::commands::mcp_bridge::ensure_mcp_read_allowed_by_id(state.inner(), &connection_id, &database).await?;
|
||||
}
|
||||
crate::commands::document_cmd::document_find_documents(
|
||||
state,
|
||||
connection_id,
|
||||
@@ -126,8 +130,12 @@ pub async fn mongo_find_one(
|
||||
projection: Option<String>,
|
||||
options: Option<String>,
|
||||
execution_id: Option<String>,
|
||||
mcp_request: Option<bool>,
|
||||
) -> Result<MongoDocumentResult, String> {
|
||||
let app = state.inner().clone();
|
||||
if mcp_request == Some(true) {
|
||||
crate::commands::mcp_bridge::ensure_mcp_read_allowed_by_id(&app, &connection_id, &database).await?;
|
||||
}
|
||||
run_cancellable(
|
||||
&app,
|
||||
execution_id,
|
||||
@@ -153,8 +161,12 @@ pub async fn mongo_count_documents(
|
||||
filter: Option<String>,
|
||||
mode: Option<String>,
|
||||
execution_id: Option<String>,
|
||||
mcp_request: Option<bool>,
|
||||
) -> Result<u64, String> {
|
||||
let app = state.inner().clone();
|
||||
if mcp_request == Some(true) {
|
||||
crate::commands::mcp_bridge::ensure_mcp_read_allowed_by_id(&app, &connection_id, &database).await?;
|
||||
}
|
||||
crate::commands::document_cmd::run_cancellable(
|
||||
&app,
|
||||
execution_id,
|
||||
@@ -176,8 +188,12 @@ pub async fn mongo_server_version(
|
||||
connection_id: String,
|
||||
database: String,
|
||||
execution_id: Option<String>,
|
||||
mcp_request: Option<bool>,
|
||||
) -> Result<String, String> {
|
||||
let app = state.inner().clone();
|
||||
if mcp_request == Some(true) {
|
||||
crate::commands::mcp_bridge::ensure_mcp_read_allowed_by_id(&app, &connection_id, &database).await?;
|
||||
}
|
||||
run_cancellable(&app, execution_id, dbx_core::mongo_ops::mongo_server_version_core(&app, &connection_id, &database))
|
||||
.await
|
||||
}
|
||||
@@ -190,8 +206,12 @@ pub async fn mongo_collection_stats(
|
||||
collection: String,
|
||||
scale: Option<serde_json::Number>,
|
||||
execution_id: Option<String>,
|
||||
mcp_request: Option<bool>,
|
||||
) -> Result<dbx_core::db::mongo_driver::MongoCollectionStatsResult, String> {
|
||||
let app = state.inner().clone();
|
||||
if mcp_request == Some(true) {
|
||||
crate::commands::mcp_bridge::ensure_mcp_read_allowed_by_id(&app, &connection_id, &database).await?;
|
||||
}
|
||||
run_cancellable(
|
||||
&app,
|
||||
execution_id,
|
||||
@@ -210,8 +230,18 @@ pub async fn mongo_aggregate_documents(
|
||||
max_rows: Option<usize>,
|
||||
options_json: Option<String>,
|
||||
execution_id: Option<String>,
|
||||
mcp_request: Option<bool>,
|
||||
) -> Result<MongoDocumentResult, String> {
|
||||
let app = state.inner().clone();
|
||||
if mcp_request == Some(true) {
|
||||
crate::commands::mcp_bridge::ensure_mcp_mongo_aggregate_allowed_by_id(
|
||||
&app,
|
||||
&connection_id,
|
||||
&database,
|
||||
&pipeline_json,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
run_cancellable(
|
||||
&app,
|
||||
execution_id,
|
||||
@@ -237,8 +267,12 @@ pub async fn mongo_distinct(
|
||||
field: String,
|
||||
filter: Option<String>,
|
||||
execution_id: Option<String>,
|
||||
mcp_request: Option<bool>,
|
||||
) -> Result<MongoDocumentResult, String> {
|
||||
let app = state.inner().clone();
|
||||
if mcp_request == Some(true) {
|
||||
crate::commands::mcp_bridge::ensure_mcp_read_allowed_by_id(&app, &connection_id, &database).await?;
|
||||
}
|
||||
run_cancellable(
|
||||
&app,
|
||||
execution_id,
|
||||
@@ -262,7 +296,17 @@ pub async fn mongo_create_index(
|
||||
collection: String,
|
||||
keys_json: String,
|
||||
options_json: Option<String>,
|
||||
mcp_request: Option<bool>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
if mcp_request == Some(true) {
|
||||
crate::commands::mcp_bridge::ensure_mcp_dangerous_write_allowed_by_id(
|
||||
state.inner(),
|
||||
&connection_id,
|
||||
&database,
|
||||
"Create index",
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
ensure_connection_writable(&state, &connection_id, "Create index").await?;
|
||||
let name = dbx_core::mongo_ops::mongo_create_index_core(
|
||||
&state,
|
||||
@@ -284,7 +328,17 @@ pub async fn mongo_drop_indexes(
|
||||
collection: String,
|
||||
indexes_json: Option<String>,
|
||||
single: bool,
|
||||
mcp_request: Option<bool>,
|
||||
) -> Result<dbx_core::db::mongo_driver::MongoDropIndexesResult, String> {
|
||||
if mcp_request == Some(true) {
|
||||
crate::commands::mcp_bridge::ensure_mcp_dangerous_write_allowed_by_id(
|
||||
state.inner(),
|
||||
&connection_id,
|
||||
&database,
|
||||
"Drop indexes",
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
ensure_connection_writable(&state, &connection_id, "Drop indexes").await?;
|
||||
dbx_core::mongo_ops::mongo_drop_indexes_core(
|
||||
&state,
|
||||
@@ -324,7 +378,12 @@ pub async fn mongo_insert_documents(
|
||||
database: String,
|
||||
collection: String,
|
||||
docs_json: String,
|
||||
mcp_request: Option<bool>,
|
||||
) -> Result<u64, String> {
|
||||
if mcp_request == Some(true) {
|
||||
crate::commands::mcp_bridge::ensure_mcp_write_allowed_by_id(state.inner(), &connection_id, &database, "Insert")
|
||||
.await?;
|
||||
}
|
||||
ensure_connection_writable(&state, &connection_id, "Insert").await?;
|
||||
dbx_core::mongo_ops::mongo_insert_documents_core(&state, &connection_id, &database, &collection, &docs_json).await
|
||||
}
|
||||
@@ -361,7 +420,18 @@ pub async fn mongo_update_documents(
|
||||
update_json: String,
|
||||
many: bool,
|
||||
options_json: Option<String>,
|
||||
mcp_request: Option<bool>,
|
||||
) -> Result<u64, String> {
|
||||
if mcp_request == Some(true) {
|
||||
crate::commands::mcp_bridge::ensure_mcp_mongo_filtered_write_allowed_by_id(
|
||||
state.inner(),
|
||||
&connection_id,
|
||||
&database,
|
||||
"Update",
|
||||
&filter_json,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
ensure_connection_writable(&state, &connection_id, "Update").await?;
|
||||
dbx_core::mongo_ops::mongo_update_documents_core(
|
||||
&state,
|
||||
@@ -397,7 +467,18 @@ pub async fn mongo_delete_documents(
|
||||
collection: String,
|
||||
filter_json: String,
|
||||
many: bool,
|
||||
mcp_request: Option<bool>,
|
||||
) -> Result<u64, String> {
|
||||
if mcp_request == Some(true) {
|
||||
crate::commands::mcp_bridge::ensure_mcp_mongo_filtered_write_allowed_by_id(
|
||||
state.inner(),
|
||||
&connection_id,
|
||||
&database,
|
||||
"Delete",
|
||||
&filter_json,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
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
|
||||
@@ -412,7 +493,18 @@ pub async fn mongo_find_one_and_update(
|
||||
filter_json: String,
|
||||
update_json: String,
|
||||
options_json: Option<String>,
|
||||
mcp_request: Option<bool>,
|
||||
) -> Result<MongoDocumentResult, String> {
|
||||
if mcp_request == Some(true) {
|
||||
crate::commands::mcp_bridge::ensure_mcp_mongo_filtered_write_allowed_by_id(
|
||||
state.inner(),
|
||||
&connection_id,
|
||||
&database,
|
||||
"Update",
|
||||
&filter_json,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
ensure_connection_writable(&state, &connection_id, "Update").await?;
|
||||
dbx_core::mongo_ops::mongo_find_one_and_update_core(
|
||||
&state,
|
||||
@@ -435,7 +527,18 @@ pub async fn mongo_find_one_and_replace(
|
||||
filter_json: String,
|
||||
replacement_json: String,
|
||||
options_json: Option<String>,
|
||||
mcp_request: Option<bool>,
|
||||
) -> Result<MongoDocumentResult, String> {
|
||||
if mcp_request == Some(true) {
|
||||
crate::commands::mcp_bridge::ensure_mcp_mongo_filtered_write_allowed_by_id(
|
||||
state.inner(),
|
||||
&connection_id,
|
||||
&database,
|
||||
"Update",
|
||||
&filter_json,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
ensure_connection_writable(&state, &connection_id, "Update").await?;
|
||||
dbx_core::mongo_ops::mongo_find_one_and_replace_core(
|
||||
&state,
|
||||
@@ -457,7 +560,18 @@ pub async fn mongo_find_one_and_delete(
|
||||
collection: String,
|
||||
filter_json: String,
|
||||
options_json: Option<String>,
|
||||
mcp_request: Option<bool>,
|
||||
) -> Result<MongoDocumentResult, String> {
|
||||
if mcp_request == Some(true) {
|
||||
crate::commands::mcp_bridge::ensure_mcp_mongo_filtered_write_allowed_by_id(
|
||||
state.inner(),
|
||||
&connection_id,
|
||||
&database,
|
||||
"Delete",
|
||||
&filter_json,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
ensure_connection_writable(&state, &connection_id, "Delete").await?;
|
||||
dbx_core::mongo_ops::mongo_find_one_and_delete_core(
|
||||
&state,
|
||||
|
||||
@@ -3,7 +3,8 @@ use tauri::State;
|
||||
|
||||
use crate::commands::connection::{ensure_connection_writable, AppState};
|
||||
use dbx_core::db::redis_driver::{
|
||||
RedisCollectionPage, RedisCommandResult, RedisCommandSafety, RedisDatabaseInfo, RedisScanResult, RedisValue,
|
||||
classify_command, parse_command_argv, RedisCollectionPage, RedisCommandResult, RedisCommandSafety,
|
||||
RedisDatabaseInfo, RedisScanResult, RedisValue,
|
||||
};
|
||||
|
||||
#[tauri::command]
|
||||
@@ -295,10 +296,12 @@ pub async fn redis_execute_command(
|
||||
command: String,
|
||||
skip_safety_check: Option<bool>,
|
||||
) -> Result<RedisCommandResult, String> {
|
||||
let argv = parse_command_argv(&command).map_err(|error| format!("Invalid Redis command: {error}"))?;
|
||||
let cmd_name = argv[0].to_ascii_uppercase();
|
||||
let safety = classify_command(&cmd_name);
|
||||
// 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 {
|
||||
if safety != RedisCommandSafety::Allowed {
|
||||
return Err(format!(
|
||||
"Read-only mode: connection '{}' has read-only protection enabled. Command '{}' blocked.",
|
||||
name, cmd_name
|
||||
|
||||
Reference in new issue
Block a user