From 81e1693c185babd3f11b13e2f6dcf745bf7e8971 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Tue, 2 Jun 2026 11:09:48 +0800 Subject: [PATCH] fix(mongodb): support restricted database users --- .../desktop/src/lib/mongoConnectionOptions.ts | 8 +++ crates/dbx-core/src/connection.rs | 8 ++- crates/dbx-core/src/db/mongo_driver.rs | 5 +- crates/dbx-core/src/mongo_ops.rs | 54 +++++++++++++++++-- .../app-tests/mongoConnectionOptions.test.ts | 13 +++++ src-tauri/src/commands/connection.rs | 30 +++++++---- 6 files changed, 101 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/lib/mongoConnectionOptions.ts b/apps/desktop/src/lib/mongoConnectionOptions.ts index 58c1b282e..df1825860 100644 --- a/apps/desktop/src/lib/mongoConnectionOptions.ts +++ b/apps/desktop/src/lib/mongoConnectionOptions.ts @@ -15,6 +15,14 @@ export function setMongoUrlParam(urlParams: string | undefined, key: string, val } export function mongodbAuthFailureHint(message: string): string { + if (message.includes("must be URL encoded") || message.includes("cannot contain unescaped %")) { + return `${message}\n\nMongoDB URL mode requires reserved characters in usernames and passwords to be percent-encoded. For example, @ becomes %40, # becomes %23, / becomes %2F, : becomes %3A, and % becomes %25.`; + } + + if (message.includes("not authorized") && message.includes("listDatabases")) { + return `${message}\n\nThis MongoDB user can authenticate but does not have permission to run listDatabases on admin. Grant listDatabases/cluster monitor privileges, or set a specific default database that the user can access.`; + } + if (message.includes("Current authentication database:")) return message; const source = message.match(/source='([^']+)'/)?.[1]; diff --git a/crates/dbx-core/src/connection.rs b/crates/dbx-core/src/connection.rs index d748aa1cd..3b614ec9a 100644 --- a/crates/dbx-core/src/connection.rs +++ b/crates/dbx-core/src/connection.rs @@ -253,7 +253,13 @@ impl AppState { } DatabaseType::MongoDb => { let native_err = match db::mongo_driver::connect(&url, connect_timeout).await { - Ok(client) => match db::mongo_driver::test_connection(&client, connect_timeout).await { + Ok(client) => match db::mongo_driver::test_connection( + &client, + connect_timeout, + db_config.effective_database(), + ) + .await + { Ok(()) => { self.connections.write().await.insert(pool_key.clone(), PoolKind::MongoDb(client)); return Ok(pool_key); diff --git a/crates/dbx-core/src/db/mongo_driver.rs b/crates/dbx-core/src/db/mongo_driver.rs index b15db403e..6aa7c0cc1 100644 --- a/crates/dbx-core/src/db/mongo_driver.rs +++ b/crates/dbx-core/src/db/mongo_driver.rs @@ -20,8 +20,9 @@ pub async fn connect(url: &str, timeout: Duration) -> Result { .await } -pub async fn test_connection(client: &Client, timeout: Duration) -> Result<(), String> { - tokio::time::timeout(timeout, client.list_database_names()) +pub async fn test_connection(client: &Client, timeout: Duration, database: Option<&str>) -> Result<(), String> { + let database = database.map(str::trim).filter(|value| !value.is_empty()).unwrap_or("admin"); + tokio::time::timeout(timeout, client.database(database).run_command(doc! { "ping": 1 })) .await .map_err(|_| format!("MongoDB connection timed out ({}s)", timeout.as_secs()))? .map(|_| ()) diff --git a/crates/dbx-core/src/mongo_ops.rs b/crates/dbx-core/src/mongo_ops.rs index 7be55ecf0..30bb686d7 100644 --- a/crates/dbx-core/src/mongo_ops.rs +++ b/crates/dbx-core/src/mongo_ops.rs @@ -4,19 +4,45 @@ use crate::db::elasticsearch_driver; use crate::db::mongo_driver::{self, MongoDocumentResult}; pub async fn mongo_list_databases_core(state: &AppState, connection_id: &str) -> Result, String> { + let fallback_database = configured_mongo_database(state, connection_id).await; let connections = state.connections.read().await; match connections.get(connection_id).ok_or("Not found")? { - PoolKind::MongoDb(client) => mongo_driver::list_databases(client).await, + PoolKind::MongoDb(client) => match mongo_driver::list_databases(client).await { + Ok(databases) => Ok(databases), + Err(error) if mongo_list_databases_unauthorized(&error) => { + fallback_mongo_database(&error, fallback_database) + } + Err(error) => Err(error), + }, PoolKind::Elasticsearch(_) => Ok(vec!["default".to_string()]), PoolKind::Agent(client) => { let mut client = client.lock().await; - let result: Vec = client.mongo_list_databases().await?; - Ok(result.iter().filter_map(|v| v.get("name")?.as_str().map(String::from)).collect()) + match client.mongo_list_databases::>().await { + Ok(result) => Ok(result.iter().filter_map(|v| v.get("name")?.as_str().map(String::from)).collect()), + Err(error) if mongo_list_databases_unauthorized(&error) => { + fallback_mongo_database(&error, fallback_database) + } + Err(error) => Err(error), + } } _ => Err("Not a MongoDB/Elasticsearch connection".to_string()), } } +async fn configured_mongo_database(state: &AppState, connection_id: &str) -> Option { + let configs = state.configs.read().await; + configs.get(connection_id).and_then(|config| config.effective_database().map(str::to_string)) +} + +fn fallback_mongo_database(error: &str, fallback_database: Option) -> Result, String> { + fallback_database.map(|database| vec![database]).ok_or_else(|| error.to_string()) +} + +fn mongo_list_databases_unauthorized(error: &str) -> bool { + let lower = error.to_lowercase(); + lower.contains("not authorized") && lower.contains("listdatabases") +} + pub async fn mongo_list_collections_core( state: &AppState, connection_id: &str, @@ -228,3 +254,25 @@ pub async fn mongo_delete_documents_core( _ => Err("Not a MongoDB connection".to_string()), } } + +#[cfg(test)] +mod tests { + use super::{fallback_mongo_database, mongo_list_databases_unauthorized}; + + #[test] + fn detects_mongo_list_databases_unauthorized_errors() { + assert!(mongo_list_databases_unauthorized( + "Command failed with error 13 (Unauthorized): not authorized on admin to execute command { listDatabases: 1 }", + )); + assert!(!mongo_list_databases_unauthorized("not authorized to execute command { find: \"orders\" }")); + } + + #[test] + fn falls_back_to_configured_mongo_database() { + assert_eq!( + fallback_mongo_database("not authorized", Some("app".to_string())).unwrap(), + vec!["app".to_string()], + ); + assert_eq!(fallback_mongo_database("not authorized", None).unwrap_err(), "not authorized"); + } +} diff --git a/packages/app-tests/mongoConnectionOptions.test.ts b/packages/app-tests/mongoConnectionOptions.test.ts index 1fe9c1d4c..6c2e5895b 100644 --- a/packages/app-tests/mongoConnectionOptions.test.ts +++ b/packages/app-tests/mongoConnectionOptions.test.ts @@ -27,3 +27,16 @@ test("adds a MongoDB authSource hint for legacy authentication failures", () => "Agent RPC error: Exception authenticating MongoCredential{mechanism=SCRAM-SHA-1, userName='rwuser', source='gray_lite_twin_fat'}\n\nCurrent authentication database: gray_lite_twin_fat. If this user was created in admin, set Authentication database to admin or add authSource=admin to URL params.", ); }); + +test("adds a MongoDB URL encoding hint for reserved password characters", () => { + const message = "MongoDB connection failed: Kind: An invalid argument was provided: password must be URL encoded"; + + assert.match(mongodbAuthFailureHint(message), /@ becomes %40/); +}); + +test("adds a MongoDB listDatabases permission hint", () => { + const message = + "Command failed with error 13 (Unauthorized): not authorized on admin to execute command { listDatabases: 1 }"; + + assert.match(mongodbAuthFailureHint(message), /does not have permission to run listDatabases/); +}); diff --git a/src-tauri/src/commands/connection.rs b/src-tauri/src/commands/connection.rs index 5d8e02e56..d4660816a 100644 --- a/src-tauri/src/commands/connection.rs +++ b/src-tauri/src/commands/connection.rs @@ -334,10 +334,14 @@ pub async fn test_connection(state: State<'_, Arc>, config: Connection } DatabaseType::MongoDb => { let native_err = match db::mongo_driver::connect(&url, connect_timeout).await { - Ok(client) => match db::mongo_driver::test_connection(&client, connect_timeout).await { - Ok(()) => return Ok("Connection successful".to_string()), - Err(e) => e, - }, + Ok(client) => { + match db::mongo_driver::test_connection(&client, connect_timeout, config.effective_database()) + .await + { + Ok(()) => return Ok("Connection successful".to_string()), + Err(e) => e, + } + } Err(e) => e, }; if native_err.contains("wire version") { @@ -483,14 +487,18 @@ pub async fn connect_db(state: State<'_, Arc>, config: ConnectionConfi } DatabaseType::MongoDb => { let native_err = match db::mongo_driver::connect(&url, connect_timeout).await { - Ok(client) => match db::mongo_driver::test_connection(&client, connect_timeout).await { - Ok(()) => { - state.configs.write().await.insert(id.clone(), config); - state.connections.write().await.insert(id.clone(), PoolKind::MongoDb(client)); - return Ok(id); + Ok(client) => { + match db::mongo_driver::test_connection(&client, connect_timeout, db_config.effective_database()) + .await + { + Ok(()) => { + state.configs.write().await.insert(id.clone(), config); + state.connections.write().await.insert(id.clone(), PoolKind::MongoDb(client)); + return Ok(id); + } + Err(e) => e, } - Err(e) => e, - }, + } Err(e) => e, }; if native_err.contains("wire version") {