feat(mysql): support AWS RDS IAM authentication#404
Conversation
…alation Adds AWS RDS IAM database authentication for MySQL connections, with auto-escalation of SSL mode to VerifyCa when a CA bundle is configured. Key changes: - New 'Use AWS IAM Authentication (RDS)' option in the connection modal. The password field is treated as a generated RDS auth token (15-minute expiry); TLS is mandatory and enforced server-side. - Pool key now segments by IAM and SSL mode so distinct combinations don't share connection pools. - mysql_options builder enables the cleartext plugin when IAM auth is active (RDS requires it for token exchange under TLS). - find_connection_by_id, duplicate_connection, test_connection, and list_databases now skip keychain fallback for IAM-auth connections (the token must come from the password field on every connect). - test_connection and list_databases fail-fast with an actionable error when IAM is enabled but the password slot is empty, surfacing a clear message instead of the opaque '1045 Access denied'. - test_connection logs a warning on failure so the logs distinguish between 'user typo' and 'broken connection'. - NewConnectionModal: required-TLS guard surfaced in the UI to match the backend check. - mcp/mod.rs: pass IAM flag through to the connection-options builder. - 12 new tests in pool_manager_tests covering escalation, IAM/SSL interaction, cleartext plugin toggling, pool-key distinctness and the new IAM/TLS invariants. - i18n: new strings translated across 8 locales (en, es, de, fr, it, ja, ru, zh).
The IAM auth, SSL auto-escalation, and cleartext plugin code is self-explanatory once the surrounding prose is removed. Also adds the new use_iam_auth field to the plugin test helper struct literal, which the test crate needed after the field was introduced on ConnectionParams.
The 183b754 chore commit added a second test_connection impl in drivers/mysql/mod.rs, but the file already had one routed through build_mysql_options (which honours pipes_as_concat, IAM, and the auto-fallback). Keep the more general one.
Code Review SummaryStatus: 1 Critical Issue | Recommendation: Address before merge Overview
Issue Details (click to expand)CRITICAL
Files Reviewed (16 files)
Technical AssessmentCode Changes Summary
Risk Assessment
Fix these issues in Kilo Cloud Reviewed by nemotron-3-nano-30b-a3b:free · Input: 348.8K · Output: 5.1K · Cached: 187.3K |
# Conflicts: # src-tauri/src/pool_manager.rs
NewtTheWolf
left a comment
There was a problem hiding this comment.
Nicely structured — serde/persistence round-trip and i18n are clean, and the SSL-escalation reasoning is sound. Two blocking issues though (one re-introduces the opaque Access denied this PR set out to fix, on a path the guards don't cover), plus a security gap and an unreachable UI. Details inline; ranked by severity.
| } | ||
| // 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() { |
There was a problem hiding this comment.
🔴 Blocking — this re-introduces the exact Access denied error the PR set out to fix, on a saved-connection path the new guards don't cover.
The empty-token guard here only fires when connection_id.is_none(), and the comment just above justifies it with "Saved connections get the token injected from the keychain after this builder returns." That justification is no longer true: find_connection_by_id (commands.rs:418) and duplicate_connection (936) now deliberately skip the keychain for IAM connections — that's the whole point of the 15-minute-token design.
So the token is never injected for a saved IAM connection, and the guard is bypassed precisely for those. Concrete repro:
- Save a MySQL connection with
use_iam_auth = true,save_in_keychain = true→ on-disk password is stripped toNone. - Restart the app (or let the token expire so the pool is evicted), then browse tables / run a query.
get_tables/execute_query→find_connection_by_id(skips keychain,passwordstaysNone) →resolve_connection_params_with_id(setsconnection_id = Some) →get_mysql_pool_for_database→ here:use_iam_auth = true,passwordempty,connection_id.is_some()→ this guard is skipped,.password()is skipped,enable_cleartext_plugin(true)is forced → connects with no password → server returnsAccess denied (using password: NO).
test_connection and list_databases have their own empty-token guard, but the query/connect commands do not — so the bug only survives on exactly the path users hit most.
Fix: enforce the empty-token check in build_mysql_options regardless of connection_id — it's the single choke point every connect routes through — and delete the now-false comment.
if params.use_iam_auth.unwrap_or(false) && password.is_empty() {
return Err("AWS IAM authentication is enabled but no RDS auth token was provided. \
Paste the output of `aws rds generate-db-auth-token` into the password field. \
Tokens expire every 15 minutes.".to_string());
}There was a problem hiding this comment.
empty-token guard now fires unconditionally in build_mysql_options,
and the permissive test is inverted to pin the strict behaviour.
The "Saved connections get the token injected from the keychain" comment is gone;
that contract was stale because IAM connections skip the keychain upstream.
| // (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) { |
There was a problem hiding this comment.
🔴 Security — the RDS auth token can travel in cleartext.
This guard only rejects MySqlSslMode::Disabled, and its error message even advertises Preferred as an acceptable mode. But Preferred is opportunistic TLS: sqlx tries TLS and silently falls back to an unencrypted connection if the handshake doesn't complete. Meanwhile enable_cleartext_plugin(true) is set unconditionally for IAM (line 295), so under mysql_clear_password the token is sent to the server in cleartext.
Result: with ssl_mode = preferred and no CA (so the has_user_ca → VerifyCa escalation at line 191 doesn't fire, and Preferred stays Preferred), a network attacker who blocks/strips the TLS upgrade gets the pre-signed RDS token on the wire in plaintext. The line-295 comment "Safe because the token only travels over the TLS link above" is false in this mode, and it defeats the stated invariant ("Refusing to send the RDS auth token over an unencrypted connection").
Fix: require an actively-enforced TLS mode for IAM — reject Preferred alongside Disabled (accept only Required/VerifyCa/VerifyIdentity), or force-upgrade Preferred → Required whenever use_iam_auth is on. Update the error string to match.
There was a problem hiding this comment.
Preferred is force-upgraded to Required under IAM
(was opportunistic TLS + forced cleartext plugin = token over the
wire in the clear under a stripped STARTTLS). Disabled still
rejected; the error string no longer lists Preferred as acceptable.
| <label className="flex items-start gap-2 cursor-pointer"> | ||
| <input | ||
| type="checkbox" | ||
| checked={!!formData.use_iam_auth} |
There was a problem hiding this comment.
🟠 The IAM checkbox is effectively unreachable when creating a fresh MySQL connection.
This block lives inside the SSL Certificate Files section, which only renders when formData.ssl_mode is truthy and not disable/disabled. A brand-new connection has formData.ssl_mode === "" — the SSL dropdown displays "Required" through a formData.ssl_mode || "required" fallback, but the underlying state is still empty. So the cert block, and this checkbox with it, stays hidden until the user happens to re-select an SSL mode from the dropdown. For the feature's primary audience (RDS users setting up IAM auth) it looks like the option simply isn't there.
Second failure mode: enable IAM under Required, then switch SSL to Disabled → the block unmounts while formData.use_iam_auth stays true. The user can no longer untick it, and the connection saves with use_iam_auth = true + SSL off — a combination build_mysql_options then rejects at connect time.
Fix: gate the IAM checkbox on driver === "mysql" (and the effective ssl mode, i.e. the same || "required" fallback used for display), independent of whether an explicit ssl_mode string has been chosen; and clear use_iam_auth when SSL is set to Disabled.
There was a problem hiding this comment.
IAM checkbox moved out of the SSL Certificate Files
block, gated on the effective SSL mode. Switching SSL to Disabled
now clears use_iam_auth, so the "block unmounts while flag is true"
failure mode is also closed.
| "The password field is treated as an RDS auth token (from `aws rds generate-db-auth-token`). Requires TLS. Tokens expire every 15 minutes.", | ||
| })} | ||
| </div> | ||
| {formData.use_iam_auth && |
There was a problem hiding this comment.
🟠 Dead code — this warning can never render.
The warning is meant to alert IAM users when SSL is off (mirroring the backend's rejection of IAM + Disabled). But its condition (ssl_mode is disabled/disable/empty) is fully mutually exclusive with the enclosing block's guard (ssl_mode && ssl_mode !== "disable" && ssl_mode !== "disabled"). In every state where this condition is true, the parent block is unmounted — so the one situation the warning exists to catch (IAM enabled, SSL disabled) is exactly the situation where it's invisible.
This is the flip side of the previous comment: once the checkbox is moved out from under the SSL-cert block, this warning will start being reachable and should be re-tested against the actual off/downgraded SSL modes.
There was a problem hiding this comment.
The TLS-required warning is now reachable
because the new gate uses the effective SSL mode.
| } | ||
|
|
||
| #[test] | ||
| fn mysql_options_iam_auth_combined_with_escalation_keeps_cleartext_plugin() { |
There was a problem hiding this comment.
🟡 This regression test doesn't test the regression it's named for.
mysql_options_iam_auth_combined_with_escalation_keeps_cleartext_plugin asserts only ssl_mode == VerifyCa — it never checks that the cleartext plugin is still enabled after escalation. That's the exact behavior the guard was added for: if a refactor let SSL escalation drop the enable_cleartext_plugin opt-in, this test would still pass green.
The sibling mysql_options_iam_auth_passes_password_through_under_tls already shows the assertion is easy:
assert!(format!("{options:?}").contains("enable_cleartext_plugin: true"));Add that here so the test actually covers escalation-keeps-plugin.
There was a problem hiding this comment.
the test now actually asserts enable_cleartext_plugin: true after escalation.
| // 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); |
There was a problem hiding this comment.
🟡 The commands-layer IAM behavior has zero test coverage — and it contradicts the one branch that is tested.
Everything the product actually depends on for IAM correctness is untested: the empty-token rejection here and in list_databases, the keychain-skip in find_connection_by_id/duplicate_connection, and the MCP password = None override.
Worse, there's a direct contradiction. build_mysql_options allows an empty password for a saved IAM connection, and that permissive branch is locked in by a test (mysql_options_iam_auth_allows_empty_password_when_connection_id_set). This command layer rejects the same empty-password saved-IAM case. Only the permissive side has coverage, so a regression that re-enabled the keychain fallback for IAM (removing the reason the strict guard exists) would sail through CI.
Add command-level tests for the empty-token rejection and the keychain-skip; and reconcile the two layers so they agree on the saved-connection empty-token case (see the blocking comment on build_mysql_options).
There was a problem hiding this comment.
require_iam_token helper extracted,
called from both sites, with 5 unit tests covering the empty-token
guard at the command layer. The command-layer / builder
contradiction is resolved by (first commit). Full keychain-skip coverage for
find_connection_by_id / duplicate_connection needs a Tauri
MockRuntime harness and is best landed as a follow-up rather than
via ad-hoc mocks.
| // 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 |
There was a problem hiding this comment.
🟡 Cleanup — duplicated guard.
This iam_auth compute + the double password.as_deref().unwrap_or("").is_empty() check + the full multi-line user-facing error string are copy-pasted verbatim into list_databases (line 2714). A wording tweak or logic change (e.g. later honoring a saved token) has to be made in both places and will drift.
Extract a single helper and call it from both:
fn require_iam_token(iam_auth: bool, req_pw: Option<&str>, expanded_pw: Option<&str>) -> Result<(), String> {
if iam_auth && req_pw.unwrap_or("").is_empty() && expanded_pw.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());
}
Ok(())
}There was a problem hiding this comment.
same as (last comment).
The previous guard only fired when connection_id was None, on the assumption that the keychain would inject the token after the builder returned. But find_connection_by_id and duplicate_connection deliberately skip the keychain for IAM connections (15-minute tokens must come from the form on every connect), so a saved IAM connection with an empty password was silently building an unauthenticated MySqlConnectOptions and hitting the server with no password — the exact "Access denied" this PR set out to fix, on the path users hit most. Enforce the empty-token check unconditionally in build_mysql_options, which is the single choke point every connect routes through, and update the regression test that was pinning the permissive behaviour.
Preferred is opportunistic TLS: sqlx attempts the upgrade and silently falls back to plaintext if the handshake fails, while IAM forces enable_cleartext_plugin(true) which would then send the pre-signed RDS auth token in the clear. A network attacker that blocks or strips the STARTTLS upgrade would catch the token on the wire. Force-upgrade Preferred to Required whenever use_iam_auth is on, so the TLS link is guaranteed. Reject Disabled with an updated error string that no longer advertises Preferred as an acceptable mode.
…lock The IAM checkbox used to live inside the SSL Certificate Files block, which only renders when formData.ssl_mode is truthy. A brand-new MySQL connection has formData.ssl_mode === "" (the dropdown shows 'Required' through a || 'required' fallback, but the underlying state is still empty), so the cert block — and the IAM checkbox with it — was hidden until the user happened to re-select an SSL mode. For the feature's primary audience (RDS users setting up IAM auth) the option simply wasn't there. Two failure modes flow from the same root cause: 1. The checkbox is unreachable on a fresh connection. 2. Enabling IAM under Required and then switching SSL to Disabled unmounts the block while formData.use_iam_auth stays true, saving a connection the backend then rejects at connect time. Move the checkbox out from under the SSL-cert block. Gate it on driver === 'mysql' and the effective SSL mode (formData.ssl_mode || 'required'), matching the Cleartext block already next to it: disable the checkbox when TLS is off, and surface the TLS-required warning in the only state where the user can actually see it. Clear use_iam_auth when the user picks Disabled from the SSL dropdown.
The test named 'mysql_options_iam_auth_combined_with_escalation_keeps_ cleartext_plugin' only verified the escalated ssl_mode, never that the cleartext opt-in survives the escalation. A refactor that drops the enable_cleartext_plugin toggle during SSL escalation would still pass green, which is the exact regression the test was added to catch. Assert on the debug output (sqlx 0.8.6 has no public getter for enable_cleartext_plugin) the same way the sibling mysql_options_iam_auth_passes_password_through_under_tls test does.
test_connection and list_databases both carry a verbatim copy of the empty-token guard (iam_auth compute + double is_empty check + the multi-line user-facing error string). A wording tweak or logic change had to be made in both places and would drift. Extract a single require_iam_token helper and call it from both sites.
The command layer's IAM behaviour (test_connection + list_databases guarding an empty token, find_connection_by_id + duplicate_connection skipping the keychain for IAM connections) had zero coverage. Worse, the permissive empty-password branch in build_mysql_options (now removed by the previous fix) was locked in by a test, so a regression that re-enabled the keychain fallback for IAM would have sailed through CI. Add a require_iam_token_tests module covering the empty-token guard at the command layer: both passwords None, both empty strings, request password present, expanded password present, and the non-IAM control case. Full keychain-skip coverage for find_connection_by_id / duplicate_connection needs a tauri::test MockRuntime harness and is deferred to a follow-up; the underlying predicate (save_in_keychain && !use_iam_auth) is straightforward enough that the build_mysql_options guard now covers the only path that matters end-to-end (the saved-connection connect path).
NewtTheWolf
left a comment
There was a problem hiding this comment.
Really solid, carefully-reasoned PR — the IAM feature itself is well-built: the TLS invariant is enforced end-to-end (Disabled→reject, Preferred→Required, cleartext plugin only under TLS), the keychain skip is thorough across load/duplicate/MCP, you log password_len rather than the token, and the pool-key isolation is right. 🙏 12 tests to boot.
No blocking bugs from me — but two app-wide changes are riding along with the IAM feature that I'd want verified/documented before merge (inline), plus one small UI-state bug. Marking as comment rather than request-changes.
✅ Nice touches: ensure_rustls_crypto_provider() added ahead of the mysql connect avoids the rustls first-handshake panic; the pool key segmenting iam: keeps IAM and password pools apart.
| 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"] } |
There was a problem hiding this comment.
Blast radius: this swaps the TLS backend for every MySQL connection, not just RDS. rustls differs from native-tls in ways that can silently break existing users:
- No OS trust store — native-tls used the system cert store; sqlx+rustls uses bundled webpki roots. A corporate MySQL server whose CA is trusted only via an OS-installed root (no explicit
ssl_ca) underVerifyIdentity/VerifyCawill now fail to connect. - TLS 1.0/1.1 dropped (only
tls12+ 1.3 enabled) — older servers/proxies break.
The macOS-EKU motivation is real and aligning with the Postgres rustls path is reasonable long-term, but this warrants explicit non-RDS regression testing (VerifyIdentity against an OS-trusted-CA server; an older TLS server) and a release note — it's a big change to be implicit inside an IAM PR. Related: the escalation tests in pool_manager_tests.rs:744 still describe native-tls CA-forwarding behavior, so that rationale is now stale.
| if has_user_ca | ||
| && matches!(ssl_mode, MySqlSslMode::Required | MySqlSslMode::Preferred) | ||
| { | ||
| ssl_mode = MySqlSslMode::VerifyCa; |
There was a problem hiding this comment.
The Required|Preferred + ssl_ca → VerifyCa escalation isn't gated on IAM (the tests confirm it's intended for all connections). So existing non-IAM MySQL connections with a CA set + Required silently gain chain validation; a user whose bundle is incomplete/mismatched loses a previously-working connection.
For IAM the only hard requirement is an encrypted link, which Required already provides — so gating this on use_iam_auth would keep the RDS fix while removing the regression surface for everyone else. If it's meant to be global, worth a release note.
| // Cleartext auth and RDS IAM must never go over an unencrypted | ||
| // link: both rely on a TLS-guaranteed channel to protect the | ||
| // password / pre-signed token. | ||
| if (driver === "mysql" && (v === "disabled" || v === "disable")) { |
There was a problem hiding this comment.
UI-state divergence: this clears use_iam_auth only for disabled, but the checkbox below is checked={!tlsOff && !!formData.use_iam_auth} + disabled, and tlsOff also covers preferred. So: enable IAM under Required, then switch SSL to Preferred → the checkbox renders unchecked while formData.use_iam_auth stays true, and the connection saves with IAM on despite the UI showing it off.
Secure (the backend force-upgrades Preferred→Required), but the UI misrepresents the persisted value. Clear use_iam_auth whenever the effective mode is TLS-off, not just on disabled — e.g. reuse the same tlsOff predicate here.
Summary
Adds AWS RDS IAM database authentication for MySQL connections. The password
field is treated as a short-lived RDS auth token (e.g. from
aws rds generate-db-auth-token); TLS is mandatory and the connection opts into MySQL's
mysql_native_passwordcleartext plugin so the token can beexchanged under TLS.
Fixes the
1045 Access deniedusers currently get when wiring a generated RDSauth token into a normal Tabularis connection.
What changed
use_iam_authflag on the MySQL connection modal. The password field isfed straight into
MySqlConnectOptionson every connect; keychain lookupsare skipped because the token must come from the form (15-minute expiry).
enable_cleartext_plugin(true)is set on the pool builder when IAM auth isactive. RDS requires it for token exchange under TLS.
VerifyCawhen a CA bundle is configured andthe user picked
RequiredorPreferred, so the IAM/TLS invariant holdswithout manual setup.
iam:{}so an IAM-auth connection never shares a poolwith a regular one.
test_connectionandlist_databasesfail fast with a clear error when IAMis enabled but the password is empty, and log a warning on failure so the
logs distinguish "user typo" from "broken connection".
mcp/mod.rsforwards the IAM flag into the connection-options builder.UI
New "Use AWS IAM Authentication (RDS)" checkbox in the MySQL connection
modal. When enabled, the password placeholder switches to the RDS-token hint
and an inline message surfaces if SSL is off.
Tests
Twelve new unit tests in
pool_manager_tests.rscovering:cargo build --release,cargo test --lib(761 passing; the four askpassfailures are pre-existing on
main), andpnpm tsc --noEmitall pass.i18n
Added
useIamAuth,useIamAuthHint, anduseIamAuthTlsRequiredacross alleight locales (en, es, de, fr, it, ja, ru, zh).