fix(mongodb): support restricted database users

This commit is contained in:
t8y2 2026-06-02 11:09:48 +08:00
parent b5f868959b
commit 81e1693c18
6 changed files with 101 additions and 17 deletions

View File

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

View File

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

View File

@ -20,8 +20,9 @@ pub async fn connect(url: &str, timeout: Duration) -> Result<Client, String> {
.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(|_| ())

View File

@ -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<Vec<String>, 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<serde_json::Value> = 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::<Vec<serde_json::Value>>().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<String> {
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<String>) -> Result<Vec<String>, 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");
}
}

View File

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

View File

@ -334,10 +334,14 @@ pub async fn test_connection(state: State<'_, Arc<AppState>>, 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<AppState>>, 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") {