From d7cb7288a6c7fe6656491ca46ae70fa42924de6e Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Thu, 13 Aug 2026 14:26:47 +0200 Subject: [PATCH] feat: support plugin-owned connection fields --- packages/create-plugin/src/cli.ts | 2 +- packages/plugin-api/README.md | 1 + packages/plugin-api/package.json | 2 +- packages/plugin-api/src/slots.ts | 7 + packages/plugin-api/src/version.ts | 4 +- plugins/PLUGIN_GUIDE.md | 14 +- plugins/manifest.schema.json | 28 ++ src-tauri/src/commands.rs | 232 ++++++++++++- src-tauri/src/connection_cache_tests.rs | 1 + src-tauri/src/connection_import/analyzer.rs | 1 + src-tauri/src/connection_import/convert.rs | 4 + src-tauri/src/connection_import/tabularis.rs | 8 + src-tauri/src/connection_tags.rs | 1 + src-tauri/src/credential_cache.rs | 65 ++++ src-tauri/src/drivers/driver_trait.rs | 32 ++ src-tauri/src/drivers/mysql/mod.rs | 1 + src-tauri/src/drivers/postgres/mod.rs | 1 + src-tauri/src/drivers/sqlite/mod.rs | 1 + src-tauri/src/export_import_tests.rs | 3 + src-tauri/src/keychain_utils.rs | 47 +++ src-tauri/src/lib.rs | 1 + src-tauri/src/mcp/mod.rs | 11 + src-tauri/src/models.rs | 14 + src-tauri/src/models_tests.rs | 15 + src-tauri/src/persistence.rs | 48 +++ src-tauri/src/plugin_secrets.rs | 304 ++++++++++++++++++ src-tauri/src/plugins/commands.rs | 1 + src-tauri/src/plugins/driver.rs | 1 + src-tauri/src/plugins/manager.rs | 7 +- src-tauri/src/plugins/tests.rs | 22 ++ src-tauri/src/sqlite_database.rs | 2 +- src/components/modals/NewConnectionModal.tsx | 211 ++++++++---- src/contexts/DatabaseContext.ts | 1 + src/contexts/PluginSlotProvider.tsx | 2 +- src/types/pluginSlots.ts | 9 + src/types/plugins.ts | 16 + src/utils/credentials.ts | 1 + .../modals/NewConnectionModal.test.tsx | 85 +++++ 38 files changed, 1116 insertions(+), 90 deletions(-) create mode 100644 src-tauri/src/plugin_secrets.rs diff --git a/packages/create-plugin/src/cli.ts b/packages/create-plugin/src/cli.ts index 295558ac9..ed6f4c903 100644 --- a/packages/create-plugin/src/cli.ts +++ b/packages/create-plugin/src/cli.ts @@ -7,7 +7,7 @@ import { scaffold } from "./scaffold"; import { titleCase, validateDbType, validateName, validateQuote } from "./validate"; const PACKAGE_VERSION = "0.1.0"; -const PLUGIN_API_VERSION = "0.1.0"; +const PLUGIN_API_VERSION = "0.2.0"; const MIN_TABULARIS_VERSION = "0.9.20"; function main(argv: string[]): number { diff --git a/packages/plugin-api/README.md b/packages/plugin-api/README.md index d1fdcea3b..4cc14db40 100644 --- a/packages/plugin-api/README.md +++ b/packages/plugin-api/README.md @@ -58,6 +58,7 @@ assertHostCompat(); // throws if the running Tabularis is older than MIN_HOST_VE | Package version | Minimum Tabularis | Notes | |-----------------|-------------------|-------| | `0.1.0` | `0.1.0` (see host `HOST_API_VERSION`) | Initial release | +| `0.2.0` | `0.2.0` | Secure per-connection plugin fields in the connection modal | ## Slot reference diff --git a/packages/plugin-api/package.json b/packages/plugin-api/package.json index 128ecffa1..a37fedc25 100644 --- a/packages/plugin-api/package.json +++ b/packages/plugin-api/package.json @@ -1,6 +1,6 @@ { "name": "@tabularis/plugin-api", - "version": "0.1.1", + "version": "0.2.0", "description": "Public API surface for Tabularis plugin UI extensions.", "license": "Apache-2.0", "homepage": "https://github.com/TabularisDB/tabularis/tree/main/packages/plugin-api", diff --git a/packages/plugin-api/src/slots.ts b/packages/plugin-api/src/slots.ts index 6c0d7a055..b3b2ddec0 100644 --- a/packages/plugin-api/src/slots.ts +++ b/packages/plugin-api/src/slots.ts @@ -80,6 +80,13 @@ export type SlotContextMap = { extra: Record; /** Update one extra field. Pass an empty string to clear it. */ setExtraField: (key: string, value: string) => void; + /** Plugin-owned secrets. Existing values are represented by metadata only. */ + secretFields?: Record< + string, + { value: string; hasStoredValue: boolean; dirty: boolean } + >; + /** Set a secret for Test/Save. An empty value explicitly clears it. */ + setSecretField?: (key: string, value: string) => void; }; }; diff --git a/packages/plugin-api/src/version.ts b/packages/plugin-api/src/version.ts index e79983b3b..c6017e151 100644 --- a/packages/plugin-api/src/version.ts +++ b/packages/plugin-api/src/version.ts @@ -2,11 +2,11 @@ * API version of this package. Must match the version field of package.json. * Bump when the host API shape changes in a way that plugin bundles can observe. */ -export const API_VERSION = "0.1.1"; +export const API_VERSION = "0.2.0"; /** * Minimum Tabularis host version that exposes an API compatible with this package. * The host sets `window.__TABULARIS_API_VERSION__`; `assertHostCompat()` uses this * constant to decide whether the active host is new enough. */ -export const MIN_HOST_VERSION = "0.1.0"; +export const MIN_HOST_VERSION = "0.2.0"; diff --git a/plugins/PLUGIN_GUIDE.md b/plugins/PLUGIN_GUIDE.md index b640a9f34..1777956a8 100644 --- a/plugins/PLUGIN_GUIDE.md +++ b/plugins/PLUGIN_GUIDE.md @@ -95,6 +95,7 @@ One manifest tells Tabularis everything about your plugin — and, when you publ | `capabilities` | object | Feature flags (see below). | | `data_types` | array | List of supported data types (see below). | | `type_mappings` | object \| null | Optional map of generic inferred type names to driver-specific types. Used during paste/import to map generic types (e.g. `DATETIME`) to driver-native equivalents (e.g. `TIMESTAMP`). See [Type Mappings](#type-mappings) below. | +| `connection_fields` | object | Optional hide, label, and placeholder overrides for the common `host`, `port`, `username`, `password`, and `database` fields. Omitted fields preserve the standard UI. | ### Capabilities @@ -338,7 +339,7 @@ Add an optional `ui_extensions` array to your manifest: | `settings.plugin.actions` | Per-plugin actions in Settings modal | `targetPluginId` | Diagnostics, re-auth buttons | | `settings.plugin.before_settings` | Content above plugin settings form | `targetPluginId` | OAuth panels, status banners | | `connection-modal.connection_content` | Inside the connection form | `driver` | Custom connection fields | -| `connection-modal.extra_fields` | Below host/port in the connection form | `driver`, `extra`, `setExtraField` | Plugin-specific connection fields (e.g. AWS region) | +| `connection-modal.extra_fields` | Below host/port in the connection form | `driver`, `extra`, `setExtraField`, `secretFields`, `setSecretField` | Plugin-specific connection fields, including values stored per connection in the OS keychain | ### SlotContext @@ -358,6 +359,17 @@ interface SlotContext { } ``` +For `connection-modal.extra_fields`, `setSecretField(key, value)` keeps the +value out of `connections.json` and stores it under the saved connection in the +OS keychain. Passing an empty value explicitly clears the entry. On edit, +`secretFields[key]` exposes only `{ value, hasStoredValue, dirty }`: an existing +secret is represented by `hasStoredValue` and is never returned to plugin UI. +At runtime the host resolves stored values into `ConnectionParams.extra` before +calling the driver, so the JSON-RPC method shapes remain backward compatible. +Both properties are optional in the public TypeScript contract. A plugin that +also supports older hosts must guard their presence; a plugin that requires +secure fields can call `assertHostCompat()` before rendering. + ### Building UI Extension Bundles Plugin UI components must be pre-built as **IIFE bundles** (Immediately Invoked Function Expression). The host provides `React`, `ReactJSXRuntime`, and the plugin API as globals — your bundle must **not** bundle its own copies of these. diff --git a/plugins/manifest.schema.json b/plugins/manifest.schema.json index 9a6fb4879..26ef1681f 100644 --- a/plugins/manifest.schema.json +++ b/plugins/manifest.schema.json @@ -180,6 +180,34 @@ } } }, + "connection_fields": { + "type": "object", + "description": "Optional presentation overrides for host-owned connection fields. Omitted fields keep the standard Tabularis behavior.", + "additionalProperties": false, + "patternProperties": { + "^(host|port|username|password|database)$": { + "type": "object", + "additionalProperties": false, + "properties": { + "hidden": { + "type": "boolean", + "default": false, + "description": "Hide this field for the driver." + }, + "label": { + "type": "string", + "maxLength": 80, + "description": "Driver-specific label." + }, + "placeholder": { + "type": "string", + "maxLength": 200, + "description": "Driver-specific input placeholder." + } + } + } + } + }, "interpreter": { "type": "string", "description": "Optional interpreter for script-based plugins (e.g. python3)." diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 45cb1eb52..45d267817 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -595,7 +595,7 @@ fn restore_runtime_connection_uri( } } -pub fn find_connection_by_id( +pub fn find_connection_metadata_by_id( app: &AppHandle, id: &str, ) -> Result { @@ -655,6 +655,18 @@ pub fn find_connection_by_id( Ok(conn) } +/// Load a connection for driver execution. Unlike the metadata/edit path, +/// this resolves plugin-owned secrets into `params.extra` only in memory. +pub fn find_connection_by_id( + app: &AppHandle, + id: &str, +) -> Result { + let mut conn = find_connection_metadata_by_id(app, id)?; + let cache = app.state::>(); + crate::plugin_secrets::hydrate_connection(&cache, &mut conn)?; + Ok(conn) +} + /// Merge a list of incoming groups into an existing list, preserving hierarchy /// and repairing any `parent_id` that points to a group id not present in the /// union (i.e. neither in the existing list nor in the incoming batch). @@ -719,7 +731,7 @@ pub async fn get_connection_by_id( app: AppHandle, id: String, ) -> Result { - find_connection_by_id(&app, &id) + find_connection_metadata_by_id(&app, &id) } #[tauri::command] @@ -985,6 +997,7 @@ pub async fn save_connection( app: AppHandle, name: String, params: ConnectionParams, + plugin_secret_changes: Option, detect_json_in_text_columns: Option, environment: Option, ) -> Result { @@ -996,8 +1009,14 @@ pub async fn save_connection( let id = Uuid::new_v4().to_string(); let cache = app.state::>(); + let plugin_secret_changes = plugin_secret_changes.unwrap_or_default(); + let plugin_secret_keys = + crate::plugin_secrets::next_secret_keys(&[], true, &plugin_secret_changes)?; let connection_uri = runtime_connection_uri(¶ms).map(str::to_owned); let mut params_to_save = params_for_persistence(¶ms, connection_uri.is_some()); + for key in plugin_secret_changes.keys() { + params_to_save.extra.remove(key); + } if params.save_in_keychain.unwrap_or(false) { log::debug!("Storing passwords in keychain for connection: {}", name); @@ -1026,6 +1045,7 @@ pub async fn save_connection( id: id.clone(), name: name.clone(), params: params_to_save, + plugin_secret_keys, group_id: None, sort_order: None, detect_json_in_text_columns, @@ -1034,12 +1054,22 @@ pub async fn save_connection( environment: validate_environment(environment)?, }; conn_file.connections.push(new_conn.clone()); - persist_connection_uri_change( + crate::plugin_secrets::persist_secret_changes( &cache, &id, - false, - connection_uri.as_deref().map(Some), - || save_connections_and_invalidate(&app, &path, &conn_file), + None, + &[], + ¶ms.driver, + &plugin_secret_changes, + || { + persist_connection_uri_change( + &cache, + &id, + false, + connection_uri.as_deref().map(Some), + || save_connections_and_invalidate(&app, &path, &conn_file), + ) + }, )?; log::info!("Connection saved successfully: {} (ID: {})", name, id); @@ -1067,6 +1097,11 @@ pub async fn delete_connection(app: AppHandle, id: String) -> Res .find(|c| c.id == id) .and_then(|c| c.params.connection_uri_in_keychain) .unwrap_or(false); + let plugin_secret_metadata = conn_file + .connections + .iter() + .find(|c| c.id == id) + .map(|c| (c.params.driver.clone(), c.plugin_secret_keys.clone())); // Capture the appearance before retain so we can cascade-delete the icon file. let appearance_to_delete = conn_file @@ -1086,6 +1121,9 @@ pub async fn delete_connection(app: AppHandle, id: String) -> Res persist_connection_uri_change(&cache, &id, uri_stored_in_keychain, Some(None), || { save_connections_and_invalidate(&app, &path, &conn_file) })?; + if let Some((driver, keys)) = plugin_secret_metadata { + crate::plugin_secrets::delete_connection_secrets(&cache, &id, &driver, &keys); + } // Invalidate the in-memory cache for this connection credential_cache::invalidate_all_for_connection(&cache, &id); @@ -1117,6 +1155,7 @@ pub async fn update_connection( id: String, name: String, params: ConnectionParams, + plugin_secret_changes: Option, detect_json_in_text_columns: Option, environment: Option, ) -> Result { @@ -1137,6 +1176,14 @@ pub async fn update_connection( // A stored URI belongs to the driver that produced it. Switching drivers // must drop it rather than hand one driver's credentials to another. let same_driver = conn_file.connections[conn_idx].params.driver == params.driver; + let original_driver = conn_file.connections[conn_idx].params.driver.clone(); + let original_plugin_secret_keys = conn_file.connections[conn_idx].plugin_secret_keys.clone(); + let plugin_secret_changes = plugin_secret_changes.unwrap_or_default(); + let plugin_secret_keys = crate::plugin_secrets::next_secret_keys( + &original_plugin_secret_keys, + same_driver, + &plugin_secret_changes, + )?; let connection_uri = runtime_connection_uri(¶ms).map(str::to_owned); // The frontend sends the URI back only when the user retyped it. An edit // that leaves the field untouched must keep the stored secret; an edit that @@ -1153,6 +1200,9 @@ pub async fn update_connection( }; let mut params_to_save = params_for_persistence(¶ms, connection_uri.is_some() || preserve_stored_uri); + for key in plugin_secret_changes.keys() { + params_to_save.extra.remove(key); + } let cache = app.state::>(); if params.save_in_keychain.unwrap_or(false) { @@ -1204,6 +1254,7 @@ pub async fn update_connection( id: id.clone(), name, params: params_to_save, + plugin_secret_keys, group_id: original_group_id, sort_order: original_sort_order, detect_json_in_text_columns, @@ -1214,9 +1265,19 @@ pub async fn update_connection( conn_file.connections[conn_idx] = updated.clone(); - persist_connection_uri_change(&cache, &id, existing_uri_in_keychain, uri_change, || { - save_connections_and_invalidate(&app, &path, &conn_file) - })?; + crate::plugin_secrets::persist_secret_changes( + &cache, + &id, + Some(&original_driver), + &original_plugin_secret_keys, + ¶ms.driver, + &plugin_secret_changes, + || { + persist_connection_uri_change(&cache, &id, existing_uri_in_keychain, uri_change, || { + save_connections_and_invalidate(&app, &path, &conn_file) + }) + }, + )?; // On single→multi transition, associate existing favorites/history (with no // database set) to the original single database name. @@ -1300,6 +1361,16 @@ pub async fn duplicate_connection( let mut original = conn_file.connections[original_idx].clone(); let cache = app.state::>(); + let plugin_secret_values = crate::plugin_secrets::load_secret_values( + &cache, + &original.id, + &original.params.driver, + &original.plugin_secret_keys, + )?; + let plugin_secret_changes: crate::plugin_secrets::PluginSecretChanges = plugin_secret_values + .into_iter() + .map(|(key, value)| (key, Some(value))) + .collect(); // Same IAM-auth guard as `find_connection_by_id`: never copy a stale RDS // auth token into a duplicated connection. @@ -1390,9 +1461,10 @@ pub async fn duplicate_connection( }; let new_conn = SavedConnection { - id: new_id, + id: new_id.clone(), name: format!("{} (Copy)", original.name), params: new_params, + plugin_secret_keys: original.plugin_secret_keys.clone(), group_id: original.group_id.clone(), // Copy to same group as original sort_order: None, // Will be placed at end of group detect_json_in_text_columns: original.detect_json_in_text_columns, @@ -1405,7 +1477,15 @@ pub async fn duplicate_connection( conn_file.connections.push(new_conn.clone()); - save_connections_and_invalidate(&app, &path, &conn_file)?; + crate::plugin_secrets::persist_secret_changes( + &cache, + &new_id, + None, + &[], + &original.params.driver, + &plugin_secret_changes, + || save_connections_and_invalidate(&app, &path, &conn_file), + )?; let mut returned_conn = new_conn; // Return with passwords for frontend consistency @@ -2269,6 +2349,13 @@ pub async fn test_connection( request.params.database ); let progress_id = request.progress_id.as_deref(); + let saved_conn = match &request.connection_id { + Some(id) => Some( + find_connection_metadata_by_id(&app, id) + .map_err(|e| emit_test_failure(&app, progress_id, "resolve", e))?, + ), + None => None, + }; let mut expanded_params = expand_ssh_connection_params(&app, &request.params) .await @@ -2294,16 +2381,40 @@ pub async fn test_connection( .map_err(|e| emit_test_failure(&app, progress_id, "resolve", e))?; 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, - }; expanded_params.password = resolve_test_connection_password(&request.params, saved_conn.as_ref(), |conn_id| { keychain_utils::get_db_password(conn_id, "") }); } + let stored_plugin_secrets = if let Some(saved) = saved_conn + .as_ref() + .filter(|saved| saved.params.driver == expanded_params.driver) + { + let unresolved_keys: Vec = saved + .plugin_secret_keys + .iter() + .filter(|key| !request.plugin_secret_changes.contains_key(*key)) + .cloned() + .collect(); + let cache = app.state::>(); + crate::plugin_secrets::load_secret_values( + &cache, + &saved.id, + &saved.params.driver, + &unresolved_keys, + ) + .map_err(|e| emit_test_failure(&app, progress_id, "resolve", e))? + } else { + HashMap::new() + }; + crate::plugin_secrets::apply_runtime_changes( + &mut expanded_params, + stored_plugin_secrets, + &request.plugin_secret_changes, + ) + .map_err(|e| emit_test_failure(&app, progress_id, "resolve", e))?; + // Reconnecting to a saved connection sends the on-disk params, which never // carry the URI — restore it the same way the password is restored above. // An inline URI (the ephemeral Test Connection flow) always wins. @@ -2570,6 +2681,7 @@ mod tests { save_in_keychain: Some(save_in_keychain), ..base_params() }, + plugin_secret_keys: Vec::new(), group_id: None, sort_order: None, detect_json_in_text_columns: None, @@ -2598,6 +2710,7 @@ mod tests { id: "conn-1".to_string(), name: "Old Name".to_string(), params: base_params(), + plugin_secret_keys: Vec::new(), group_id: Some("group-a".to_string()), sort_order: Some(3), detect_json_in_text_columns: None, @@ -2616,6 +2729,7 @@ mod tests { id: existing.id.clone(), name: "New Name".to_string(), params: base_params(), + plugin_secret_keys: Vec::new(), group_id: existing.group_id.clone(), sort_order: existing.sort_order, detect_json_in_text_columns: None, @@ -2635,6 +2749,7 @@ mod tests { id: id.to_string(), name: "Test".to_string(), params: base_params(), + plugin_secret_keys: Vec::new(), group_id: None, sort_order: None, detect_json_in_text_columns: None, @@ -5722,12 +5837,33 @@ pub async fn delete_connection_group( // deleted along with it. Connections belonging to any group in the // subtree are removed as well. let to_delete = crate::models::collect_group_subtree(&file.groups, &id); + let deleted_plugin_secrets: Vec<(String, String, Vec)> = file + .connections + .iter() + .filter(|connection| { + connection + .group_id + .as_ref() + .is_some_and(|group_id| to_delete.contains(group_id)) + }) + .map(|connection| { + ( + connection.id.clone(), + connection.params.driver.clone(), + connection.plugin_secret_keys.clone(), + ) + }) + .collect(); file.groups.retain(|g| !to_delete.contains(&g.id)); file.connections .retain(|c| !c.group_id.as_ref().is_some_and(|gid| to_delete.contains(gid))); save_connections_and_invalidate(&app, &path, &file)?; + let cache = app.state::>(); + for (connection_id, driver, keys) in deleted_plugin_secrets { + crate::plugin_secrets::delete_connection_secrets(&cache, &connection_id, &driver, &keys); + } Ok(()) } @@ -5877,6 +6013,7 @@ pub async fn export_connections_payload( .state::>() .inner() .clone(); + let mut plugin_secrets = HashMap::new(); // Resolve passwords for database connections for conn in &mut conn_file.connections { @@ -5886,8 +6023,18 @@ pub async fn export_connections_payload( conn.params.ssh_password = None; conn.params.ssh_key_passphrase = None; conn.params.connection_uri = None; + conn.plugin_secret_keys.clear(); continue; } + let resolved_plugin_secrets = crate::plugin_secrets::load_secret_values( + &cache, + &conn.id, + &conn.params.driver, + &conn.plugin_secret_keys, + )?; + if !resolved_plugin_secrets.is_empty() { + plugin_secrets.insert(conn.id.clone(), resolved_plugin_secrets); + } if conn.params.save_in_keychain.unwrap_or(false) { // Without this the export carries the marker but not the URI, and // restoring elsewhere yields a connection that cannot resolve it. @@ -5937,6 +6084,7 @@ pub async fn export_connections_payload( ssh_connections, k8s_connections, tags: conn_file.tags, + plugin_secrets, }) } @@ -5971,7 +6119,7 @@ pub async fn import_connections_payload( /// import command above and the foreign-app import flow. pub async fn apply_export_payload( app: AppHandle, - payload: ExportPayload, + mut payload: ExportPayload, ) -> Result<(), String> { let conn_path = get_config_path(&app)?; let ssh_path = get_ssh_config_path(&app)?; @@ -5988,6 +6136,7 @@ pub async fn apply_export_payload( .state::>() .inner() .clone(); + let mut imported_plugin_secrets = std::mem::take(&mut payload.plugin_secrets); // Merge groups (preserves hierarchy; demotes orphaned parent_ids to root) merge_groups(&mut current_file.groups, payload.groups); @@ -6027,6 +6176,57 @@ pub async fn apply_export_payload( // An imported payload is untrusted input and may carry an inline URI. // Hold it to the same rule as a save: keychain or nothing. validate_connection_uri_persistence(&new_conn.params)?; + let declared_plugin_secret_keys = std::mem::take(&mut new_conn.plugin_secret_keys); + for key in &declared_plugin_secret_keys { + crate::plugin_secrets::validate_secret_key(key)?; + new_conn.params.extra.remove(key); + } + let secrets_for_connection = imported_plugin_secrets + .remove(&new_conn.id) + .unwrap_or_default(); + crate::plugin_secrets::validate_secret_changes( + &secrets_for_connection + .iter() + .map(|(key, value)| (key.clone(), Some(value.clone()))) + .collect(), + )?; + let previous_plugin_metadata = current_file + .connections + .iter() + .find(|connection| connection.id == new_conn.id) + .map(|connection| { + ( + connection.params.driver.clone(), + connection.plugin_secret_keys.clone(), + ) + }); + let mut imported_keys: Vec = secrets_for_connection.keys().cloned().collect(); + imported_keys.sort(); + for (key, value) in &secrets_for_connection { + new_conn.params.extra.remove(key); + keychain_utils::set_plugin_secret(&new_conn.id, &new_conn.params.driver, key, value)?; + credential_cache::set_plugin_secret_cached( + &cache, + &new_conn.id, + &new_conn.params.driver, + key, + value, + ); + } + if let Some((old_driver, old_keys)) = previous_plugin_metadata { + for key in old_keys { + if old_driver != new_conn.params.driver || !imported_keys.contains(&key) { + let _ = keychain_utils::delete_plugin_secret(&new_conn.id, &old_driver, &key); + credential_cache::invalidate_plugin_secret( + &cache, + &new_conn.id, + &old_driver, + &key, + ); + } + } + } + new_conn.plugin_secret_keys = imported_keys; // Handle passwords in keychain if new_conn.params.save_in_keychain.unwrap_or(false) { diff --git a/src-tauri/src/connection_cache_tests.rs b/src-tauri/src/connection_cache_tests.rs index 77bf18a8e..198f577d9 100644 --- a/src-tauri/src/connection_cache_tests.rs +++ b/src-tauri/src/connection_cache_tests.rs @@ -8,6 +8,7 @@ mod tests { id: id.to_string(), name: name.to_string(), params: ConnectionParams::default(), + plugin_secret_keys: Vec::new(), group_id: None, sort_order: None, detect_json_in_text_columns: None, diff --git a/src-tauri/src/connection_import/analyzer.rs b/src-tauri/src/connection_import/analyzer.rs index b3c777940..2899290b6 100644 --- a/src-tauri/src/connection_import/analyzer.rs +++ b/src-tauri/src/connection_import/analyzer.rs @@ -216,6 +216,7 @@ mod tests { database: DatabaseSelection::Single(db.to_string()), ..Default::default() }, + plugin_secret_keys: Vec::new(), group_id: None, sort_order: None, detect_json_in_text_columns: None, diff --git a/src-tauri/src/connection_import/convert.rs b/src-tauri/src/connection_import/convert.rs index ade1e30e1..adad43d28 100644 --- a/src-tauri/src/connection_import/convert.rs +++ b/src-tauri/src/connection_import/convert.rs @@ -4,6 +4,8 @@ //! `SshConnection` records linked by `ssh_connection_id`; groups are matched to //! existing groups by name or created fresh. +use std::collections::HashMap; + use serde::Deserialize; use super::driver_map; @@ -53,6 +55,7 @@ pub fn build_payload( ssh_connections: Vec::new(), k8s_connections: Vec::new(), tags: Vec::new(), + plugin_secrets: HashMap::new(), }; // Resolve group name -> group id, reusing an existing group when the name @@ -219,6 +222,7 @@ fn build_connection( id: conn_id.to_string(), name: conn.name.clone(), params, + plugin_secret_keys: Vec::new(), group_id, sort_order: None, detect_json_in_text_columns: None, diff --git a/src-tauri/src/connection_import/tabularis.rs b/src-tauri/src/connection_import/tabularis.rs index f404f02f0..ae70b70ee 100644 --- a/src-tauri/src/connection_import/tabularis.rs +++ b/src-tauri/src/connection_import/tabularis.rs @@ -105,6 +105,7 @@ pub fn apply( // Carried through wholesale so any tag_ids on imported connections // keep resolving; the merge in apply_export_payload dedups by id. tags: payload.tags.clone(), + plugin_secrets: HashMap::new(), }; let mut group_ids: HashMap = HashMap::new(); // Original payload group ids to preserve verbatim (with their ancestor @@ -161,6 +162,7 @@ pub fn apply( original_group.clone() }; + let source_connection_id = conn.id.clone(); let mut new_conn = conn; new_conn.id = if res.action == "replace" { res.replace_existing_id.clone().unwrap_or_else(new_id) @@ -169,6 +171,10 @@ pub fn apply( }; new_conn.group_id = group_id; new_conn.sort_order = None; + if let Some(secrets) = payload.plugin_secrets.get(&source_connection_id) { + out.plugin_secrets + .insert(new_conn.id.clone(), secrets.clone()); + } // Remap the linked SSH record to a fresh id so the copy doesn't share // (and later clobber) the original's keychain entry. @@ -240,6 +246,7 @@ mod tests { database: DatabaseSelection::Single(db.into()), ..Default::default() }, + plugin_secret_keys: Vec::new(), group_id: group_id.map(str::to_string), sort_order: Some(3), detect_json_in_text_columns: None, @@ -268,6 +275,7 @@ mod tests { ssh_connections: Vec::new(), k8s_connections: Vec::new(), tags: Vec::new(), + plugin_secrets: HashMap::new(), } } diff --git a/src-tauri/src/connection_tags.rs b/src-tauri/src/connection_tags.rs index 5b25c47d8..2c0757e4d 100644 --- a/src-tauri/src/connection_tags.rs +++ b/src-tauri/src/connection_tags.rs @@ -239,6 +239,7 @@ mod tests { id: id.to_string(), name: id.to_string(), params: ConnectionParams::default(), + plugin_secret_keys: Vec::new(), group_id: None, sort_order: None, detect_json_in_text_columns: None, diff --git a/src-tauri/src/credential_cache.rs b/src-tauri/src/credential_cache.rs index ed2c0613e..2a0e4c748 100644 --- a/src-tauri/src/credential_cache.rs +++ b/src-tauri/src/credential_cache.rs @@ -20,6 +20,7 @@ pub enum CacheEntry { pub struct CredentialCache { pub db_passwords: Mutex>, pub connection_uris: Mutex>, + pub plugin_secrets: Mutex>, pub ssh_passwords: Mutex>, pub ssh_passphrases: Mutex>, pub ai_keys: Mutex>, @@ -30,6 +31,7 @@ impl Default for CredentialCache { Self { db_passwords: Mutex::new(HashMap::new()), connection_uris: Mutex::new(HashMap::new()), + plugin_secrets: Mutex::new(HashMap::new()), ssh_passwords: Mutex::new(HashMap::new()), ssh_passphrases: Mutex::new(HashMap::new()), ai_keys: Mutex::new(HashMap::new()), @@ -37,6 +39,10 @@ impl Default for CredentialCache { } } +fn plugin_secret_cache_key(connection_id: &str, driver: &str, key: &str) -> String { + format!("{}\0{}\0{}", connection_id, driver, key) +} + // ─── Read-through helpers ───────────────────────────────────────────────────── // These functions are synchronous and intended to be called from inside // `tokio::task::spawn_blocking` when used in async contexts. @@ -95,6 +101,33 @@ pub fn get_connection_uri_cached( Ok(Some(value)) } +pub fn get_plugin_secret_cached( + cache: &CredentialCache, + connection_id: &str, + driver: &str, + key: &str, +) -> Result, String> { + let cache_key = plugin_secret_cache_key(connection_id, driver, key); + { + let guard = cache.plugin_secrets.lock().unwrap(); + match guard.get(&cache_key) { + Some(CacheEntry::Present(value)) => return Ok(Some(value.clone())), + Some(CacheEntry::Absent) => return Ok(None), + None => {} + } + } + + let value = crate::keychain_utils::get_plugin_secret(connection_id, driver, key)?; + cache.plugin_secrets.lock().unwrap().insert( + cache_key, + match &value { + Some(value) => CacheEntry::Present(value.clone()), + None => CacheEntry::Absent, + }, + ); + Ok(value) +} + /// Get SSH password: check cache first, fall through to keychain on miss. pub fn get_ssh_password_cached( cache: &CredentialCache, @@ -198,6 +231,19 @@ pub fn set_connection_uri_cached( ); } +pub fn set_plugin_secret_cached( + cache: &CredentialCache, + connection_id: &str, + driver: &str, + key: &str, + value: &str, +) { + cache.plugin_secrets.lock().unwrap().insert( + plugin_secret_cache_key(connection_id, driver, key), + CacheEntry::Present(value.to_string()), + ); +} + pub fn set_ssh_password_cached(cache: &CredentialCache, connection_id: &str, password: &str) { cache.ssh_passwords.lock().unwrap().insert( connection_id.to_string(), @@ -237,6 +283,19 @@ pub fn invalidate_connection_uri(cache: &CredentialCache, connection_id: &str) { cache.connection_uris.lock().unwrap().remove(connection_id); } +pub fn invalidate_plugin_secret( + cache: &CredentialCache, + connection_id: &str, + driver: &str, + key: &str, +) { + cache + .plugin_secrets + .lock() + .unwrap() + .remove(&plugin_secret_cache_key(connection_id, driver, key)); +} + pub fn invalidate_ssh_password(cache: &CredentialCache, connection_id: &str) { cache.ssh_passwords.lock().unwrap().remove(connection_id); } @@ -255,4 +314,10 @@ pub fn invalidate_all_for_connection(cache: &CredentialCache, connection_id: &st cache.connection_uris.lock().unwrap().remove(connection_id); cache.ssh_passwords.lock().unwrap().remove(connection_id); cache.ssh_passphrases.lock().unwrap().remove(connection_id); + let prefix = format!("{}\0", connection_id); + cache + .plugin_secrets + .lock() + .unwrap() + .retain(|key, _| !key.starts_with(&prefix)); } diff --git a/src-tauri/src/drivers/driver_trait.rs b/src-tauri/src/drivers/driver_trait.rs index eb84af486..c1e4ea63e 100644 --- a/src-tauri/src/drivers/driver_trait.rs +++ b/src-tauri/src/drivers/driver_trait.rs @@ -203,6 +203,34 @@ pub struct PluginSettingDefinition { pub options: Vec, } +/// Optional presentation overrides for one of the host-owned connection +/// fields. Omitted values preserve the host defaults so manifests written +/// before this contract continue to render exactly as they do today. +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +pub struct ConnectionFieldOverride { + #[serde(default)] + pub hidden: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub placeholder: Option, +} + +/// Driver-specific overrides for the common network connection fields. +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +pub struct ConnectionFieldOverrides { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub host: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub port: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub username: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub password: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub database: Option, +} + /// Metadata describing a registered driver plugin. #[derive(Debug, Serialize, Deserialize, Clone)] pub struct PluginManifest { @@ -248,6 +276,10 @@ pub struct PluginManifest { /// UI extension slot declarations. Absent for built-in drivers. #[serde(default, skip_serializing_if = "Option::is_none")] pub ui_extensions: Option>, + /// Optional hide/relabel/placeholder overrides for the common connection + /// fields. Absent for built-ins and legacy plugins. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub connection_fields: Option, /// Static type mappings applied by `map_inferred_type`. Keys are generic /// inferred types (uppercase, e.g. `"DATETIME"`), values are driver-specific /// types (e.g. `"TIMESTAMP"`). Empty for built-in drivers which override the diff --git a/src-tauri/src/drivers/mysql/mod.rs b/src-tauri/src/drivers/mysql/mod.rs index 86961a2bc..3a577465c 100644 --- a/src-tauri/src/drivers/mysql/mod.rs +++ b/src-tauri/src/drivers/mysql/mod.rs @@ -1849,6 +1849,7 @@ impl MysqlDriver { }, ], ui_extensions: None, + connection_fields: None, type_mappings: std::collections::HashMap::new(), }, } diff --git a/src-tauri/src/drivers/postgres/mod.rs b/src-tauri/src/drivers/postgres/mod.rs index ca5a04bff..c740f39e4 100644 --- a/src-tauri/src/drivers/postgres/mod.rs +++ b/src-tauri/src/drivers/postgres/mod.rs @@ -1793,6 +1793,7 @@ impl PostgresDriver { icon: "postgres".to_string(), settings: vec![], ui_extensions: None, + connection_fields: None, type_mappings: std::collections::HashMap::new(), }, } diff --git a/src-tauri/src/drivers/sqlite/mod.rs b/src-tauri/src/drivers/sqlite/mod.rs index ce30aa355..1bb07b97a 100644 --- a/src-tauri/src/drivers/sqlite/mod.rs +++ b/src-tauri/src/drivers/sqlite/mod.rs @@ -1013,6 +1013,7 @@ impl SqliteDriver { icon: "sqlite".to_string(), settings: vec![], ui_extensions: None, + connection_fields: None, type_mappings: std::collections::HashMap::new(), }, } diff --git a/src-tauri/src/export_import_tests.rs b/src-tauri/src/export_import_tests.rs index 3ed78b0d1..54b7f84dc 100644 --- a/src-tauri/src/export_import_tests.rs +++ b/src-tauri/src/export_import_tests.rs @@ -27,6 +27,7 @@ mod tests { save_in_keychain: Some(true), ..Default::default() }, + plugin_secret_keys: vec![], group_id: Some("group1".to_string()), sort_order: Some(0), detect_json_in_text_columns: None, @@ -49,6 +50,7 @@ mod tests { }], k8s_connections: vec![], tags: vec![], + plugin_secrets: std::collections::HashMap::new(), }; let json = serde_json::to_string(&payload).unwrap(); @@ -113,6 +115,7 @@ mod tests { ssh_connections: vec![], k8s_connections: vec![], tags: vec![], + plugin_secrets: std::collections::HashMap::new(), }; let json = serde_json::to_string(&payload).unwrap(); diff --git a/src-tauri/src/keychain_utils.rs b/src-tauri/src/keychain_utils.rs index b35d491bd..0e9c31b0d 100644 --- a/src-tauri/src/keychain_utils.rs +++ b/src-tauri/src/keychain_utils.rs @@ -69,6 +69,53 @@ pub fn delete_connection_uri(connection_id: &str) -> Result<(), String> { } } +fn plugin_secret_account(connection_id: &str, driver: &str, key: &str) -> String { + format!("{}:plugin:{}:{}", connection_id, driver, key) +} + +pub fn set_plugin_secret( + connection_id: &str, + driver: &str, + key: &str, + value: &str, +) -> Result<(), String> { + let entry = Entry::new( + SERVICE_NAME, + &plugin_secret_account(connection_id, driver, key), + ) + .map_err(|e| e.to_string())?; + entry.set_password(value).map_err(|e| e.to_string()) +} + +pub fn get_plugin_secret( + connection_id: &str, + driver: &str, + key: &str, +) -> Result, String> { + let entry = Entry::new( + SERVICE_NAME, + &plugin_secret_account(connection_id, driver, key), + ) + .map_err(|e| e.to_string())?; + match entry.get_password() { + Ok(value) => Ok(Some(value)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(error) => Err(error.to_string()), + } +} + +pub fn delete_plugin_secret(connection_id: &str, driver: &str, key: &str) -> Result<(), String> { + let entry = Entry::new( + SERVICE_NAME, + &plugin_secret_account(connection_id, driver, key), + ) + .map_err(|e| e.to_string())?; + match entry.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(error) => Err(error.to_string()), + } +} + pub fn set_ssh_password(connection_id: &str, password: &str) -> Result<(), String> { eprintln!("[Keychain] Setting SSH password for {}", connection_id); let entry = diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index be2d7a80d..387358061 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -64,6 +64,7 @@ pub mod paths; // Added #[cfg(test)] pub mod paths_tests; pub mod persistence; +pub mod plugin_secrets; pub mod plugins; pub mod pool_manager; #[cfg(test)] diff --git a/src-tauri/src/mcp/mod.rs b/src-tauri/src/mcp/mod.rs index ac02edb43..d875655e2 100644 --- a/src-tauri/src/mcp/mod.rs +++ b/src-tauri/src/mcp/mod.rs @@ -297,6 +297,17 @@ async fn resolve_db_params( } } + if !conn.plugin_secret_keys.is_empty() { + let cache = credential_cache::CredentialCache::default(); + crate::plugin_secrets::hydrate_connection(&cache, &mut conn).map_err(|message| { + JsonRpcError { + code: -32000, + message, + data: None, + } + })?; + } + let expanded = expand_ssh_params_for_mcp(&conn.params).await?; let expanded = expand_k8s_params_for_mcp(&expanded).await?; let db_params = commands::resolve_connection_params(&expanded).map_err(|e| JsonRpcError { diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 7ce9c6bb0..62e4a6d16 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -297,6 +297,11 @@ pub struct SavedConnection { pub id: String, pub name: String, pub params: ConnectionParams, + /// Names of plugin-owned `extra` values stored in the OS keychain. Only + /// the names are persisted; values are resolved into `params.extra` at + /// runtime and are never written to the connections file. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub plugin_secret_keys: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub group_id: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -401,6 +406,11 @@ pub struct ExportPayload { pub k8s_connections: Vec, #[serde(default)] pub tags: Vec, + /// Export-only secret material keyed by connection id and plugin field. + /// This remains empty for metadata-only exports and never appears in + /// `connections.json`. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub plugin_secrets: HashMap>, } #[derive(Debug, Deserialize, Serialize, Clone)] @@ -408,6 +418,10 @@ pub struct TestConnectionRequest { pub params: ConnectionParams, #[serde(skip_serializing_if = "Option::is_none")] pub connection_id: Option, + /// Per-field secret mutations. Missing keys preserve stored values, + /// strings set values, and null explicitly clears values. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub plugin_secret_changes: HashMap>, /// When set, the test emits "connection-test-progress" events tagged with /// this id so the caller can render a live step log. #[serde(skip_serializing_if = "Option::is_none")] diff --git a/src-tauri/src/models_tests.rs b/src-tauri/src/models_tests.rs index e6d4d1967..c7e383f9e 100644 --- a/src-tauri/src/models_tests.rs +++ b/src-tauri/src/models_tests.rs @@ -196,4 +196,19 @@ mod tests { let json = serde_json::to_string(¶ms).expect("serialize params"); assert!(!json.contains("extra")); } + + #[test] + fn saved_connection_defaults_plugin_secret_markers_for_legacy_json() { + let stored = r#"{ + "id": "legacy", + "name": "Legacy", + "params": { "driver": "mysql", "database": "app" } + }"#; + let connection: crate::models::SavedConnection = + serde_json::from_str(stored).expect("legacy connection deserializes"); + assert!(connection.plugin_secret_keys.is_empty()); + + let json = serde_json::to_string(&connection).expect("serialize connection"); + assert!(!json.contains("plugin_secret_keys")); + } } diff --git a/src-tauri/src/persistence.rs b/src-tauri/src/persistence.rs index 5a910bdc2..6653354f6 100644 --- a/src-tauri/src/persistence.rs +++ b/src-tauri/src/persistence.rs @@ -44,6 +44,9 @@ pub fn save_connections_file(path: &Path, file: &ConnectionsFile) -> Result<(), let mut connections_to_save = Vec::new(); for conn in &file.connections { let mut c = conn.clone(); + for key in &c.plugin_secret_keys { + c.params.extra.remove(key); + } if c.params.save_in_keychain.unwrap_or(false) { // Passwords are stored in keychain, remove from JSON c.params.password = None; @@ -84,3 +87,48 @@ pub fn save_groups(path: &Path, groups: &[ConnectionGroup]) -> Result<(), String file.groups = groups.to_vec(); save_connections_file(path, &file) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{ConnectionParams, DatabaseSelection}; + + #[test] + fn strips_plugin_secret_values_but_preserves_markers() { + let dir = tempfile::tempdir().expect("temp dir"); + let path = dir.path().join("connections.json"); + let connection = SavedConnection { + id: "connection-1".into(), + name: "Plugin".into(), + params: ConnectionParams { + driver: "bigquery".into(), + database: DatabaseSelection::Single("project".into()), + extra: [ + ("location".into(), "EU".into()), + ("credential".into(), "private-value".into()), + ] + .into_iter() + .collect(), + ..Default::default() + }, + plugin_secret_keys: vec!["credential".into()], + group_id: None, + sort_order: None, + detect_json_in_text_columns: None, + appearance: None, + tag_ids: None, + environment: None, + }; + let file = ConnectionsFile { + connections: vec![connection], + ..Default::default() + }; + + save_connections_file(&path, &file).expect("save connections"); + let stored = fs::read_to_string(path).expect("read connections"); + assert!(!stored.contains("private-value")); + assert!(stored.contains("credential")); + assert!(stored.contains("location")); + assert!(stored.contains("EU")); + } +} diff --git a/src-tauri/src/plugin_secrets.rs b/src-tauri/src/plugin_secrets.rs new file mode 100644 index 000000000..bedba1385 --- /dev/null +++ b/src-tauri/src/plugin_secrets.rs @@ -0,0 +1,304 @@ +use std::collections::{HashMap, HashSet}; + +use crate::credential_cache::{self, CredentialCache}; +use crate::models::{ConnectionParams, SavedConnection}; + +pub type PluginSecretChanges = HashMap>; + +const MAX_SECRET_KEY_LENGTH: usize = 64; +const MAX_SECRET_VALUE_LENGTH: usize = 1024 * 1024; + +pub fn validate_secret_changes(changes: &PluginSecretChanges) -> Result<(), String> { + for (key, value) in changes { + validate_secret_key(key)?; + if value + .as_ref() + .is_some_and(|value| value.len() > MAX_SECRET_VALUE_LENGTH) + { + return Err(format!( + "Plugin secret '{}' exceeds the maximum size of {} bytes", + key, MAX_SECRET_VALUE_LENGTH + )); + } + } + Ok(()) +} + +pub fn validate_secret_key(key: &str) -> Result<(), String> { + if key.is_empty() + || key.len() > MAX_SECRET_KEY_LENGTH + || !key + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.')) + { + return Err(format!( + "Invalid plugin secret key '{}': use 1-{} ASCII letters, digits, '.', '_' or '-'", + key, MAX_SECRET_KEY_LENGTH + )); + } + Ok(()) +} + +pub fn next_secret_keys( + existing: &[String], + same_driver: bool, + changes: &PluginSecretChanges, +) -> Result, String> { + validate_secret_changes(changes)?; + let mut keys: HashSet = if same_driver { + existing.iter().cloned().collect() + } else { + HashSet::new() + }; + for (key, value) in changes { + if value.is_some() { + keys.insert(key.clone()); + } else { + keys.remove(key); + } + } + let mut keys: Vec = keys.into_iter().collect(); + keys.sort(); + Ok(keys) +} + +pub fn hydrate_connection( + cache: &CredentialCache, + connection: &mut SavedConnection, +) -> Result<(), String> { + let values = load_secret_values( + cache, + &connection.id, + &connection.params.driver, + &connection.plugin_secret_keys, + )?; + connection.params.extra.extend(values); + Ok(()) +} + +pub fn load_secret_values( + cache: &CredentialCache, + connection_id: &str, + driver: &str, + keys: &[String], +) -> Result, String> { + let mut values = HashMap::new(); + for key in keys { + validate_secret_key(key)?; + let value = credential_cache::get_plugin_secret_cached(cache, connection_id, driver, key)? + .ok_or_else(|| format!("Stored plugin secret '{}' is unavailable", key))?; + values.insert(key.clone(), value); + } + Ok(values) +} + +pub fn apply_runtime_changes( + params: &mut ConnectionParams, + stored_values: HashMap, + changes: &PluginSecretChanges, +) -> Result<(), String> { + validate_secret_changes(changes)?; + params.extra.extend(stored_values); + for (key, value) in changes { + match value { + Some(value) => _ = params.extra.insert(key.clone(), value.clone()), + None => _ = params.extra.remove(key), + } + } + Ok(()) +} + +pub fn persist_secret_changes( + cache: &CredentialCache, + connection_id: &str, + old_driver: Option<&str>, + old_keys: &[String], + new_driver: &str, + changes: &PluginSecretChanges, + persist: impl FnOnce() -> Result<(), String>, +) -> Result<(), String> { + validate_secret_changes(changes)?; + let same_driver = match old_driver { + None => true, + Some(driver) => driver == new_driver, + }; + let mut affected: Vec<(String, String)> = if same_driver { + changes + .keys() + .map(|key| (new_driver.to_string(), key.clone())) + .collect() + } else { + old_keys + .iter() + .map(|key| (old_driver.unwrap_or_default().to_string(), key.clone())) + .chain( + changes + .keys() + .map(|key| (new_driver.to_string(), key.clone())), + ) + .collect() + }; + affected.sort(); + affected.dedup(); + + let mut snapshots = Vec::with_capacity(affected.len()); + for (driver, key) in &affected { + let previous = crate::keychain_utils::get_plugin_secret(connection_id, driver, key)?; + snapshots.push((driver.clone(), key.clone(), previous)); + } + + let apply_result = (|| { + if !same_driver { + let old_driver = old_driver.unwrap_or_default(); + for key in old_keys { + crate::keychain_utils::delete_plugin_secret(connection_id, old_driver, key)?; + credential_cache::invalidate_plugin_secret(cache, connection_id, old_driver, key); + } + } + for (key, value) in changes { + match value { + Some(value) => { + crate::keychain_utils::set_plugin_secret( + connection_id, + new_driver, + key, + value, + )?; + credential_cache::set_plugin_secret_cached( + cache, + connection_id, + new_driver, + key, + value, + ); + } + None => { + crate::keychain_utils::delete_plugin_secret(connection_id, new_driver, key)?; + credential_cache::invalidate_plugin_secret( + cache, + connection_id, + new_driver, + key, + ); + } + } + } + persist() + })(); + + if apply_result.is_ok() { + return Ok(()); + } + + let original_error = apply_result.unwrap_err(); + let mut rollback_errors = Vec::new(); + for (driver, key, previous) in snapshots { + let result = match previous { + Some(value) => { + crate::keychain_utils::set_plugin_secret(connection_id, &driver, &key, &value).map( + |()| { + credential_cache::set_plugin_secret_cached( + cache, + connection_id, + &driver, + &key, + &value, + ); + }, + ) + } + None => crate::keychain_utils::delete_plugin_secret(connection_id, &driver, &key).map( + |()| { + credential_cache::invalidate_plugin_secret(cache, connection_id, &driver, &key); + }, + ), + }; + if let Err(error) = result { + rollback_errors.push(error); + } + } + + if rollback_errors.is_empty() { + Err(original_error) + } else { + Err(format!( + "{} (failed to roll back plugin secrets: {})", + original_error, + rollback_errors.join("; ") + )) + } +} + +pub fn delete_connection_secrets( + cache: &CredentialCache, + connection_id: &str, + driver: &str, + keys: &[String], +) { + for key in keys { + let _ = crate::keychain_utils::delete_plugin_secret(connection_id, driver, key); + credential_cache::invalidate_plugin_secret(cache, connection_id, driver, key); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn preserves_existing_keys_and_applies_tri_state_changes() { + let changes = HashMap::from([ + ("replace".to_string(), Some("new".to_string())), + ("remove".to_string(), None), + ("add".to_string(), Some("value".to_string())), + ]); + let keys = next_secret_keys( + &["keep".into(), "replace".into(), "remove".into()], + true, + &changes, + ) + .unwrap(); + assert_eq!(keys, vec!["add", "keep", "replace"]); + } + + #[test] + fn changing_driver_drops_old_keys() { + let changes = HashMap::from([("new-token".to_string(), Some("x".to_string()))]); + let keys = next_secret_keys(&["old-token".into()], false, &changes).unwrap(); + assert_eq!(keys, vec!["new-token"]); + } + + #[test] + fn runtime_changes_merge_stored_values_and_apply_replacements_and_clears() { + let mut params = ConnectionParams { + extra: HashMap::from([ + ("region".to_string(), "EU".to_string()), + ("remove".to_string(), "stale".to_string()), + ]), + ..Default::default() + }; + let stored = HashMap::from([ + ("keep".to_string(), "stored".to_string()), + ("replace".to_string(), "old".to_string()), + ]); + let changes = HashMap::from([ + ("replace".to_string(), Some("new".to_string())), + ("remove".to_string(), None), + ]); + + apply_runtime_changes(&mut params, stored, &changes).unwrap(); + + assert_eq!(params.extra.get("region").map(String::as_str), Some("EU")); + assert_eq!(params.extra.get("keep").map(String::as_str), Some("stored")); + assert_eq!(params.extra.get("replace").map(String::as_str), Some("new")); + assert!(!params.extra.contains_key("remove")); + } + + #[test] + fn rejects_keys_that_could_escape_the_keychain_namespace() { + assert!(validate_secret_key("credential").is_ok()); + assert!(validate_secret_key("oauth.refresh_token").is_ok()); + assert!(validate_secret_key("bad:key").is_err()); + assert!(validate_secret_key("").is_err()); + } +} diff --git a/src-tauri/src/plugins/commands.rs b/src-tauri/src/plugins/commands.rs index a70dce102..ac480ff2a 100644 --- a/src-tauri/src/plugins/commands.rs +++ b/src-tauri/src/plugins/commands.rs @@ -295,6 +295,7 @@ pub async fn get_plugin_manifest(plugin_id: String) -> Result, #[serde(default)] pub ui_extensions: Option>, + #[serde(default)] + pub connection_fields: Option, /// Static type mappings for `map_inferred_type`. Keys are generic inferred /// types (e.g. `"DATETIME"`), values are driver-specific types (e.g. `"TIMESTAMP"`). #[serde(default)] @@ -186,6 +190,7 @@ pub async fn load_plugin_from_dir( icon: config.icon, settings: config.settings, ui_extensions: config.ui_extensions, + connection_fields: config.connection_fields, type_mappings: config.type_mappings, }; diff --git a/src-tauri/src/plugins/tests.rs b/src-tauri/src/plugins/tests.rs index 20f99e471..c9ff7a2a9 100644 --- a/src-tauri/src/plugins/tests.rs +++ b/src-tauri/src/plugins/tests.rs @@ -104,6 +104,28 @@ fn preserves_ui_extension_driver_filter_from_manifest() { assert_eq!(entries[1].order, Some(10)); } +#[test] +fn parses_optional_connection_field_overrides() { + let manifest: ConfigManifest = serde_json::from_str( + r#"{ + "name": "bigquery", + "version": "1.0.0", + "description": "BigQuery driver", + "connection_fields": { + "host": { "hidden": true }, + "database": { "label": "GCP Project ID", "placeholder": "billing-project" } + } +}"#, + ) + .expect("parse manifest"); + + let fields = manifest.connection_fields.expect("connection fields"); + assert!(fields.host.expect("host override").hidden); + let database = fields.database.expect("database override"); + assert_eq!(database.label.as_deref(), Some("GCP Project ID")); + assert_eq!(database.placeholder.as_deref(), Some("billing-project")); +} + #[test] fn returns_error_for_invalid_manifest() { let dir = tempdir().expect("temp dir"); diff --git a/src-tauri/src/sqlite_database.rs b/src-tauri/src/sqlite_database.rs index 707c576cd..aa8a56bc6 100644 --- a/src-tauri/src/sqlite_database.rs +++ b/src-tauri/src/sqlite_database.rs @@ -124,7 +124,7 @@ pub async fn create_sqlite_database( ..ConnectionParams::default() }; - match crate::commands::save_connection(app, name, params, None, None).await { + match crate::commands::save_connection(app, name, params, None, None, None).await { Ok(connection) => Ok(connection), Err(error) => { if let Err(cleanup_error) = fs::remove_file(&path) { diff --git a/src/components/modals/NewConnectionModal.tsx b/src/components/modals/NewConnectionModal.tsx index 076f54330..24073afef 100644 --- a/src/components/modals/NewConnectionModal.tsx +++ b/src/components/modals/NewConnectionModal.tsx @@ -155,6 +155,7 @@ interface SavedConnection { id: string; name: string; params: ConnectionParams; + plugin_secret_keys?: string[]; detect_json_in_text_columns?: boolean; appearance?: ConnectionAppearance; tag_ids?: string[]; @@ -349,6 +350,9 @@ export const NewConnectionModal = ({ const [detectJsonInTextColumns, setDetectJsonInTextColumns] = useState(false); const [passwordDirty, setPasswordDirty] = useState(false); const [sshPasswordDirty, setSshPasswordDirty] = useState(false); + const [pluginSecretChanges, setPluginSecretChanges] = useState< + Record + >({}); const [connectionString, setConnectionString] = useState(""); const [connectionStringError, setConnectionStringError] = useState< string | null @@ -625,6 +629,11 @@ export const NewConnectionModal = ({ // Flat single-database store (e.g. Meilisearch): no database to select or name. const singleDatabase = activeDriver?.capabilities?.single_database === true; + const hostField = activeDriver?.connection_fields?.host; + const portField = activeDriver?.connection_fields?.port; + const usernameField = activeDriver?.connection_fields?.username; + const passwordField = activeDriver?.connection_fields?.password; + const databaseField = activeDriver?.connection_fields?.database; // ── plugin slot: connection-modal.connection_content ── const slotRegistry = usePluginSlotRegistry(); @@ -656,13 +665,44 @@ export const NewConnectionModal = ({ extra: updateExtraField(prev.extra, key, value), })); }, []); + const setSecretField = useCallback((key: string, value: string) => { + const normalizedKey = key.trim(); + if (!normalizedKey) return; + setPluginSecretChanges((previous) => ({ + ...previous, + [normalizedKey]: value === "" ? null : value, + })); + }, []); + const secretFields = useMemo(() => { + const storedKeys = + initialConnection?.params.driver === driver + ? (initialConnection.plugin_secret_keys ?? []) + : []; + const keys = new Set([...storedKeys, ...Object.keys(pluginSecretChanges)]); + return Object.fromEntries( + Array.from(keys).map((key) => { + const dirty = Object.hasOwn(pluginSecretChanges, key); + const pendingValue = pluginSecretChanges[key]; + return [ + key, + { + value: typeof pendingValue === "string" ? pendingValue : "", + hasStoredValue: storedKeys.includes(key), + dirty, + }, + ]; + }), + ); + }, [driver, initialConnection, pluginSecretChanges]); const extraFieldsSlotContext = useMemo( () => ({ driver, extra: formData.extra ?? {}, setExtraField, + secretFields, + setSecretField, }), - [driver, formData.extra, setExtraField], + [driver, formData.extra, secretFields, setExtraField, setSecretField], ); // ── helpers ── @@ -1634,6 +1674,7 @@ export const NewConnectionModal = ({ setDatabaseLoadError(null); setPasswordDirty(false); setSshPasswordDirty(false); + setPluginSecretChanges({}); setDbSearchQuery(""); setConnectionString(""); setConnectionStringError(null); @@ -1801,6 +1842,7 @@ export const NewConnectionModal = ({ const handleDriverChange = (newDriver: string) => { setDriver(newDriver); + setPluginSecretChanges({}); setFormData({ driver: newDriver, host: "", @@ -1957,6 +1999,7 @@ export const NewConnectionModal = ({ params: { ...testParams }, connection_id: initialConnection?.id, progress_id: progressId, + plugin_secret_changes: pluginSecretChanges, }, }); @@ -2127,6 +2170,7 @@ export const NewConnectionModal = ({ } else if ( !noConnectionRequired && !singleDatabase && + !databaseField?.hidden && !hasConnectionUri && (!formData.database || (typeof formData.database === "string" && !formData.database.trim())) @@ -2187,6 +2231,7 @@ export const NewConnectionModal = ({ id: initialConnection.id, name, params, + pluginSecretChanges, detectJsonInTextColumns: detectJsonInTextColumns ? true : null, environment: environment || null, }); @@ -2199,6 +2244,7 @@ export const NewConnectionModal = ({ const saved = await invoke<{ id: string }>("save_connection", { name, params, + pluginSecretChanges, detectJsonInTextColumns: detectJsonInTextColumns ? true : null, environment: environment || null, }); @@ -2529,27 +2575,39 @@ export const NewConnectionModal = ({ )} - {!isUriPassthrough && ( + {!isUriPassthrough && + (!hostField?.hidden || !portField?.hidden) && (
- updateField("host", v)} - placeholder="localhost" - /> - updateField("port", v)} - type="number" - placeholder={driver === "mysql" ? "3306" : "5432"} - /> + {!hostField?.hidden && ( + updateField("host", v)} + placeholder={hostField?.placeholder ?? "localhost"} + /> + )} + {!portField?.hidden && ( + updateField("port", v)} + type="number" + placeholder={ + portField?.placeholder ?? + (driver === "mysql" ? "3306" : "5432") + } + /> + )}
)} @@ -2561,60 +2619,78 @@ export const NewConnectionModal = ({ /> {/* User + Password */} -
- {!isUriPassthrough && ( - updateField("username", v)} - placeholder={t("newConnection.usernamePlaceholder")} - /> - )} - { - setPasswordDirty(true); - updateField("password", v); - }} - type="password" - placeholder={ - initialConnection && !passwordDirty && !formData.password - ? "••••••••" - : t("newConnection.passwordPlaceholder") - } - /> -
+ {(!passwordField?.hidden || + (!isUriPassthrough && !usernameField?.hidden)) && ( +
+ {!isUriPassthrough && !usernameField?.hidden && ( + updateField("username", v)} + placeholder={ + usernameField?.placeholder ?? + t("newConnection.usernamePlaceholder") + } + /> + )} + {!passwordField?.hidden && ( + { + setPasswordDirty(true); + updateField("password", v); + }} + type="password" + placeholder={ + initialConnection && !passwordDirty && !formData.password + ? "••••••••" + : passwordField?.placeholder ?? + t("newConnection.passwordPlaceholder") + } + /> + )} +
+ )} {/* Database (single) — only shown for non-multi-db drivers */} - {!isUriPassthrough && !isMultiDb && !singleDatabase && ( + {!isUriPassthrough && + !isMultiDb && + !singleDatabase && + !databaseField?.hidden && (
- + {!hostField?.hidden && ( + + )}
{availableDatabases.length > 0 ? ( context.setSecretField?.("credential", event.target.value)} + /> + ); + }, +})); + +afterEach(() => { + driverState.connectionFields = undefined; + slotMocks.renderSecureField = false; +}); + vi.mock("../../../src/hooks/useSettings", () => ({ useSettings: () => ({ settings: {}, @@ -327,6 +369,49 @@ describe("NewConnectionModal layout", () => { expect(shell).toHaveClass("flex"); expect(shell).toHaveClass("flex-col"); }); + + it("applies additive hide and label overrides to common fields", () => { + driverState.connectionFields = { + host: { hidden: true }, + port: { hidden: true }, + username: { hidden: true }, + password: { label: "Credential" }, + database: { label: "GCP Project ID", placeholder: "billing-project" }, + }; + + renderModal(createInitialConnection({ password: "", host: "", username: "" })); + + expect(screen.queryByText("newConnection.host")).not.toBeInTheDocument(); + expect(screen.queryByText("newConnection.port")).not.toBeInTheDocument(); + expect(screen.queryByText("newConnection.username")).not.toBeInTheDocument(); + expect(screen.getByText("Credential")).toBeInTheDocument(); + expect(screen.getByText("GCP Project ID")).toBeInTheDocument(); + }); + + it("passes secret tri-state mutations without exposing the stored value", async () => { + slotMocks.renderSecureField = true; + const connection = createInitialConnection({}); + connection.plugin_secret_keys = ["credential"]; + renderModal(connection); + + const input = screen.getByLabelText("plugin-credential"); + expect(input).toHaveValue(""); + expect(input).toHaveAttribute("placeholder", "stored-secret"); + + fireEvent.change(input, { target: { value: "replacement" } }); + fireEvent.click(screen.getByText("newConnection.testConnection")); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + "test_connection", + expect.objectContaining({ + request: expect.objectContaining({ + plugin_secret_changes: { credential: "replacement" }, + }), + }), + ); + }); + }); }); async function openInlineK8s() {