fix(mongodb): support restricted database users
This commit is contained in:
parent
b5f868959b
commit
81e1693c18
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(|_| ())
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
Loading…
Reference in New Issue