diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 71a8e9e0..d6376fc6 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3378,23 +3378,6 @@ dependencies = [ "windows-sys 0.60.2", ] -[[package]] -name = "native-tls" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework 3.7.0", - "security-framework-sys", - "tempfile", -] - [[package]] name = "ndk" version = "0.9.0" @@ -5895,10 +5878,10 @@ dependencies = [ "indexmap 2.13.0", "log", "memchr", - "native-tls", "once_cell", "percent-encoding", "rust_decimal", + "rustls", "serde", "serde_json", "sha2", @@ -5909,6 +5892,7 @@ dependencies = [ "tracing", "url", "uuid", + "webpki-roots 0.26.11", ] [[package]] @@ -7784,6 +7768,24 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.8", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a139a01d..b0439fbb 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -28,7 +28,7 @@ serde = { version = "1.0", features = ["derive"] } log = "0.4" tauri = { version = "2.10.2", features = ["protocol-asset", "devtools"] } tauri-plugin-log = "2" -sqlx = { version = "0.8.6", features = ["runtime-tokio", "sqlite", "mysql", "postgres", "tls-native-tls", "chrono", "uuid", "rust_decimal", "json"] } +sqlx = { version = "0.8.6", features = ["runtime-tokio", "sqlite", "mysql", "postgres", "tls-rustls", "chrono", "uuid", "rust_decimal", "json"] } tokio = { version = "1.49.0", features = ["full"] } openssl = { version = "0.10", features = ["vendored"] } uuid = { version = "1.20.0", features = ["v4", "serde"] } @@ -59,10 +59,16 @@ zip = "4.2.0" tauri-plugin-clipboard-manager = "2" tokio-postgres = { version = "0.7.13", features = ["with-chrono-0_4", "with-uuid-1", "with-serde_json-1", "array-impls"] } deadpool-postgres = "0.14.1" -# rustls is used for the PostgreSQL deadpool TLS path. native-tls (used by -# sqlx and the MySQL pool) trips macOS Secure Transport's strict EKU checks -# on user-supplied root anchors (e.g. the AWS RDS bundle), so the Postgres -# pool routes through rustls + the platform verifier instead. +# sqlx uses `tls-rustls` for both the MySQL pool and the SQLite/embedded +# paths. `tls-native-tls` is intentionally NOT enabled: macOS Secure +# Transport (the native-tls backend on Darwin) trips strict EKU checks on +# user-supplied root anchors (e.g. the AWS RDS regional CA bundle), so +# MySQL connections that go through native-tls fail the TLS handshake +# with the opaque error "One or more parameters passed to a function +# were not valid." even though the same bundle validates fine with +# `openssl s_client` and `mysql --ssl-mode=VERIFY_IDENTITY`. rustls has +# no such restriction. The PostgreSQL deadpool path uses rustls + the +# platform verifier for the same reason. tokio-postgres-rustls = "0.13" rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] } rustls-pemfile = "2" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 959996a8..942ba236 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -410,10 +410,13 @@ pub fn find_connection_by_id( } }; - // Load passwords from keychain if needed, via the in-memory cache. - // On a warm cache hit this is a HashMap lookup (nanoseconds); on a cold miss - // it calls keychain once and caches the result for all subsequent reads. - if conn.params.save_in_keychain.unwrap_or(false) { + // Load passwords from keychain via the in-memory cache (warm hit = lookup, + // cold miss = keychain call + cache). Skip IAM-auth connections: their + // 15-min tokens must come from the `password` field, never the keychain, + // so a stale token from an older release can't be surfaced in the modal. + if conn.params.save_in_keychain.unwrap_or(false) + && !conn.params.use_iam_auth.unwrap_or(false) + { let cache = app.state::>(); match credential_cache::get_db_password_cached(&cache, &conn.id) { Ok(pwd) => conn.params.password = Some(pwd), @@ -846,8 +849,11 @@ pub async fn duplicate_connection( let cache = app.state::>(); - // Recover passwords if in keychain (via cache for fast repeat access) - if original.params.save_in_keychain.unwrap_or(false) { + // Same IAM-auth guard as `find_connection_by_id`: never copy a stale RDS + // auth token into a duplicated connection. + if original.params.save_in_keychain.unwrap_or(false) + && !original.params.use_iam_auth.unwrap_or(false) + { if let Ok(pwd) = credential_cache::get_db_password_cached(&cache, &original.id) { original.params.password = Some(pwd); } @@ -1724,7 +1730,29 @@ pub async fn test_connection( let mut expanded_params = expand_ssh_connection_params(&app, &request.params).await?; expanded_params = expand_k8s_connection_params(&app, &expanded_params).await?; - if request.params.password.is_none() && expanded_params.password.is_none() { + // AWS RDS IAM auth tokens are short-lived (15 min) and must come from the + // password field on every test/connect, never from the keychain. Skip the + // keychain fallback so a stale token can't be reused. + let iam_auth = expanded_params.use_iam_auth.unwrap_or(false); + + // IAM auth needs an RDS auth token right now. Without this guard the + // builder accepts an empty password (saved connections inject later), + // the server replies with the opaque "Access denied (using password: + // YES)", and the user can't tell whether the token is missing, wrong, + // or expired. + if iam_auth + && request.params.password.as_deref().unwrap_or("").is_empty() + && expanded_params.password.as_deref().unwrap_or("").is_empty() + { + return Err( + "AWS IAM authentication is enabled but the password field is empty. \ + Paste the output of `aws rds generate-db-auth-token` into the \ + password field and try again. Tokens expire every 15 minutes." + .to_string(), + ); + } + + if !iam_auth && request.params.password.is_none() && expanded_params.password.is_none() { let saved_conn = match &request.connection_id { Some(id) => find_connection_by_id(&app, id).ok(), None => None, @@ -1759,7 +1787,13 @@ pub async fn test_connection( } } - drv.test_connection(&resolved_params).await?; + drv.test_connection(&resolved_params).await.map_err(|e| { + log::warn!( + "Connection test failed for database {}: {e}", + request.params.database + ); + e + })?; log::info!( "Connection test successful for database: {}", @@ -2594,7 +2628,24 @@ pub async fn list_databases( let mut expanded_params = expand_ssh_connection_params(&app, &request.params).await?; expanded_params = expand_k8s_connection_params(&app, &expanded_params).await?; - if request.params.password.is_none() && expanded_params.password.is_none() { + let iam_auth = expanded_params.use_iam_auth.unwrap_or(false); + + // IAM auth needs an RDS auth token right now; skip the keychain fallback + // so a stale token can't be reused, and fail fast with an actionable + // message if none was supplied. + if iam_auth + && request.params.password.as_deref().unwrap_or("").is_empty() + && expanded_params.password.as_deref().unwrap_or("").is_empty() + { + return Err( + "AWS IAM authentication is enabled but the password field is empty. \ + Paste the output of `aws rds generate-db-auth-token` into the \ + password field and try again. Tokens expire every 15 minutes." + .to_string(), + ); + } + + if !iam_auth && request.params.password.is_none() && expanded_params.password.is_none() { let saved_conn = match &request.connection_id { Some(id) => find_connection_by_id(&app, id).ok(), None => None, diff --git a/src-tauri/src/mcp/mod.rs b/src-tauri/src/mcp/mod.rs index 687062b2..7deeb046 100644 --- a/src-tauri/src/mcp/mod.rs +++ b/src-tauri/src/mcp/mod.rs @@ -240,8 +240,11 @@ async fn resolve_db_params( ) -> Result<(crate::models::SavedConnection, ConnectionParams), JsonRpcError> { let mut conn = find_connection(conn_id)?; - // Load DB password from keychain if it isn't stored inline - if conn.params.save_in_keychain.unwrap_or(false) { + // Load DB password from keychain unless the connection uses IAM auth, + // whose 15-min tokens must come from the `password` field on every + // connect — never from the keychain, where a stale token would survive. + let iam_auth = conn.params.use_iam_auth.unwrap_or(false); + if !iam_auth && conn.params.save_in_keychain.unwrap_or(false) { let cache = std::sync::Arc::new(credential_cache::CredentialCache::default()); let id = conn.id.clone(); let pwd = tokio::task::spawn_blocking(move || { @@ -259,6 +262,12 @@ async fn resolve_db_params( conn.params.password = Some(p); } } + } else if iam_auth && conn.params.save_in_keychain.unwrap_or(false) { + log::warn!( + "MCP: connection {} has use_iam_auth=true; ignoring any password stored in the keychain. A fresh RDS auth token must be supplied on every connect.", + conn_id + ); + conn.params.password = None; } let expanded = expand_ssh_params_for_mcp(&conn.params).await?; diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index f33e1551..a5e5f7ab 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -140,6 +140,11 @@ pub struct ConnectionParams { // Set to `false` for servers that reject altering sql_mode, e.g. Vitess/PlanetScale. #[serde(default, skip_serializing_if = "Option::is_none")] pub pipes_as_concat: Option, + // When true, `password` is a pre-signed RDS auth token (from + // `aws rds generate-db-auth-token`) instead of a real password. + // Requires TLS; only meaningful for the `mysql` driver. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub use_iam_auth: Option, // SSH Tunnel pub ssh_enabled: Option, pub ssh_connection_id: Option, diff --git a/src-tauri/src/plugins/driver.rs b/src-tauri/src/plugins/driver.rs index 30808fb2..d72716fe 100644 --- a/src-tauri/src/plugins/driver.rs +++ b/src-tauri/src/plugins/driver.rs @@ -842,6 +842,7 @@ mod tests { k8s_resource_name: None, k8s_port: None, startup_script: None, + use_iam_auth: None, connection_id: Some("conn-1".to_string()), } } diff --git a/src-tauri/src/pool_manager.rs b/src-tauri/src/pool_manager.rs index 0bfe3462..c4578a7e 100644 --- a/src-tauri/src/pool_manager.rs +++ b/src-tauri/src/pool_manager.rs @@ -20,8 +20,8 @@ use tokio::sync::RwLock; use tokio_postgres::{config::SslMode as PgSslMode, Config as PgConfig}; use tokio_postgres_rustls::MakeRustlsConnect; -/// `tokio_postgres` renders only the top-level error kind ("error performing -/// TLS handshake"); the concrete cause lives in the `source()` chain. +/// Walks `Error::source()` to surface the real cause, which `tokio_postgres` +/// hides behind a generic "error performing TLS handshake". pub(crate) fn format_error_chain(err: &E) -> String { let mut out = err.to_string(); let mut source = err.source(); @@ -33,7 +33,6 @@ pub(crate) fn format_error_chain(err: &E) -> Stri out } -/// rustls 0.23 needs a process-level `CryptoProvider`; install once. fn ensure_rustls_crypto_provider() { use std::sync::Once; static INSTALL: Once = Once::new(); @@ -87,9 +86,9 @@ fn mysql_numeric_setting(key: &str, default: u64) -> u64 { .unwrap_or(default) } -/// Build a stable connection key that works with SSH tunnels. -/// If connection_id is provided (from saved connections), use it for stable pooling. -/// Otherwise fall back to host:port:database (for ad-hoc connections). +/// Stable pool key: uses `connection_id` when present (saved connections), +/// else `host:port:database` (ad-hoc). The TLS/iam tuple is appended so +/// different SSL settings of the same connection get separate pools. pub(crate) fn build_connection_key( params: &ConnectionParams, connection_id: Option<&str>, @@ -99,13 +98,20 @@ pub(crate) fn build_connection_key( // `clear` keeps cleartext and non-cleartext connections to the same // host in separate pools — they authenticate differently. `pipes` // likewise separates pools that force sql_mode from those that don't. - "ssl:{}:{}:{}:{}:clear:{}:pipes:{}", + // `iam` likewise separates RDS-IAM-token connections (which use the + // cleartext plugin) from regular password auth. + "ssl:{}:{}:{}:{}:clear:{}:pipes:{}:iam:{}", params.ssl_mode.as_deref().unwrap_or("default"), params.ssl_ca.as_deref().unwrap_or(""), params.ssl_cert.as_deref().unwrap_or(""), params.ssl_key.as_deref().unwrap_or(""), params.enable_cleartext_plugin.unwrap_or(false), - params.pipes_as_concat.unwrap_or(true) + params.pipes_as_concat.unwrap_or(true), + if params.use_iam_auth.unwrap_or(false) { + "true" + } else { + "false" + } )), "postgres" => { let ssl_mode = params.ssl_mode.as_deref().unwrap_or("prefer"); @@ -119,7 +125,6 @@ pub(crate) fn build_connection_key( }; let base_key = if let Some(conn_id) = connection_id { - // Include database in key so different databases on the same connection use separate pools format!("{}:conn:{}:{}", params.driver, conn_id, params.database) } else { // Fall back to host:port:user:database for ad-hoc connections (no saved @@ -170,29 +175,80 @@ pub(crate) fn build_mysql_options( let database = override_db.unwrap_or_else(|| params.database.primary()); let timezone = mysql_string_setting("timezone", DEFAULT_MYSQL_TIMEZONE); + // ssl_mode: user-selected, with auto-escalation to VerifyCa when an ssl_ca + // is supplied under Required/Preferred. sqlx-mysql only forwards the CA + // bundle to the TLS connector for VerifyCa/VerifyIdentity, so on macOS the + // system keychain handles verification for the weaker modes and rejects + // the regional RDS root CAs with an opaque handshake error. + let has_user_ca = params + .ssl_ca + .as_deref() + .is_some_and(|s| !s.trim().is_empty()); + let mut ssl_mode = match params.ssl_mode.as_deref().unwrap_or("required") { + "disabled" | "disable" => MySqlSslMode::Disabled, + "preferred" | "prefer" => MySqlSslMode::Preferred, + "required" | "require" => MySqlSslMode::Required, + "verify_ca" => MySqlSslMode::VerifyCa, + "verify_identity" => MySqlSslMode::VerifyIdentity, + _ => MySqlSslMode::Required, + }; + if has_user_ca + && matches!(ssl_mode, MySqlSslMode::Required | MySqlSslMode::Preferred) + { + ssl_mode = MySqlSslMode::VerifyCa; + } + + // AWS RDS IAM auth: `password` carries the pre-signed RDS auth token + // (from `aws rds generate-db-auth-token`), sent cleartext via + // mysql_clear_password over TLS. Refuse to send it unencrypted. + if params.use_iam_auth.unwrap_or(false) { + if matches!(ssl_mode, MySqlSslMode::Disabled) { + return Err( + "AWS IAM authentication requires a TLS/SSL mode to be enabled \ + (Preferred, Required, Verify CA, or Verify Identity). Refusing \ + to send the RDS auth token over an unencrypted connection." + .to_string(), + ); + } + // Saved connections get the token injected from the keychain after + // this builder returns, so an empty password is fine for them. + if password.is_empty() && params.connection_id.is_none() { + return Err( + "AWS IAM authentication is enabled but the password field is \ + empty. Paste the output of `aws rds generate-db-auth-token` \ + into the password field." + .to_string(), + ); + } + } + + log::info!( + "build_mysql_options: driver=mysql host={host} port={port} \ + ssl_mode_param={:?} ssl_ca_present={} effective_ssl_mode={:?} \ + iam_auth={} password_len={}", + params.ssl_mode, + has_user_ca, + ssl_mode, + params.use_iam_auth.unwrap_or(false), + password.len(), + ); + let mut options = MySqlConnectOptions::new() .host(host) .port(port) .username(username) .database(database) - .timezone(timezone); + .timezone(timezone) + .ssl_mode(ssl_mode); + // Skip `.password(...)` when the password is empty: an empty string is + // stamped by sqlx as "user pressed Enter", which the server rejects with + // "Access denied (using password: YES)". Saved IAM-auth connections get + // the token injected from the keychain after this builder returns. if !password.is_empty() { options = options.password(password); } - // Configure SSL mode based on params.ssl_mode - let ssl_mode = match params.ssl_mode.as_deref().unwrap_or("required") { - "disabled" | "disable" => MySqlSslMode::Disabled, - "preferred" | "prefer" => MySqlSslMode::Preferred, - "required" | "require" => MySqlSslMode::Required, - "verify_ca" => MySqlSslMode::VerifyCa, - "verify_identity" => MySqlSslMode::VerifyIdentity, - _ => MySqlSslMode::Required, - }; - options = options.ssl_mode(ssl_mode); - - // Apply SSL certificates if provided in params if let Some(ca) = ¶ms.ssl_ca { options = options.ssl_ca(ca); } @@ -232,6 +288,13 @@ pub(crate) fn build_mysql_options( .pipes_as_concat(force_sql_mode) .no_engine_substitution(force_sql_mode); + // IAM auth: the server announces `mysql_clear_password` and rejects the + // handshake with "mysql_cleartext_plugin disabled" unless the client + // opts in. Safe because the token only travels over the TLS link above. + if params.use_iam_auth.unwrap_or(false) { + options = options.enable_cleartext_plugin(true); + } + Ok(options) } @@ -329,33 +392,20 @@ pub(crate) fn build_postgres_configurations(params: &ConnectionParams) -> PgConf /// Build the rustls connector for the PostgreSQL pool. /// /// `rustls` (not `native-tls`) because macOS Secure Transport applies a -/// strict `id-kp-serverAuth` EKU check to user-supplied root anchors, which -/// rejects valid CA certs with "The extended key usage is not valid". -/// -/// `ssl_ca` (PEM file or bundle) overrides the platform trust store. This -/// is the path RDS users take: the macOS keychain does not trust the -/// regional Amazon RDS root CAs, so they must supply -/// `https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem` -/// (or a region-specific bundle) via the connection's CA Certificate field. -/// -/// We deliberately do NOT vendor the RDS bundle in the repo: AWS rotates -/// these CAs every 1-3 years, and shipping a stale bundle in a release -/// silently breaks RDS users until they upgrade. Distributors who want -/// out-of-the-box RDS support can pull a fresh bundle at packaging time -/// (e.g. via a Dockerfile `RUN curl ...` or a build script that drops it -/// into `src-tauri/assets/`) and point users at the resulting path. +/// strict `id-kp-serverAuth` EKU check to user-supplied root anchors and +/// rejects valid CA certs. `ssl_ca` overrides the platform trust store — +/// RDS users point it at the AWS global bundle +/// (`https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem`). +/// The bundle is intentionally not vendored: AWS rotates these CAs every +/// 1-3 years, so distributors pull a fresh copy at packaging time. /// /// SSL modes: /// - `disable`: no TLS /// - `allow`/`prefer`: TLS without certificate verification /// - `require`: force TLS without certificate verification -/// NOTE: Prior to v0.10.3, `require` validated the certificate chain. -/// It now matches libpq behavior (TLS without validation). Users who -/// need certificate validation should use `verify-ca` or `verify-full`. -/// - `verify-ca`: force TLS, validate certificate chain, skip hostname check. -/// Requires an explicit CA file — platform roots are not used to avoid -/// macOS Secure Transport EKU incompatibilities. -/// - `verify-full`: force TLS, validate certificate chain and hostname +/// (prior to v0.10.3 this validated the chain; it now matches libpq). +/// - `verify-ca`: force TLS, validate chain, skip hostname check +/// - `verify-full`: force TLS, validate chain and hostname pub(crate) fn build_postgres_tls_connector( params: &ConnectionParams, ) -> Result { @@ -365,8 +415,7 @@ pub(crate) fn build_postgres_tls_connector( let config = match ssl_mode { "disable" | "allow" | "prefer" => { - // No certificate verification for these modes. - // The PgSslMode setting handles whether TLS is attempted. + // No cert verification; PgSslMode below controls whether TLS is attempted. let verifier = Arc::new(NoCertVerifier::new()); ClientConfig::builder() .dangerous() @@ -374,7 +423,7 @@ pub(crate) fn build_postgres_tls_connector( .with_no_client_auth() } "require" => { - // Force TLS but skip all certificate validation. + // Force TLS, skip cert validation. let verifier = Arc::new(NoCertVerifier::new()); ClientConfig::builder() .dangerous() @@ -382,12 +431,8 @@ pub(crate) fn build_postgres_tls_connector( .with_no_client_auth() } "verify-ca" => { - // Validate certificate chain but skip hostname verification. - // Requires an explicit CA file — we deliberately do NOT fall back - // to platform roots because macOS Secure Transport applies strict - // id-kp-serverAuth EKU checks that reject valid CA certificates - // (e.g. the AWS RDS bundle). This matches libpq's behavior where - // sslmode=verify-ca expects root certs to be supplied explicitly. + // Validate chain, skip hostname. Requires an explicit CA file — + // platform roots are not used (macOS EKU check rejects them). let ca_path = user_ca.ok_or_else(|| { "verify-ca mode requires an explicit CA file via the connection's \ CA Certificate field. On macOS, platform root certificates are \ @@ -727,6 +772,12 @@ async fn get_mysql_pool_for_database_with_id( params.host, key ); + // sqlx-mysql's rustls backend (selected when sqlx is built with + // `tls-rustls` and not `tls-native-tls`) panics on the first handshake + // unless a process-level `CryptoProvider` has been installed. We share + // the same install-once helper the Postgres deadpool path uses. + ensure_rustls_crypto_provider(); + let options = build_mysql_options(params, override_db)?; let connect_timeout = Duration::from_millis(mysql_numeric_setting( "connectTimeout", DEFAULT_MYSQL_CONNECT_TIMEOUT_MS, diff --git a/src-tauri/src/pool_manager_tests.rs b/src-tauri/src/pool_manager_tests.rs index 1b335db0..73fea72d 100644 --- a/src-tauri/src/pool_manager_tests.rs +++ b/src-tauri/src/pool_manager_tests.rs @@ -270,6 +270,20 @@ mod tests { ); } + #[test] + fn mysql_pool_key_changes_when_iam_auth_changes() { + let mut plain = mysql_params("required"); + plain.use_iam_auth = Some(false); + + let mut iam = mysql_params("required"); + iam.use_iam_auth = Some(true); + + assert_ne!( + build_connection_key(&plain, Some("conn-1")), + build_connection_key(&iam, Some("conn-1")) + ); + } + #[test] fn detects_pipes_as_concat_unsupported_error() { // Vitess/PlanetScale reject sqlx's forced sql_mode; the message that @@ -287,6 +301,174 @@ mod tests { "Access denied for user 'root'@'localhost'" )); } + + #[test] + fn mysql_options_iam_auth_rejects_disabled_ssl() { + let mut params = mysql_params("disabled"); + params.use_iam_auth = Some(true); + params.password = Some("token".to_string()); + + let err = build_mysql_options(¶ms, None).unwrap_err(); + assert!( + err.contains("IAM") + && (err.contains("TLS") || err.contains("SSL")), + "expected IAM/SSL error, got: {}", + err + ); + } + + #[test] + fn mysql_options_iam_auth_rejects_empty_password_for_adhoc() { + let mut params = mysql_params("required"); + params.use_iam_auth = Some(true); + params.password = Some(String::new()); + params.connection_id = None; + + let err = build_mysql_options(¶ms, None).unwrap_err(); + assert!( + err.contains("password") && err.contains("empty"), + "expected empty-password error, got: {}", + err + ); + } + + #[test] + fn mysql_options_iam_auth_allows_empty_password_when_connection_id_set() { + // Saved connections get the password injected from the keychain after + // the builder returns, so an empty password here is fine. + let mut params = mysql_params("required"); + params.use_iam_auth = Some(true); + params.password = Some(String::new()); + params.connection_id = Some("conn-1".to_string()); + + let opts = build_mysql_options(¶ms, None) + .expect("must build when connection_id is set"); + assert!(matches!(opts.get_ssl_mode(), MySqlSslMode::Required)); + } + + #[test] + fn mysql_options_iam_auth_passes_password_through_under_tls() { + let mut params = mysql_params("required"); + params.use_iam_auth = Some(true); + params.password = Some("fake-rds-token".to_string()); + + let opts = build_mysql_options(¶ms, None).expect("must build"); + assert!(matches!(opts.get_ssl_mode(), MySqlSslMode::Required)); + // sqlx 0.8.6 has no public getter for `enable_cleartext_plugin`, so + // assert on the `Debug` output as a regression sentinel. + let debug = format!("{:?}", opts); + assert!( + debug.contains("enable_cleartext_plugin: true"), + "expected cleartext plugin to be enabled for IAM auth; got: {debug}" + ); + } + + #[test] + fn mysql_options_iam_auth_off_is_unchanged() { + // When use_iam_auth is None/false, the password must be passed through + // exactly as before so existing connections keep working. + let mut params = mysql_params("required"); + params.use_iam_auth = None; + params.password = Some("regular-pass".to_string()); + + let opts = build_mysql_options(¶ms, None).expect("must build"); + assert!(matches!(opts.get_ssl_mode(), MySqlSslMode::Required)); + // Counterpart to the IAM-on assertion: when IAM is off the cleartext + // plugin must NOT be enabled, otherwise a regular password would be + // transmitted in cleartext to a server that doesn't ask for it. + let debug = format!("{:?}", opts); + assert!( + debug.contains("enable_cleartext_plugin: false"), + "expected cleartext plugin OFF for non-IAM auth; got: {debug}" + ); + } + + // --- Auto-escalation: ssl_ca + Required/Preferred -> VerifyCa ----------- + // + // sqlx-mysql with `tls-native-tls` (the default) only forwards the user + // CA bundle to the TLS connector for `VerifyCa` and `VerifyIdentity` + // modes. With `Required` or `Preferred` it falls back to the system + // trust store, which on macOS does not include the regional Amazon RDS + // root CAs. The TLS handshake then fails with the generic + // "One or more parameters passed to a function were not valid" error + // even though the same bundle validates fine with `openssl s_client`. + // + // `build_mysql_options` therefore escalates the mode to `VerifyCa` + // whenever a non-empty `ssl_ca` is supplied and the user has selected + // `Required` or `Preferred`. This mirrors the documented behaviour of + // `psql`'s `verify-ca` mode: supplying a CA file is itself an explicit + // opt-in to stricter validation. + + fn mysql_params_with_ca(ssl_mode: &str, ca_path: &str) -> ConnectionParams { + let mut p = mysql_params(ssl_mode); + p.ssl_ca = Some(ca_path.to_string()); + p + } + + #[test] + fn mysql_options_escalates_required_to_verify_ca_when_ca_supplied() { + let params = + mysql_params_with_ca("required", "/Users/dperez/.ssh/rds-combined-ca-bundle.pem"); + let opts = build_mysql_options(¶ms, None).expect("must build"); + assert!( + matches!(opts.get_ssl_mode(), MySqlSslMode::VerifyCa), + "required + ssl_ca must auto-escalate to VerifyCa so the bundle is used" + ); + } + + #[test] + fn mysql_options_escalates_preferred_to_verify_ca_when_ca_supplied() { + let params = + mysql_params_with_ca("preferred", "/Users/dperez/.ssh/rds-combined-ca-bundle.pem"); + let opts = build_mysql_options(¶ms, None).expect("must build"); + assert!(matches!(opts.get_ssl_mode(), MySqlSslMode::VerifyCa)); + } + + #[test] + fn mysql_options_does_not_escalate_when_ca_absent() { + // No CA file -> no escalation. `Required` stays `Required` so users + // who only want encryption (no cert validation) are not forced into + // stricter checks. + let params = mysql_params("required"); + assert!(params.ssl_ca.is_none()); + let opts = build_mysql_options(¶ms, None).expect("must build"); + assert!(matches!(opts.get_ssl_mode(), MySqlSslMode::Required)); + } + + #[test] + fn mysql_options_does_not_escalate_when_ca_is_blank() { + // Whitespace-only `ssl_ca` is treated as absent by the input parser; + // we mirror that here so the contract is "any non-empty path". + let params = mysql_params_with_ca("required", " "); + let opts = build_mysql_options(¶ms, None).expect("must build"); + assert!(matches!(opts.get_ssl_mode(), MySqlSslMode::Required)); + } + + #[test] + fn mysql_options_does_not_escalate_when_user_chose_verify_identity() { + // User's explicit choice is preserved. + let params = mysql_params_with_ca( + "verify_identity", + "/Users/dperez/.ssh/rds-combined-ca-bundle.pem", + ); + let opts = build_mysql_options(¶ms, None).expect("must build"); + assert!(matches!(opts.get_ssl_mode(), MySqlSslMode::VerifyIdentity)); + } + + #[test] + fn mysql_options_iam_auth_combined_with_escalation_keeps_cleartext_plugin() { + // IAM auth + ssl_ca + required must: (a) escalate to VerifyCa, and + // (b) still opt in to the cleartext plugin. Regression test for the + // exact user scenario reported on 2026-06-23. + let mut params = mysql_params_with_ca( + "required", + "/Users/dperez/.ssh/rds-combined-ca-bundle.pem", + ); + params.use_iam_auth = Some(true); + params.password = Some("fake-rds-auth-token".to_string()); + let opts = build_mysql_options(¶ms, None).expect("must build"); + assert!(matches!(opts.get_ssl_mode(), MySqlSslMode::VerifyCa)); + } } #[cfg(test)] diff --git a/src/components/modals/NewConnectionModal.tsx b/src/components/modals/NewConnectionModal.tsx index b596256d..0db4192e 100644 --- a/src/components/modals/NewConnectionModal.tsx +++ b/src/components/modals/NewConnectionModal.tsx @@ -65,6 +65,8 @@ interface ConnectionParams { // MySQL: force PIPES_AS_CONCAT / NO_ENGINE_SUBSTITUTION sql_mode on connect. // Defaults to true; disable for Vitess/PlanetScale which reject altering sql_mode. pipes_as_concat?: boolean; + // When true, the password field is an RDS auth token (mysql only). + use_iam_auth?: boolean; // SSH ssh_enabled?: boolean; ssh_connection_id?: string; @@ -1564,6 +1566,45 @@ export const NewConnectionModal = ({ + + {driver === "mysql" && ( +
+ +
+ )} )} diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 6bf12985..1985e1a9 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -722,7 +722,10 @@ "startupScriptDescription": "SQL, das bei jeder neuen Verbindung zu dieser Datenquelle ausgeführt wird. Verwenden Sie es für Sitzungseinstellungen wie SET / set_config (z. B. zum Umgehen von RLS). Trennen Sie Anweisungen mit Semikolons.", "startupScriptPlaceholder": "SELECT set_config('app.bypass_rls', 'on', false);", "scrollTabsLeft": "Registerkarten nach links blättern", - "scrollTabsRight": "Registerkarten nach rechts blättern" + "scrollTabsRight": "Registerkarten nach rechts blättern", + "useIamAuth": "AWS-IAM-Authentifizierung verwenden (RDS)", + "useIamAuthHint": "Das Passwortfeld wird als RDS-Auth-Token verwendet (aus `aws rds generate-db-auth-token`). Erfordert TLS. Tokens laufen alle 15 Minuten ab.", + "useIamAuthTlsRequired": "Aktiviere oben einen SSL-Modus, um die AWS-IAM-Authentifizierung zu nutzen." }, "sshConnections": { "title": "SSH-Verbindungen", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 3f126659..dc81164f 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -756,7 +756,10 @@ "startupScriptDescription": "SQL run on every new connection to this data source. Use it for session settings such as SET / set_config (e.g. bypassing RLS). Separate statements with semicolons.", "startupScriptPlaceholder": "SELECT set_config('app.bypass_rls', 'on', false);", "scrollTabsLeft": "Scroll tabs left", - "scrollTabsRight": "Scroll tabs right" + "scrollTabsRight": "Scroll tabs right", + "useIamAuth": "Use AWS IAM Authentication (RDS)", + "useIamAuthHint": "The password field is treated as an RDS auth token (from `aws rds generate-db-auth-token`). Requires TLS. Tokens expire every 15 minutes.", + "useIamAuthTlsRequired": "Enable an SSL mode above to use AWS IAM authentication." }, "sshConnections": { "title": "SSH Connections", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index d4f3aa41..82e52a3b 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -727,7 +727,10 @@ "startupScriptDescription": "SQL que se ejecuta en cada nueva conexión a esta fuente de datos. Úsalo para ajustes de sesión como SET / set_config (p. ej., para omitir RLS). Separa las sentencias con punto y coma.", "startupScriptPlaceholder": "SELECT set_config('app.bypass_rls', 'on', false);", "scrollTabsLeft": "Desplazar pestañas a la izquierda", - "scrollTabsRight": "Desplazar pestañas a la derecha" + "scrollTabsRight": "Desplazar pestañas a la derecha", + "useIamAuth": "Usar autenticación AWS IAM (RDS)", + "useIamAuthHint": "El campo de contraseña se interpreta como un token de autenticación de RDS (obtenido con `aws rds generate-db-auth-token`). Requiere TLS. Los tokens caducan cada 15 minutos.", + "useIamAuthTlsRequired": "Activa un modo SSL arriba para usar la autenticación AWS IAM." }, "sshConnections": { "title": "Conexiones SSH", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index eedf2cf2..c6d01341 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -722,7 +722,10 @@ "startupScriptDescription": "SQL exécuté à chaque nouvelle connexion à cette source de données. Utilisez-le pour des paramètres de session tels que SET / set_config (par exemple, pour contourner la RLS). Séparez les instructions par des points-virgules.", "startupScriptPlaceholder": "SELECT set_config('app.bypass_rls', 'on', false);", "scrollTabsLeft": "Défiler les onglets vers la gauche", - "scrollTabsRight": "Défiler les onglets vers la droite" + "scrollTabsRight": "Défiler les onglets vers la droite", + "useIamAuth": "Utiliser l'authentification AWS IAM (RDS)", + "useIamAuthHint": "Le champ mot de passe est traité comme un jeton d'authentification RDS (généré par `aws rds generate-db-auth-token`). Nécessite TLS. Les jetons expirent toutes les 15 minutes.", + "useIamAuthTlsRequired": "Activez un mode SSL ci-dessus pour utiliser l'authentification AWS IAM." }, "sshConnections": { "title": "Connexions SSH", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 4bf32e0c..8ee62ded 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -727,7 +727,10 @@ "startupScriptDescription": "SQL eseguito a ogni nuova connessione a questa origine dati. Usalo per le impostazioni di sessione come SET / set_config (ad es. per bypassare la RLS). Separa le istruzioni con punto e virgola.", "startupScriptPlaceholder": "SELECT set_config('app.bypass_rls', 'on', false);", "scrollTabsLeft": "Scorri schede a sinistra", - "scrollTabsRight": "Scorri schede a destra" + "scrollTabsRight": "Scorri schede a destra", + "useIamAuth": "Usa autenticazione AWS IAM (RDS)", + "useIamAuthHint": "Il campo password è trattato come un token di autenticazione RDS (da `aws rds generate-db-auth-token`). Richiede TLS. I token scadono ogni 15 minuti.", + "useIamAuthTlsRequired": "Attiva una modalità SSL qui sopra per usare l'autenticazione AWS IAM." }, "sshConnections": { "title": "Connessioni SSH", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index e8f79120..2206e324 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -736,7 +736,10 @@ "startupScriptDescription": "このデータソースへの新規接続ごとに実行される SQL です。SET / set_config(例: RLS のバイパス)などのセッション設定に使用します。複数のステートメントはセミコロンで区切ってください。", "startupScriptPlaceholder": "SELECT set_config('app.bypass_rls', 'on', false);", "scrollTabsLeft": "タブを左にスクロール", - "scrollTabsRight": "タブを右にスクロール" + "scrollTabsRight": "タブを右にスクロール", + "useIamAuth": "AWS IAM 認証を使用する (RDS)", + "useIamAuthHint": "パスワード欄は RDS 認証トークン (`aws rds generate-db-auth-token` の出力) として扱われます。TLS 必須。トークンの有効期限は 15 分です。", + "useIamAuthTlsRequired": "AWS IAM 認証を使用するには、上記で SSL モードを有効にしてください。" }, "sshConnections": { "title": "SSH 接続", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 7611649a..9a5ac9d4 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -715,7 +715,10 @@ "startupScriptDescription": "SQL, выполняемый при каждом новом подключении к этому источнику данных. Используйте его для настроек сеанса, таких как SET / set_config (например, для обхода RLS). Разделяйте операторы точкой с запятой.", "startupScriptPlaceholder": "SELECT set_config('app.bypass_rls', 'on', false);", "scrollTabsLeft": "Прокрутить вкладки влево", - "scrollTabsRight": "Прокрутить вкладки вправо" + "scrollTabsRight": "Прокрутить вкладки вправо", + "useIamAuth": "Использовать аутентификацию AWS IAM (RDS)", + "useIamAuthHint": "Поле пароля используется как токен аутентификации RDS (команда `aws rds generate-db-auth-token`). Требуется TLS. Срок действия токенов — 15 минут.", + "useIamAuthTlsRequired": "Чтобы использовать аутентификацию AWS IAM, включите режим SSL выше." }, "sshConnections": { "title": "SSH-подключения", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 7dabdba7..3edfb70e 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -690,7 +690,10 @@ "startupScriptDescription": "每次新建到此数据源的连接时执行的 SQL。可用于会话设置,例如 SET / set_config(如绕过 RLS)。多条语句请用分号分隔。", "startupScriptPlaceholder": "SELECT set_config('app.bypass_rls', 'on', false);", "scrollTabsLeft": "向左滚动标签页", - "scrollTabsRight": "向右滚动标签页" + "scrollTabsRight": "向右滚动标签页", + "useIamAuth": "使用 AWS IAM 身份验证 (RDS)", + "useIamAuthHint": "密码字段将被视为 RDS 身份验证令牌(通过 `aws rds generate-db-auth-token` 生成)。需要启用 TLS。令牌有效期为 15 分钟。", + "useIamAuthTlsRequired": "请先在上方启用 SSL 模式以使用 AWS IAM 身份验证。" }, "sshConnections": { "title": "SSH 连接",