feat(salesforce): add Salesforce SOQL data source

This commit is contained in:
邹超 authored and GitHub committed 2026-09-24 18:25:23 +08:00
1 parent bf3e356540
commit f87b5d1568
125 files changed
+9999 -117

No files matched your search

@@ -68,6 +68,14 @@ pub fn dbx_mcp_enabled_tools(agent_mode: bool) -> Vec<&'static str> {
if agent_mode {
tools.push("dbx_execute_query");
tools.push("dbx_execute_redis_command");
// Read-only Salesforce identity, so an agent working against an org can tell the
// user who a change would be attributed to. The `dbx_salesforce_prepare_write` /
// `dbx_salesforce_apply_write` pair is deliberately absent: DBX spawns these CLI
// agents with `--permission-mode dontAsk`, so a tool listed here runs with no
// interactive gate, and a Salesforce write cannot be rolled back. The two-step
// confirmation belongs to MCP clients that do put the summary in front of a
// person; the connection-level DML switch alone is not a substitute.
tools.push("dbx_salesforce_current_user");
}
tools
}
@@ -2908,6 +2908,39 @@
"userAdmin": false,
"driverManagement": false
}
},
{
"dbType": "salesforce",
"label": "Salesforce",
"dialect": "SOQL",
"runtimeMode": "native",
"mcpMode": "bridge",
"singleConnectionPool": true,
"metadataConnectionScoped": false,
"skipTcpProbe": true,
"defaultPort": 443,
"traits": {
"singleDatabase": true
},
"supportLevel": "operate",
"capabilities": {
"queryExecution": true,
"metadataBrowse": true,
"objectBrowser": true,
"objectSource": false,
"schemaSearch": true,
"diagram": false,
"tableDataEdit": true,
"tableStructureEdit": false,
"tableImport": false,
"dataTransfer": false,
"sqlFileExecution": false,
"databaseCreate": false,
"fieldLineage": false,
"sqlExplain": false,
"userAdmin": false,
"driverManagement": false
}
}
]
}
+42 -1
View File
@@ -166,6 +166,21 @@ fn apply_ceiling(current: (bool, bool), ceiling: (bool, bool)) -> (bool, bool) {
(read_only, !read_only && current.1 && ceiling.1)
}
/// Whether an AI agent may write to this Salesforce connection through MCP.
///
/// A pure per-connection opt-in: no global or group setting can turn it on, and
/// a connection without a saved rule stays off. Callers still run every write
/// through the ordinary execution policy, so the global/connection read-only
/// modes, connection read-only protection and production protection all remain
/// upper bounds on top of this switch.
pub fn connection_allows_salesforce_dml(policy: &McpGlobalPolicy, connection_id: &str) -> bool {
policy
.connection_policies
.iter()
.find(|rule| rule.connection_id == connection_id)
.is_some_and(|rule| rule.allow_salesforce_dml && !rule.read_only)
}
/// Reject qualified SQL references when database-specific execution rules are
/// present and individual referenced databases have not been evaluated.
pub fn ensure_sql_database_execution_scope(
@@ -251,7 +266,10 @@ pub fn ensure_mongo_database_execution_scope(
#[cfg(test)]
mod tests {
use super::{effective_database_execution_policy, resolve_database, MCP_EXECUTION_POLICY_VERSION};
use super::{
connection_allows_salesforce_dml, effective_database_execution_policy, resolve_database,
MCP_EXECUTION_POLICY_VERSION,
};
use crate::storage::{McpConnectionPolicy, McpDatabasePolicy, McpGlobalPolicy};
fn policy(version: Option<u8>) -> McpGlobalPolicy {
@@ -270,6 +288,7 @@ mod tests {
read_only: false,
allow_dangerous_sql: true,
}],
allow_salesforce_dml: false,
}],
..Default::default()
}
@@ -316,4 +335,26 @@ mod tests {
assert_eq!(resolve_database(" ", Some("sample")), "sample");
assert_eq!(resolve_database("analytics", Some("sample")), "analytics");
}
#[test]
fn salesforce_dml_needs_an_explicit_connection_opt_in() {
// No rule for the connection at all → off, whatever the global mode says.
let mut open = policy(Some(MCP_EXECUTION_POLICY_VERSION));
open.read_only = false;
assert!(!connection_allows_salesforce_dml(&open, "other"));
assert!(!connection_allows_salesforce_dml(&open, "conn"));
open.connection_policies[0].allow_salesforce_dml = true;
assert!(connection_allows_salesforce_dml(&open, "conn"));
}
#[test]
fn salesforce_dml_opt_in_is_void_on_a_read_only_connection_rule() {
let mut policy = policy(Some(MCP_EXECUTION_POLICY_VERSION));
policy.connection_policies[0].allow_salesforce_dml = true;
policy.connection_policies[0].read_only = true;
// `normalized()` already clears the flag for read-only rules; this keeps a
// hand-built or legacy policy from sneaking a write through.
assert!(!connection_allows_salesforce_dml(&policy, "conn"));
}
}
@@ -27,6 +27,10 @@ pub const MQTT_AUTH_PASSWORD_KEY: &str = "mqtt.auth.password";
pub const CASSANDRA_TLS_SECRET_PREFIX: &str = "cassandra.tls.";
pub const CASSANDRA_TRUSTSTORE_PASSWORD_KEY: &str = "cassandra.tls.truststore_password";
pub const CASSANDRA_KEYSTORE_PASSWORD_KEY: &str = "cassandra.tls.keystore_password";
pub const SALESFORCE_AUTH_SECRET_PREFIX: &str = "salesforce.auth.";
pub const SALESFORCE_AUTH_CLIENT_SECRET_KEY: &str = "salesforce.auth.client_secret";
pub const SALESFORCE_AUTH_REFRESH_TOKEN_KEY: &str = "salesforce.auth.refresh_token";
pub const SALESFORCE_AUTH_PASSWORD_KEY: &str = "salesforce.auth.password";
pub const PLUGIN_CONNECTION_SECRET_PREFIX: &str = "plugin_connection.";
/// Storage-level secret key for one plugin connection field: namespaced under
@@ -127,6 +131,7 @@ pub fn save_connections_to_file(
persist_mq_token_signing_secret(store, config)?;
persist_mqtt_auth_secrets(store, config)?;
persist_cassandra_tls_secrets(store, config)?;
persist_salesforce_auth_secrets(store, config)?;
delete_secret_prefix(store, &config.id, PLUGIN_CONNECTION_SECRET_PREFIX)?;
for (key, secret) in &config.connection_secrets {
persist_secret(store, &config.id, &plugin_connection_secret_key(key)?, secret)?;
@@ -201,6 +206,7 @@ pub fn load_connections_from_file(
hydrate_mq_token_signing_secret(store, config, &mut needs_rewrite)?;
hydrate_mqtt_auth_secrets(store, config, &mut needs_rewrite)?;
hydrate_cassandra_tls_secrets(store, config, &mut needs_rewrite)?;
hydrate_salesforce_auth_secrets(store, config, &mut needs_rewrite)?;
let plugin_secret_keys = config.connection_secrets.keys().cloned().collect::<Vec<_>>();
for key in plugin_secret_keys {
let storage_key = plugin_connection_secret_key(&key)?;
@@ -394,6 +400,7 @@ fn delete_removed_connection_secrets(
delete_secret_prefix(store, &config.id, MQ_TOKEN_SIGNING_SECRET_PREFIX)?;
delete_secret_prefix(store, &config.id, MQTT_AUTH_SECRET_PREFIX)?;
delete_secret_prefix(store, &config.id, CASSANDRA_TLS_SECRET_PREFIX)?;
delete_secret_prefix(store, &config.id, SALESFORCE_AUTH_SECRET_PREFIX)?;
}
Ok(())
}
@@ -653,6 +660,96 @@ fn cassandra_tls_object_mut(
value?.get_mut("tls")?.as_object_mut()
}
// ── Salesforce OAuth 密钥持久化 ─────────────────────────────────
//
// Persist semantics (mirrors MQ's replace_mq_auth_secret):
// 1. Incoming empty/missing + secret already stored → preserve existing secret.
// 2. Incoming non-empty → overwrite.
// 3. external_config.auth block absent (user switched off OAuth) → delete both secrets.
// This is required because the frontend edits an existing connection with the
// sensitive fields blanked out — an empty value must not clobber the stored secret.
fn persist_salesforce_auth_secrets(store: &dyn ConnectionSecretStore, config: &ConnectionConfig) -> Result<(), String> {
if config.db_type != DatabaseType::Salesforce {
delete_secret_prefix(store, &config.id, SALESFORCE_AUTH_SECRET_PREFIX)?;
return Ok(());
}
let Some(auth) = salesforce_auth_object(config.external_config.as_ref()) else {
delete_secret_prefix(store, &config.id, SALESFORCE_AUTH_SECRET_PREFIX)?;
return Ok(());
};
replace_salesforce_auth_secret(store, &config.id, SALESFORCE_AUTH_CLIENT_SECRET_KEY, auth, "clientSecret")?;
replace_salesforce_auth_secret(store, &config.id, SALESFORCE_AUTH_REFRESH_TOKEN_KEY, auth, "refreshToken")?;
replace_salesforce_auth_secret(store, &config.id, SALESFORCE_AUTH_PASSWORD_KEY, auth, "password")?;
Ok(())
}
fn replace_salesforce_auth_secret(
store: &dyn ConnectionSecretStore,
connection_id: &str,
key: &str,
auth: &serde_json::Map<String, serde_json::Value>,
field: &str,
) -> Result<(), String> {
let current = auth.get(field).and_then(serde_json::Value::as_str).filter(|secret| !secret.is_empty());
let existing = if current.is_none() { store.get_secret(connection_id, key)? } else { None };
// Per-key delete: we only manage the exact key we own, so two parallel
// replace calls don't wipe each other out.
store.delete_secret(connection_id, key)?;
match current {
Some(secret) => store.set_secret(connection_id, key, secret),
None => match existing {
Some(secret) => store.set_secret(connection_id, key, &secret),
None => Ok(()),
},
}
}
fn hydrate_salesforce_auth_secrets(
store: &dyn ConnectionSecretStore,
config: &mut ConnectionConfig,
needs_rewrite: &mut bool,
) -> Result<(), String> {
if config.db_type != DatabaseType::Salesforce {
return Ok(());
}
let Some(auth) = salesforce_auth_object_mut(config.external_config.as_mut()) else {
return Ok(());
};
hydrate_json_secret(store, &config.id, SALESFORCE_AUTH_CLIENT_SECRET_KEY, auth, "clientSecret", needs_rewrite)?;
hydrate_json_secret(store, &config.id, SALESFORCE_AUTH_REFRESH_TOKEN_KEY, auth, "refreshToken", needs_rewrite)?;
hydrate_json_secret(store, &config.id, SALESFORCE_AUTH_PASSWORD_KEY, auth, "password", needs_rewrite)?;
Ok(())
}
fn scrub_salesforce_auth_secrets(config: &mut ConnectionConfig) {
if config.db_type != DatabaseType::Salesforce {
return;
}
let Some(auth) = salesforce_auth_object_mut(config.external_config.as_mut()) else {
return;
};
scrub_json_secret(auth, "clientSecret");
scrub_json_secret(auth, "refreshToken");
scrub_json_secret(auth, "password");
}
fn salesforce_auth_object(value: Option<&serde_json::Value>) -> Option<&serde_json::Map<String, serde_json::Value>> {
value?.get("auth")?.as_object()
}
fn salesforce_auth_object_mut(
value: Option<&mut serde_json::Value>,
) -> Option<&mut serde_json::Map<String, serde_json::Value>> {
value?.get_mut("auth")?.as_object_mut()
}
// ── MQTT 密钥持久化 ──────────────────────────────────────────────
fn persist_mqtt_auth_secrets(store: &dyn ConnectionSecretStore, config: &ConnectionConfig) -> Result<(), String> {
@@ -845,6 +942,7 @@ fn sanitize_connections(configs: &[ConnectionConfig]) -> Vec<ConnectionConfig> {
scrub_mq_token_signing_secret(&mut config);
scrub_mqtt_auth_secrets(&mut config);
scrub_cassandra_tls_secrets(&mut config);
scrub_salesforce_auth_secrets(&mut config);
scrub_plugin_connection_secrets(&mut config);
config
})
+117
View File
@@ -43,6 +43,7 @@ use crate::plugins::{
PluginRuntimeEnv, PluginRuntimeProxy,
};
use crate::query_cancel::RunningQueries;
use crate::salesforce_oauth::SfBrowserOpener;
use crate::session_credentials::SessionCredentialStore;
use crate::storage::{normalize_duckdb_worker_max_processes, Storage, DUCKDB_WORKER_MAX_PROCESSES_DEFAULT};
use crate::task_supervisor::TaskSupervisor;
@@ -118,6 +119,7 @@ pub enum PoolKind {
Easysearch(db::easysearch_driver::EasysearchClient),
Solr(db::solr_driver::SolrClient),
Meilisearch(db::meilisearch_driver::MeilisearchClient),
Salesforce(db::salesforce_driver::SfClient),
HBase(db::hbase_driver::HBaseClient),
VectorDb(db::vector_driver::VectorClient),
InfluxDb(db::influxdb_driver::InfluxdbClient),
@@ -367,6 +369,24 @@ struct SharedResourceBudget {
semaphore: Arc<Semaphore>,
}
/// Cached Salesforce connected-user identity + org display name, serialized
/// with camelCase field names for the frontend. `Deserialize` is derived too so
/// the Web-mode MCP backend can decode the same JSON the desktop route emits.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SalesforceCurrentUser {
pub user_id: String,
pub name: String,
pub email: String,
pub organization_id: String,
pub username: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub is_admin: Option<bool>,
pub org_name: String,
}
pub struct AppState {
connections: Arc<RwLock<ConnectionPoolRegistry>>,
task_supervisor: TaskSupervisor,
@@ -407,6 +427,7 @@ pub struct AppState {
pub write_unlock_windows: crate::write_unlock::WriteUnlockWindows,
metadata_gates: Arc<Mutex<HashMap<String, Arc<Semaphore>>>>,
mongo_oidc_browser_opener: std::sync::RwLock<Option<MongoOidcBrowserOpener>>,
salesforce_browser_opener: std::sync::RwLock<Option<SfBrowserOpener>>,
#[cfg(feature = "mq-admin")]
pub mq_registry: crate::mq::MqAdminRegistry,
}
@@ -1601,6 +1622,7 @@ impl AppState {
write_unlock_windows: crate::write_unlock::WriteUnlockWindows::default(),
metadata_gates: Arc::new(Mutex::new(HashMap::new())),
mongo_oidc_browser_opener: std::sync::RwLock::new(None),
salesforce_browser_opener: std::sync::RwLock::new(None),
#[cfg(feature = "mq-admin")]
mq_registry: crate::mq::MqAdminRegistry::new(),
}
@@ -1614,6 +1636,14 @@ impl AppState {
self.mongo_oidc_browser_opener.read().expect("MongoDB OIDC browser opener lock poisoned").clone()
}
pub fn set_salesforce_browser_opener(&self, opener: SfBrowserOpener) {
*self.salesforce_browser_opener.write().expect("Salesforce browser opener lock poisoned") = Some(opener);
}
pub fn salesforce_browser_opener(&self) -> Option<SfBrowserOpener> {
self.salesforce_browser_opener.read().expect("Salesforce browser opener lock poisoned").clone()
}
pub(crate) async fn acquire_metadata_permit(
&self,
connection_id: &str,
@@ -2844,6 +2874,16 @@ impl AppState {
db::meilisearch_driver::test_connection(&client, connect_timeout).await?;
PoolKind::Meilisearch(client)
}
DatabaseType::Salesforce => {
let client = db::salesforce_driver::SfClient::from_config(
&url,
Some(&db_config.password),
db_config.external_config.as_ref(),
connect_timeout,
)?;
db::salesforce_driver::SfClient::test_connection(&client, connect_timeout).await?;
PoolKind::Salesforce(client)
}
DatabaseType::Hbase => {
let client = db::hbase_driver::HBaseClient::new(
&url,
@@ -4214,6 +4254,17 @@ impl AppState {
}
}
}
PoolKind::Salesforce(client) => {
let client = client.clone();
let timeout = crate::db::connection_timeout();
match db::salesforce_driver::SfClient::test_connection(&client, timeout).await {
Ok(()) => false,
Err(err) => {
log::warn!("Salesforce connection pool '{pool_key}' is stale: {err}");
true
}
}
}
PoolKind::HBase(client) => {
let client = client.clone();
let timeout = crate::db::connection_timeout();
@@ -5249,6 +5300,38 @@ impl AppState {
Ok(())
}
/// Cached connected-user identity for a Salesforce connection. Returns a
/// camelCase-serializable struct for the frontend (identity badge, admin
/// warning). Errors when the connection is not Salesforce or has no pool.
pub async fn salesforce_current_user(&self, connection_id: &str) -> Result<SalesforceCurrentUser, String> {
let db_type = {
let configs = self.configs.read().await;
configs.get(connection_id).map(|c| c.db_type)
};
if db_type != Some(DatabaseType::Salesforce) {
return Err("Not a Salesforce connection".to_string());
}
let pool_key = base_pool_key_for(db_type, connection_id, None, false);
let pool = self.pool_handle(&pool_key).await.ok_or_else(|| "Connection not found".to_string())?;
match pool {
PoolKind::Salesforce(client) => {
let user = client.cached_current_user().await?;
let org_name = client.org_display_name().await;
Ok(SalesforceCurrentUser {
user_id: user.user_id,
name: user.name,
email: user.email,
organization_id: user.organization_id,
username: user.username,
profile_name: user.profile_name,
is_admin: user.is_admin,
org_name,
})
}
_ => Err("Not a Salesforce connection".to_string()),
}
}
/// Warm the driver/pool a tab is about to use, off the user's critical path.
///
/// The first statement of a session pays costs that the user perceives as
@@ -5405,6 +5488,16 @@ impl AppState {
}
}
}
PoolKind::Salesforce(client) => {
let client = client.clone();
match db::salesforce_driver::SfClient::test_connection(&client, timeout).await {
Ok(()) => true,
Err(e) => {
log::warn!("Salesforce connection pool '{key}' is unhealthy: {e}");
false
}
}
}
PoolKind::HBase(client) => match db::hbase_driver::test_connection(client, timeout).await {
Ok(_) => true,
Err(e) => {
@@ -5796,6 +5889,7 @@ enum KeepaliveTarget {
SqlServer(Arc<tokio::sync::Mutex<db::sqlserver::SqlServerClient>>),
Elasticsearch(db::elasticsearch_driver::EsClient),
Easysearch(db::easysearch_driver::EasysearchClient),
Salesforce(db::salesforce_driver::SfClient),
Solr(db::solr_driver::SolrClient),
HBase(db::hbase_driver::HBaseClient),
VectorDb(db::vector_driver::VectorClient),
@@ -5898,6 +5992,7 @@ fn keepalive_target_from_pool(pool: &PoolKind, config: &ConnectionConfig) -> Opt
PoolKind::SqlServer(client) => Some(KeepaliveTarget::SqlServer(client.clone())),
PoolKind::Elasticsearch(client) => Some(KeepaliveTarget::Elasticsearch(client.clone())),
PoolKind::Easysearch(client) => Some(KeepaliveTarget::Easysearch(client.clone())),
PoolKind::Salesforce(client) => Some(KeepaliveTarget::Salesforce(client.clone())),
PoolKind::Solr(client) => Some(KeepaliveTarget::Solr(client.clone())),
PoolKind::HBase(client) => Some(KeepaliveTarget::HBase(client.clone())),
PoolKind::VectorDb(client) => Some(KeepaliveTarget::VectorDb(client.clone())),
@@ -5947,6 +6042,9 @@ async fn ping_keepalive_target(target: &mut KeepaliveTarget, timeout: Duration)
KeepaliveTarget::Easysearch(client) => {
db::easysearch_driver::test_connection(client, timeout).await.map_err(Into::into)
}
KeepaliveTarget::Salesforce(client) => {
db::salesforce_driver::SfClient::test_connection(client, timeout).await.map_err(Into::into)
}
KeepaliveTarget::Solr(client) => db::solr_driver::test_connection(client, timeout).await.map_err(Into::into),
KeepaliveTarget::HBase(client) => {
db::hbase_driver::test_connection(client, timeout).await.map(|_| ()).map_err(Into::into)
@@ -6370,6 +6468,7 @@ fn clone_pool_kind(pool: &PoolKind) -> PoolKind {
PoolKind::Easysearch(client) => PoolKind::Easysearch(client.clone()),
PoolKind::Solr(client) => PoolKind::Solr(client.clone()),
PoolKind::Meilisearch(client) => PoolKind::Meilisearch(client.clone()),
PoolKind::Salesforce(client) => PoolKind::Salesforce(client.clone()),
PoolKind::HBase(client) => PoolKind::HBase(client.clone()),
PoolKind::VectorDb(client) => PoolKind::VectorDb(client.clone()),
PoolKind::InfluxDb(client) => PoolKind::InfluxDb(client.clone()),
@@ -6434,6 +6533,9 @@ async fn close_pool_kind(pool: PoolKind) -> Result<(), String> {
PoolKind::Meilisearch(client) => {
drop(client);
}
PoolKind::Salesforce(client) => {
drop(client);
}
PoolKind::HBase(client) => {
drop(client);
}
@@ -6534,6 +6636,7 @@ fn base_pool_key_for_with_catalog(
| DatabaseType::Milvus
| DatabaseType::Weaviate
| DatabaseType::ChromaDb
| DatabaseType::Salesforce
));
is_single && (!database_capabilities::is_agent_type(db_type) || shares_database_pool_with_connection(db_type))
});
@@ -8997,6 +9100,20 @@ mod tests {
}
}
#[test]
fn salesforce_connections_never_use_a_tcp_probe() {
// Salesforce reaches a cloud HTTPS endpoint through one pooled client, so the
// manifest sets skipTcpProbe: a raw TCP pre-flight is meaningless even for a
// forwarded local endpoint.
let mut config = mysql_config(Some("app"));
config.db_type = DatabaseType::Salesforce;
config.host = "acme.my.salesforce.com".to_string();
config.port = 443;
assert!(!uses_tcp_probe(&config, "acme.my.salesforce.com", 443), "instance url");
assert!(!uses_tcp_probe(&config, "127.0.0.1", 54000), "forwarded");
}
#[test]
fn h2_agent_connections_skip_tcp_probe_for_file_and_tcp_modes() {
let mut file_config = mysql_config(None);
+66
View File
@@ -42,6 +42,16 @@ async fn connection_database_type(state: &AppState, connection_id: &str) -> Resu
pub async fn export_table_data_csv_core(state: &AppState, options: TableCsvExportOptions) -> Result<u64, String> {
let database_type = connection_database_type(state, &options.connection_id).await?;
// This loop pages with `LIMIT <page_size> OFFSET <n>` and stops at the first
// short page. Neither half holds for SOQL: a `/query` response carries at most
// 2000 rows and hands the rest back as a QueryLocator (`has_more` +
// `session_id`), and OFFSET is capped at 2000, so the first page always looks
// short and the export would stop there — a CSV silently missing the rest of
// the object. Refuse instead of truncating; a Salesforce export has to follow
// the QueryLocator (`fetch_more`) the way the grid's "load more" does.
if database_type == DatabaseType::Salesforce {
return Err("Exporting a Salesforce object to CSV is not supported yet: SOQL pages through a QueryLocator, not LIMIT/OFFSET. Run a SOQL query and export its result instead.".to_string());
}
let page_size = options.page_size.unwrap_or(TABLE_DATA_EXPORT_PAGE_SIZE).max(1);
let mut writer =
BufWriter::new(File::create(&options.file_path).map_err(|err| format!("Failed to write CSV file: {err}"))?);
@@ -109,3 +119,59 @@ pub async fn export_table_data_csv_core(state: &AppState, options: TableCsvExpor
writer.flush().map_err(|err| err.to_string())?;
Ok(rows_exported)
}
#[cfg(test)]
mod tests {
use super::{export_table_data_csv_core, CsvQuoteMode, TableCsvExportOptions};
use crate::connection::AppState;
use crate::models::connection::{ConnectionConfig, DatabaseType};
use crate::storage::Storage;
fn salesforce_config(id: &str) -> ConnectionConfig {
let mut config = serde_json::from_value::<ConnectionConfig>(serde_json::json!({
"id": id,
"name": "SFDC QA",
"db_type": "postgres",
"host": "example.my.salesforce.com",
"port": 443,
"username": "user@example.com",
"password": "",
"database": ""
}))
.unwrap();
config.db_type = DatabaseType::Salesforce;
config
}
/// The refusal has to land before `File::create`, otherwise the user is left
/// holding a header-only CSV that looks like an object with no records.
#[tokio::test]
async fn salesforce_table_csv_export_is_refused_before_a_file_is_created() {
let directory = tempfile::tempdir().unwrap();
let storage = Storage::open(&directory.path().join("storage.db")).await.unwrap();
let state = AppState::new(storage);
let config = salesforce_config("sfdc-csv");
state.configs.write().await.insert(config.id.clone(), config);
let csv_path = directory.path().join("Account.csv");
let error = export_table_data_csv_core(
&state,
TableCsvExportOptions {
file_path: csv_path.to_string_lossy().into_owned(),
connection_id: "sfdc-csv".to_string(),
database: String::new(),
schema: None,
table_name: "Account".to_string(),
columns: vec!["Id".to_string(), "Name".to_string()],
page_size: None,
timeout_secs: None,
csv_quote_mode: CsvQuoteMode::default(),
},
)
.await
.expect_err("SOQL cannot page an export with LIMIT/OFFSET");
assert!(error.contains("QueryLocator"), "unexpected error: {error}");
assert!(!csv_path.exists(), "a refused export must not leave a truncated CSV behind");
}
}
+1
View File
@@ -56,6 +56,7 @@ pub use data::mongodb_dump;
pub use data::mongodb_import_export;
pub use dbx_drivers::mongo_oidc;
pub use dbx_drivers::mongo_shell;
pub use dbx_drivers::salesforce_oauth;
pub use dbx_formats::export_split_zip;
pub use dbx_plugin_runtime::plugins;
pub use dbx_sql::db_admin_sql;
+116 -56
View File
@@ -93,12 +93,30 @@ impl SecretCodec {
data_dir: &std::path::Path,
allow_create: bool,
) -> Result<SecretKeyResolution, String> {
if let Some(path) = std::env::var_os("DBX_SECRET_KEY_FILE") {
Self::resolve_with_env(policy, data_dir, allow_create, |name| std::env::var_os(name))
}
/// [`Self::resolve`] with the environment lookup injected. `DBX_SECRET_KEY`
/// and `DBX_SECRET_KEY_FILE` are process global, so tests drive the explicit
/// key branches through here instead of mutating the environment: a mutation
/// in flight would silently change the key every other test in the same
/// binary resolves, and the tests that encrypt with one key would fail to
/// decrypt once it is restored.
fn resolve_with_env<F>(
policy: SecretKeyPolicy,
data_dir: &std::path::Path,
allow_create: bool,
env_lookup: F,
) -> Result<SecretKeyResolution, String>
where
F: Fn(&str) -> Option<std::ffi::OsString>,
{
if let Some(path) = env_lookup("DBX_SECRET_KEY_FILE") {
let path = std::path::PathBuf::from(path);
let codec = Self::read_key_file(&path, false)?;
return Ok(SecretKeyResolution { codec, source: SecretKeySource::ExplicitFile });
}
if let Some(value) = std::env::var_os("DBX_SECRET_KEY") {
if let Some(value) = env_lookup("DBX_SECRET_KEY") {
let value = value.to_str().ok_or_else(|| "SECRET_KEY_INVALID".to_string())?;
let codec = Self::from_key_material(value.trim()).map_err(|_| "SECRET_KEY_INVALID".to_string())?;
return Ok(SecretKeyResolution { codec, source: SecretKeySource::ExplicitEnv });
@@ -142,7 +160,9 @@ impl SecretCodec {
F: FnOnce(bool) -> Result<Option<SecretCodec>, String>,
{
let compatibility_error = if let Some(path) = compatibility_path.as_ref().filter(|path| path.exists()) {
match Self::read_key_file(path, false) {
// Another process may have just created this file and not finished
// writing it yet; retry briefly before treating it as corrupt.
match read_key_file_with_retry(path, false) {
Ok(codec) => return Ok(SecretKeyResolution { codec, source: SecretKeySource::ManagedDataDir }),
Err(error) => Some(error),
}
@@ -417,13 +437,9 @@ fn create_key_file(path: &std::path::Path, managed: bool) -> Result<SecretCodec,
}
match path.symlink_metadata() {
Ok(metadata) if managed && metadata.file_type().is_symlink() => Err("KEY_FILE_UNAVAILABLE".to_string()),
Ok(_) => {
if managed {
read_key_file_with_retry(path, managed)
} else {
SecretCodec::read_key_file(path, managed)
}
}
// The winner of the `create_new` race may still be mid-write, so read
// through the same retry loop the managed key path uses.
Ok(_) => read_key_file_with_retry(path, managed),
Err(_) => Err("KEY_FILE_UNAVAILABLE".to_string()),
}
}
@@ -433,16 +449,24 @@ pub fn managed_key_path(data_dir: &std::path::Path) -> std::path::PathBuf {
}
fn read_key_file_with_retry(path: &std::path::Path, reject_symlink: bool) -> Result<SecretCodec, String> {
let mut last_error = "KEY_FILE_UNAVAILABLE".to_string();
for attempt in 0..20 {
match SecretCodec::read_key_file(path, reject_symlink) {
Ok(codec) => return Ok(codec),
Err(error) if attempt < 19 && error == "SECRET_KEY_INVALID" && path.metadata().is_ok() => {
Err(error) => {
let retryable = attempt < 19 && error == "SECRET_KEY_INVALID" && path.metadata().is_ok();
last_error = error;
if !retryable {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
Err(error) => return Err(error),
}
}
Err("KEY_FILE_UNAVAILABLE".to_string())
// Surface what the file actually said: callers tell a corrupt key
// (SECRET_KEY_INVALID) apart from an unreadable one, and retrying must not
// blur that distinction once the retries are exhausted.
Err(last_error)
}
fn default_key_path() -> Option<std::path::PathBuf> {
@@ -470,7 +494,7 @@ fn aad(namespace: &str, key: &str) -> String {
#[cfg(test)]
mod tests {
use super::{managed_key_path, SecretCodec, SecretKeyPolicy, SecretKeySource};
use super::{managed_key_path, read_key_file_with_retry, SecretCodec, SecretKeyPolicy, SecretKeySource};
use base64::Engine as _;
use std::sync::{Mutex, OnceLock};
@@ -555,6 +579,46 @@ mod tests {
}
}
#[test]
fn compatibility_key_still_being_written_by_a_concurrent_starter_is_retried() {
// The shared compatibility path (`~/.config/dbx/secret.key`) is global, so
// two starters can race: the `create_new` winner leaves an empty file on
// disk for a moment. A reader that gives up immediately reports
// SECRET_KEY_INVALID for a key that is fine a millisecond later.
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("secret.key");
std::fs::write(&path, "").unwrap();
let writer_path = path.clone();
let writer = std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(10));
let material = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([7u8; 32]);
std::fs::write(&writer_path, material).unwrap();
});
// allow_create=false covers the read-only probe every write goes through;
// allow_create=true covers the losing side of the create_new race.
for allow_create in [false, true] {
let resolved =
SecretCodec::resolve_platform_default(Some(path.clone()), allow_create, |_| Ok(None)).unwrap();
assert_eq!(resolved.source, SecretKeySource::ManagedDataDir);
let expected = SecretCodec::new([7u8; 32]);
let envelope = expected.encrypt("n", "k", "value").unwrap();
assert_eq!(resolved.codec.decrypt("n", "k", &envelope).unwrap(), "value");
}
writer.join().unwrap();
}
#[test]
fn exhausted_key_file_retries_keep_the_original_error_code() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("secret.key");
std::fs::write(&path, "\n").unwrap();
// A key file that stays corrupt must not be laundered into the generic
// "unavailable" code: the migration preflight reports Invalid vs
// Unavailable differently.
assert!(matches!(read_key_file_with_retry(&path, false), Err(error) if error == "SECRET_KEY_INVALID"));
}
#[cfg(unix)]
#[test]
fn managed_key_symlink_is_rejected() {
@@ -576,39 +640,37 @@ mod tests {
#[test]
fn explicit_key_file_symlink_is_allowed() {
use std::os::unix::fs::symlink;
let _guard = env_lock().lock().unwrap();
let dir = tempfile::tempdir().unwrap();
let real = dir.path().join("real.key");
let link = dir.path().join("external.key");
std::fs::write(&real, base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([5u8; 32])).unwrap();
symlink(&real, &link).unwrap();
let previous_file = std::env::var_os("DBX_SECRET_KEY_FILE");
let previous_env = std::env::var_os("DBX_SECRET_KEY");
std::env::set_var("DBX_SECRET_KEY_FILE", &link);
std::env::remove_var("DBX_SECRET_KEY");
let resolved = SecretCodec::resolve(SecretKeyPolicy::ManagedDataDir, dir.path(), false).unwrap();
let resolved = SecretCodec::resolve_with_env(
SecretKeyPolicy::ManagedDataDir,
dir.path(),
false,
explicit_env(&[("DBX_SECRET_KEY_FILE", std::ffi::OsString::from(&link))]),
)
.unwrap();
assert_eq!(resolved.source, SecretKeySource::ExplicitFile);
restore_env("DBX_SECRET_KEY_FILE", previous_file);
restore_env("DBX_SECRET_KEY", previous_env);
}
#[cfg(unix)]
#[test]
fn invalid_non_utf8_explicit_environment_key_does_not_fallback() {
use std::os::unix::ffi::OsStringExt;
let _guard = env_lock().lock().unwrap();
let dir = tempfile::tempdir().unwrap();
let previous_file = std::env::var_os("DBX_SECRET_KEY_FILE");
let previous_env = std::env::var_os("DBX_SECRET_KEY");
std::env::remove_var("DBX_SECRET_KEY_FILE");
std::env::set_var("DBX_SECRET_KEY", std::ffi::OsString::from_vec(vec![0xff, 0xfe]));
let invalid = std::ffi::OsString::from_vec(vec![0xff, 0xfe]);
assert!(matches!(
SecretCodec::resolve(SecretKeyPolicy::ManagedDataDir, dir.path(), true),
SecretCodec::resolve_with_env(
SecretKeyPolicy::ManagedDataDir,
dir.path(),
true,
explicit_env(&[("DBX_SECRET_KEY", invalid)])
),
Err(error) if error == "SECRET_KEY_INVALID"
));
restore_env("DBX_SECRET_KEY_FILE", previous_file);
restore_env("DBX_SECRET_KEY", previous_env);
}
#[test]
@@ -673,40 +735,38 @@ mod tests {
#[test]
fn explicit_sources_follow_precedence_without_fallback() {
let _guard = env_lock().lock().unwrap();
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("external.key");
let previous_file = std::env::var_os("DBX_SECRET_KEY_FILE");
let previous_env = std::env::var("DBX_SECRET_KEY").ok();
std::env::remove_var("DBX_SECRET_KEY_FILE");
std::env::remove_var("DBX_SECRET_KEY");
SecretCodec::resolve(SecretKeyPolicy::ManagedDataDir, dir.path(), true).unwrap();
let env_key = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([2u8; 32]);
let resolve = |allow_create: bool, entries: &[(&str, std::ffi::OsString)]| {
let env = explicit_env(entries);
SecretCodec::resolve_with_env(SecretKeyPolicy::ManagedDataDir, dir.path(), allow_create, env)
};
// Nothing explicit is configured, so the managed data-dir key is provisioned.
resolve(true, &[]).unwrap();
// A corrupt explicit file outranks a valid explicit key and must not fall back.
std::fs::write(&file, "\n").unwrap();
std::env::set_var("DBX_SECRET_KEY_FILE", &file);
std::env::set_var("DBX_SECRET_KEY", base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([2u8; 32]));
assert!(matches!(
SecretCodec::resolve(SecretKeyPolicy::ManagedDataDir, dir.path(), false),
Err(error) if error == "SECRET_KEY_INVALID"
));
let both = [
("DBX_SECRET_KEY_FILE", std::ffi::OsString::from(&file)),
("DBX_SECRET_KEY", std::ffi::OsString::from(env_key.as_str())),
];
assert!(matches!(resolve(false, &both), Err(error) if error == "SECRET_KEY_INVALID"));
std::fs::write(&file, base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([1u8; 32])).unwrap();
let resolved = SecretCodec::resolve(SecretKeyPolicy::ManagedDataDir, dir.path(), false).unwrap();
assert_eq!(resolved.source, SecretKeySource::ExplicitFile);
assert_eq!(resolve(false, &both).unwrap().source, SecretKeySource::ExplicitFile);
std::env::remove_var("DBX_SECRET_KEY_FILE");
let resolved = SecretCodec::resolve(SecretKeyPolicy::ManagedDataDir, dir.path(), false).unwrap();
assert_eq!(resolved.source, SecretKeySource::ExplicitEnv);
restore_env("DBX_SECRET_KEY_FILE", previous_file);
restore_env("DBX_SECRET_KEY", previous_env.map(std::ffi::OsString::from));
let key_only = [("DBX_SECRET_KEY", std::ffi::OsString::from(env_key.as_str()))];
assert_eq!(resolve(false, &key_only).unwrap().source, SecretKeySource::ExplicitEnv);
}
fn restore_env(name: &str, value: Option<std::ffi::OsString>) {
if let Some(value) = value {
std::env::set_var(name, value);
} else {
std::env::remove_var(name);
}
/// Environment lookup for [`SecretCodec::resolve_with_env`] that never
/// touches the process environment, so these tests cannot change the key
/// another test in the same binary resolves while they run.
fn explicit_env<'a>(
entries: &'a [(&'a str, std::ffi::OsString)],
) -> impl Fn(&str) -> Option<std::ffi::OsString> + 'a {
move |name| entries.iter().find(|(key, _)| *key == name).map(|(_, value)| value.clone())
}
}
+121 -1
View File
@@ -25,7 +25,8 @@ use crate::connection_secrets::{
CASSANDRA_TRUSTSTORE_PASSWORD_KEY, MQTT_AUTH_PASSWORD_KEY, MQTT_AUTH_SECRET_PREFIX, MQ_AUTH_API_KEY_VALUE_KEY,
MQ_AUTH_CLIENT_SECRET_KEY, MQ_AUTH_PASSWORD_KEY, MQ_AUTH_SECRET_PREFIX, MQ_AUTH_TOKEN_KEY, MQ_TOKEN_SIGNING_KEY,
MQ_TOKEN_SIGNING_SECRET_PREFIX, NACOS_AUTH_PASSWORD_KEY, NACOS_AUTH_SECRET_PREFIX,
NACOS_RNACOS_CONSOLE_PASSWORD_KEY, PLUGIN_CONNECTION_SECRET_PREFIX,
NACOS_RNACOS_CONSOLE_PASSWORD_KEY, PLUGIN_CONNECTION_SECRET_PREFIX, SALESFORCE_AUTH_CLIENT_SECRET_KEY,
SALESFORCE_AUTH_PASSWORD_KEY, SALESFORCE_AUTH_REFRESH_TOKEN_KEY, SALESFORCE_AUTH_SECRET_PREFIX,
};
use crate::db::sqlite::{connect_path_create_if_missing, SqliteHandle};
use crate::history::{
@@ -471,6 +472,15 @@ pub struct McpConnectionPolicy {
/// connection default, while a present entry takes priority over it.
#[serde(default)]
pub database_policies: Vec<McpDatabasePolicy>,
/// Opt-in switch letting an AI agent write to this Salesforce org through
/// MCP. Absent on every policy saved before the switch existed, and `false`
/// by default: SOQL reads need no permission beyond the execution mode, but
/// Salesforce DML has no transaction and no rollback, so an agent may only
/// reach it after a person turns this on for the connection and confirms
/// each write (`dbx_salesforce_prepare_write` → `dbx_salesforce_apply_write`).
/// Forced back to false whenever `read_only` is set.
#[serde(default)]
pub allow_salesforce_dml: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -646,6 +656,9 @@ impl McpGlobalPolicy {
current.allowed_databases = databases;
current.database_policies =
merge_mcp_database_policies(&current.database_policies, &rule.database_policies);
// Same conjunction as high-risk SQL: every duplicate rule has
// to opt in before an agent may write to the org.
current.allow_salesforce_dml &= rule.allow_salesforce_dml;
})
.or_insert_with(|| McpConnectionPolicy {
connection_id: connection_id.to_string(),
@@ -656,6 +669,7 @@ impl McpGlobalPolicy {
database_scope: rule.database_scope,
allowed_databases: normalize_mcp_database_names(&rule.allowed_databases),
database_policies: normalize_mcp_database_policies(&rule.database_policies),
allow_salesforce_dml: rule.allow_salesforce_dml,
});
}
let mut connection_policies = policies.into_values().collect::<Vec<_>>();
@@ -663,6 +677,9 @@ impl McpGlobalPolicy {
for rule in &mut connection_policies {
if rule.read_only {
rule.allow_dangerous_sql = false;
// A read-only connection cannot carry a Salesforce write opt-in,
// however the saved rules were merged.
rule.allow_salesforce_dml = false;
}
rule.allowed_databases = normalize_mcp_database_names(&rule.allowed_databases);
if rule.database_scope != McpDatabaseScope::Selected {
@@ -2947,6 +2964,18 @@ fn scrub_cassandra_tls_secrets(config: &mut ConnectionConfig) {
scrub_json_secret(tls, "keystore_password");
}
fn scrub_salesforce_auth_secrets(config: &mut ConnectionConfig) {
if config.db_type != DatabaseType::Salesforce {
return;
}
let Some(auth) = salesforce_auth_object_mut(config.external_config.as_mut()) else {
return;
};
scrub_json_secret(auth, "clientSecret");
scrub_json_secret(auth, "refreshToken");
scrub_json_secret(auth, "password");
}
fn delete_secret_prefix_in_tx(
tx: &rusqlite::Transaction<'_>,
connection_id: &str,
@@ -5209,6 +5238,7 @@ fn sanitized_connection_config(config: &ConnectionConfig) -> ConnectionConfig {
scrub_mq_token_signing_secret(&mut sanitized);
scrub_nacos_auth_secrets(&mut sanitized);
scrub_cassandra_tls_secrets(&mut sanitized);
scrub_salesforce_auth_secrets(&mut sanitized);
sanitized.connection_secrets.clear();
sanitized
}
@@ -5306,6 +5336,7 @@ fn persist_connection_in_tx(
persist_mq_token_signing_secret_in_tx(tx, codec, &config)?;
persist_nacos_auth_secrets_in_tx(tx, codec, &config)?;
persist_cassandra_tls_secrets_in_tx(tx, codec, &config)?;
persist_salesforce_auth_secrets_in_tx(tx, codec, &config)?;
delete_secret_prefix_in_tx(tx, &config.id, PLUGIN_CONNECTION_SECRET_PREFIX)?;
for (key, secret) in &config.connection_secrets {
if !key.is_empty() {
@@ -5576,6 +5607,7 @@ impl Storage {
scrub_mq_token_signing_secret(&mut sanitized);
scrub_nacos_auth_secrets(&mut sanitized);
scrub_cassandra_tls_secrets(&mut sanitized);
scrub_salesforce_auth_secrets(&mut sanitized);
sanitized.connection_secrets.clear();
let json = serde_json::to_string(&sanitized).map_err(|e| e.to_string())?;
@@ -5964,6 +5996,7 @@ impl Storage {
let needs_mq_token_signing_rewrite = self.hydrate_mq_token_signing_secret(&id, &mut config).await?;
let needs_nacos_auth_rewrite = self.hydrate_nacos_auth_secret(&id, &mut config).await?;
let needs_cassandra_tls_rewrite = self.hydrate_cassandra_tls_secrets(&id, &mut config).await?;
let needs_salesforce_auth_rewrite = self.hydrate_salesforce_auth_secrets(&id, &mut config).await?;
let mut needs_plugin_secret_rewrite = false;
let plugin_secret_keys = config.connection_secrets.keys().cloned().collect::<Vec<_>>();
for key in plugin_secret_keys {
@@ -5983,6 +6016,7 @@ impl Storage {
|| needs_mq_token_signing_rewrite
|| needs_nacos_auth_rewrite
|| needs_cassandra_tls_rewrite
|| needs_salesforce_auth_rewrite
|| needs_plugin_secret_rewrite;
if needs_external_secret_rewrite {
// `config` is hydrated above, so always use the canonical
@@ -6140,6 +6174,28 @@ impl Storage {
.await?;
Ok(truststore_rewrite || keystore_rewrite)
}
async fn hydrate_salesforce_auth_secrets(
&self,
connection_id: &str,
config: &mut ConnectionConfig,
) -> Result<bool, String> {
if config.db_type != DatabaseType::Salesforce {
return Ok(false);
}
let Some(auth) = salesforce_auth_object_mut(config.external_config.as_mut()) else {
return Ok(false);
};
let client_secret_rewrite =
hydrate_mq_json_secret(self, connection_id, SALESFORCE_AUTH_CLIENT_SECRET_KEY, auth, "clientSecret")
.await?;
let refresh_token_rewrite =
hydrate_mq_json_secret(self, connection_id, SALESFORCE_AUTH_REFRESH_TOKEN_KEY, auth, "refreshToken")
.await?;
let password_rewrite =
hydrate_mq_json_secret(self, connection_id, SALESFORCE_AUTH_PASSWORD_KEY, auth, "password").await?;
Ok(client_secret_rewrite || refresh_token_rewrite || password_rewrite)
}
}
// Saved SQL
@@ -7862,6 +7918,60 @@ fn persist_cassandra_tls_secrets_in_tx(
)
}
fn persist_salesforce_auth_secrets_in_tx(
tx: &rusqlite::Transaction<'_>,
codec: &SecretCodec,
config: &ConnectionConfig,
) -> Result<(), String> {
if config.db_type != DatabaseType::Salesforce {
delete_secret_prefix_in_tx(tx, &config.id, SALESFORCE_AUTH_SECRET_PREFIX)?;
return Ok(());
}
let Some(auth) = salesforce_auth_object(config.external_config.as_ref()) else {
delete_secret_prefix_in_tx(tx, &config.id, SALESFORCE_AUTH_SECRET_PREFIX)?;
return Ok(());
};
replace_salesforce_auth_secret_in_tx(
tx,
codec,
&config.id,
SALESFORCE_AUTH_CLIENT_SECRET_KEY,
auth,
"clientSecret",
)?;
replace_salesforce_auth_secret_in_tx(
tx,
codec,
&config.id,
SALESFORCE_AUTH_REFRESH_TOKEN_KEY,
auth,
"refreshToken",
)?;
replace_salesforce_auth_secret_in_tx(tx, codec, &config.id, SALESFORCE_AUTH_PASSWORD_KEY, auth, "password")?;
Ok(())
}
fn replace_salesforce_auth_secret_in_tx(
tx: &rusqlite::Transaction<'_>,
codec: &SecretCodec,
connection_id: &str,
key: &str,
auth: &serde_json::Map<String, serde_json::Value>,
field: &str,
) -> Result<(), String> {
let current = auth.get(field).and_then(serde_json::Value::as_str).filter(|secret| !secret.is_empty());
let existing = if current.is_none() { get_secret_in_tx(tx, codec, connection_id, key)? } else { None };
tx.execute("DELETE FROM connection_secrets WHERE connection_id = ?1 AND key = ?2", params![connection_id, key])
.map_err(|e| e.to_string())?;
match current {
Some(secret) => persist_secret_in_tx(tx, codec, connection_id, key, secret),
None => match existing {
Some(secret) => persist_secret_in_tx(tx, codec, connection_id, key, &secret),
None => Ok(()),
},
}
}
fn persist_json_secret_if_present_in_tx(
tx: &rusqlite::Transaction<'_>,
codec: &SecretCodec,
@@ -7934,6 +8044,16 @@ fn cassandra_tls_object_mut(
value?.get_mut("tls")?.as_object_mut()
}
fn salesforce_auth_object(value: Option<&serde_json::Value>) -> Option<&serde_json::Map<String, serde_json::Value>> {
value?.get("auth")?.as_object()
}
fn salesforce_auth_object_mut(
value: Option<&mut serde_json::Value>,
) -> Option<&mut serde_json::Map<String, serde_json::Value>> {
value?.get_mut("auth")?.as_object_mut()
}
fn nacos_auth_object(value: Option<&serde_json::Value>) -> Option<&serde_json::Map<String, serde_json::Value>> {
value?.get("auth")?.as_object()
}
+19
View File
@@ -2078,6 +2078,23 @@ async fn do_execute_typed(
}
result
}
PoolKind::Salesforce(client) => {
let client = client.clone();
let sql = sql.to_string();
let max_rows = options.max_rows;
let result = wait_for_query_opt(cancel_token, query_timeout, async move {
if let Some(cursor) = options.result_session_id.as_deref() {
client.fetch_more(cursor).await
} else {
client.execute_query(&sql, max_rows).await
}
})
.await;
if matches!(result.as_ref(), Err(err) if should_discard_pool_after_error(pool_db_type, err)) {
state.remove_pool_by_key(pool_key).await;
}
result
}
PoolKind::VectorDb(client) => {
let client = client.clone();
let sql = sql.to_string();
@@ -4704,6 +4721,7 @@ fn pool_kind_has_transactional_path(pool: &PoolKind) -> bool {
| PoolKind::Easysearch(_)
| PoolKind::Solr(_)
| PoolKind::Meilisearch(_)
| PoolKind::Salesforce(_)
| PoolKind::VectorDb(_)
| PoolKind::InfluxDb(_)
| PoolKind::InfluxDb3(_)
@@ -5108,6 +5126,7 @@ fn batch_transaction_path(pool: &PoolKind) -> BatchTransactionPath {
| PoolKind::Easysearch(_)
| PoolKind::Solr(_)
| PoolKind::Meilisearch(_)
| PoolKind::Salesforce(_)
| PoolKind::VectorDb(_)
| PoolKind::InfluxDb(_)
| PoolKind::InfluxDb3(_)
+13
View File
@@ -676,6 +676,12 @@ async fn list_databases_once(state: &AppState, connection_id: &str) -> Result<Ve
if let Some(client) = extract_pool!(pool_handle.as_ref(), VictoriaMetrics) {
return db::victoriametrics_driver::list_databases(&client).await;
}
if let Some(client) = extract_pool!(pool_handle.as_ref(), Salesforce) {
// singleDatabase trait: the whole org is one synthesized database
// node; sObjects are listed as its tables.
let name = client.org_display_name().await;
return Ok(vec![db::DatabaseInfo { name, ..Default::default() }]);
}
try_sqlserver!(pool_handle, list_databases);
if let Some(client) = extract_pool!(pool_handle.as_ref(), Agent) {
let is_mongo = db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::MongoDb);
@@ -2683,6 +2689,10 @@ async fn list_tables_once(
.await
.map(|names| collection_names_to_tables(names, "INDEX"))
.map(|tables| filter_table_infos(tables, filter, limit, offset, object_types, table_name_filter)),
PoolKind::Salesforce(client) => db::salesforce_driver::SfClient::list_tables(client)
.await
.map(|names| collection_names_to_tables(names, "SOBJECT"))
.map(|tables| filter_table_infos(tables, filter, limit, offset, object_types, table_name_filter)),
PoolKind::HBase(client) => db::hbase_driver::list_tables(client, database)
.await
.map(|tables| filter_table_infos(tables, filter, limit, offset, object_types, table_name_filter)),
@@ -7456,6 +7466,9 @@ async fn get_columns_core_for_session_inner_with_pool(
PoolKind::Meilisearch(client) => {
db::meilisearch_driver::get_columns(client, table).await.map(deduplicate_column_infos)
}
PoolKind::Salesforce(client) => {
db::salesforce_driver::SfClient::get_columns(client, table).await.map(deduplicate_column_infos)
}
PoolKind::HBase(client) => {
db::hbase_driver::get_columns(client, database, table).await.map(deduplicate_column_infos)
}
@@ -364,6 +364,18 @@ fn driver_manifest_declares_expected_product_capabilities() {
assert!(!uxdb.capabilities.data_transfer);
assert!(!uxdb.capabilities.user_admin);
let salesforce = find_driver(DatabaseType::Salesforce);
assert_eq!(salesforce.label, "Salesforce");
assert_eq!(salesforce.support_level, "operate");
assert!(salesforce.capabilities.query_execution);
assert!(salesforce.capabilities.metadata_browse);
assert!(salesforce.capabilities.object_browser);
assert!(salesforce.capabilities.schema_search);
assert!(salesforce.capabilities.table_data_edit);
assert!(!salesforce.capabilities.table_structure_edit);
assert!(!salesforce.capabilities.object_source);
assert!(!salesforce.capabilities.diagram);
let spanner = find_driver(DatabaseType::Spanner);
assert_eq!(spanner.label, "Google Cloud Spanner");
assert_eq!(spanner.runtime_mode, "agent");
+1
View File
@@ -39,6 +39,7 @@ pub mod proxy_tunnel;
pub mod questdb;
pub mod redis_driver;
pub mod rqlite_driver;
pub mod salesforce_driver;
pub mod solr_driver;
pub mod sqlite;
pub mod sqlite_worker;
File diff suppressed because it is too large. Load diff
+1
View File
@@ -22,4 +22,5 @@ pub mod metadata;
pub mod mongo_oidc;
pub mod mongo_shell;
pub mod runtime_config;
pub mod salesforce_oauth;
pub mod ssh_config;
File diff suppressed because it is too large. Load diff
+67 -1
View File
@@ -8,7 +8,7 @@ use async_trait::async_trait;
use dbx_core::{
agent_events::{ToolCall, ToolResult},
agent_tools::{self, format_query_result_as_text, AgentSqlPermissions, QueryCellWindow},
connection::{connection_configs_pool_equivalent, AppState, ConnectionLifecycleSnapshot},
connection::{connection_configs_pool_equivalent, AppState, ConnectionLifecycleSnapshot, SalesforceCurrentUser},
db::{mongo_driver::MongoIndexSpec, redis_driver::RedisCommandResult, ColumnInfo, TableInfo},
history::HistoryEntry,
mcp_policy::{connection_group_paths, McpConnectionGroupPath},
@@ -87,6 +87,9 @@ fn effective_mcp_policy_with_legacy_allow_writes(
for rule in &mut policy.connection_policies {
rule.read_only = true;
rule.allow_dangerous_sql = false;
// An unconfirmed CLI run must not reach Salesforce DML either: the
// connection opt-in is a write permission like any other here.
rule.allow_salesforce_dml = false;
rule.execution_mode_configured = true;
rule.execution_mode_policy_version = Some(dbx_core::mcp_policy::MCP_EXECUTION_POLICY_VERSION);
for database_policy in &mut rule.database_policies {
@@ -390,6 +393,14 @@ pub trait DbxBackend: Send + Sync {
let _ = (connection, database, command);
Err("MongoDB shell commands are not supported by this backend.".to_string())
}
/// Connected-user identity for a Salesforce connection: who a write would be
/// attributed to, plus the profile's "Modify All Data" flag. Salesforce has
/// no session-scoped identity, so this reads the pool's cached user info and
/// creates the pool when the MCP process is still cold.
async fn salesforce_current_user(&self, connection: &ConnectionConfig) -> Result<SalesforceCurrentUser, String> {
let _ = connection;
Err("Salesforce identity is not supported by this backend.".to_string())
}
/// Release the connection pool pinned by an MCP session (`client_session_id`).
async fn close_client_session(
&self,
@@ -1253,6 +1264,19 @@ impl DbxBackend for LocalBackend {
dbx_core::mongo_ops::execute_mongo_command_core(&self.state, &connection.id, database, command, 100).await
}
async fn salesforce_current_user(&self, connection: &ConnectionConfig) -> Result<SalesforceCurrentUser, String> {
if connection.db_type != DatabaseType::Salesforce {
return Err("Not a Salesforce connection".to_string());
}
// The identity is the pool's cached connected-user record, so a cold MCP
// process has to establish the connection first — the same thing the
// metadata paths above do before reading pool state.
if self.state.pool_handle(&connection.id).await.is_none() {
self.state.get_or_create_pool(&connection.id, None).await?;
}
self.state.salesforce_current_user(&connection.id).await
}
async fn close_client_session(
&self,
connection_id: &str,
@@ -1838,6 +1862,19 @@ impl DbxBackend for WebBackend {
.map_err(|error| format!("Invalid Redis command response: {error}"))
}
async fn salesforce_current_user(&self, connection: &ConnectionConfig) -> Result<SalesforceCurrentUser, String> {
self.ensure_connected(connection).await?;
self.request(
reqwest::Method::GET,
&format!("/api/salesforce/current-user?connection_id={}", url_encode(&connection.id)),
None,
)
.await?
.json()
.await
.map_err(|error| format!("Invalid Salesforce identity response: {error}"))
}
async fn execute_mongo_command(
&self,
connection: &ConnectionConfig,
@@ -2684,6 +2721,35 @@ mod tests {
assert!(effective_mcp_policy_with_legacy_allow_writes(policy_state(true, true), None).read_only);
}
#[test]
fn legacy_read_only_also_revokes_the_salesforce_dml_opt_in() {
// DBX_MCP_ALLOW_WRITES=0 marks an unconfirmed CLI run. The Salesforce DML
// opt-in is a write permission like any other, so it must be withdrawn
// with the rest — an opted-in connection must not become writable just
// because the org speaks REST instead of SQL.
let mut state = policy_state(true, false);
state.connection_policies = vec![dbx_core::storage::McpConnectionPolicy {
connection_id: "sfdc".to_string(),
read_only: false,
allow_dangerous_sql: true,
execution_mode_configured: false,
execution_mode_policy_version: None,
database_scope: dbx_core::storage::McpDatabaseScope::All,
allowed_databases: Vec::new(),
database_policies: Vec::new(),
allow_salesforce_dml: true,
}];
let forced = effective_mcp_policy_with_legacy_allow_writes(state.clone(), Some(false));
assert!(forced.read_only);
assert!(!forced.connection_policies[0].allow_salesforce_dml);
assert!(!forced.connection_policies[0].allow_dangerous_sql);
// Without the env override the stored opt-in survives untouched.
let untouched = effective_mcp_policy_with_legacy_allow_writes(state, None);
assert!(untouched.connection_policies[0].allow_salesforce_dml);
}
#[test]
fn parses_database_type_using_dbx_protocol_names() {
assert_eq!(parse_database_type("Postgres").unwrap(), DatabaseType::Postgres);
File diff suppressed because it is too large. Load diff
@@ -57,6 +57,7 @@ async fn list_databases(
database_scope,
allowed_databases,
database_policies: Vec::new(),
allow_salesforce_dml: false,
}],
..Default::default()
})
+5 -2
View File
@@ -328,9 +328,9 @@ async fn initializes_lists_tools_and_calls_a_tool() {
let tools = client.peer().list_tools(None).await.expect("list tools");
let names = tools.tools.iter().map(|tool| tool.name.as_ref()).collect::<Vec<_>>();
#[cfg(feature = "mq-admin")]
assert_eq!(names.len(), 22);
assert_eq!(names.len(), 25);
#[cfg(not(feature = "mq-admin"))]
assert_eq!(names.len(), 20);
assert_eq!(names.len(), 23);
#[cfg(feature = "mq-admin")]
assert!(names.contains(&"dbx_peek_messages"));
#[cfg(not(feature = "mq-admin"))]
@@ -339,6 +339,9 @@ async fn initializes_lists_tools_and_calls_a_tool() {
assert!(names.contains(&"dbx_list_databases"));
assert!(names.contains(&"dbx_duplicate_connection"));
assert!(names.contains(&"dbx_execute_redis_command"));
assert!(names.contains(&"dbx_salesforce_current_user"));
assert!(names.contains(&"dbx_salesforce_prepare_write"));
assert!(names.contains(&"dbx_salesforce_apply_write"));
assert!(names.contains(&"dbx_execute_and_show"));
assert!(names.contains(&"dbx_execute_batch"));
assert!(names.contains(&"dbx_list_routines"));
@@ -0,0 +1,459 @@
use serde_json::{Map, Value};
use super::*;
use crate::models::connection::DatabaseType;
/// Header line emitted before every JSON body. The driver's
/// `parse_salesforce_statement` matches this case-insensitively.
const SALESFORCE_DML_HEADER: &str = "DBX SALESFORCE DML";
/// Build the save statements for a Salesforce grid save. Order matches the
/// generic SQL builder: updates, deletes, inserts (see `build_data_grid_save_statements`).
///
/// Each statement is a `DBX SALESFORCE DML` pseudo-command carrying a JSON
/// body. The driver routes them to the appropriate REST endpoint.
pub(super) fn build_salesforce_data_grid_save_statements(options: &DataGridSaveStatementOptions) -> Vec<String> {
let object = &options.table_meta.table_name;
let columns = resolve_salesforce_columns(options);
let column_info = options.table_meta.columns.as_deref().unwrap_or(&[]);
let id_column_index = find_salesforce_id_column_index(&columns, &options.table_meta.primary_keys);
let mut statements = Vec::new();
// 1. Updates (dirty_rows): one statement per dirty row, fields = only the changed columns.
for (row_index, changes) in &options.dirty_rows {
let Some(row) = options.rows.get(*row_index) else { continue };
let Some(id_index) = id_column_index else { continue };
let Some(id_value) = row.get(id_index).and_then(Value::as_str).filter(|s| !s.is_empty()) else {
continue;
};
let mut fields = Map::new();
for (column_index, value) in changes {
let Some(field_name) = columns.get(*column_index).and_then(|c| c.as_deref()) else { continue };
// Id is the identity, never a write target.
if field_name.eq_ignore_ascii_case("Id") {
continue;
}
// Skip columns marked non-updateable in the describe metadata.
if !salesforce_column_is_updateable(column_info_for(column_info, field_name)) {
continue;
}
fields.insert(field_name.to_string(), value.clone());
}
if fields.is_empty() {
continue;
}
let body = serde_json::json!({
"op": "update",
"object": object,
"id": id_value,
"fields": fields
});
statements.push(format!("{SALESFORCE_DML_HEADER}\n{body}"));
}
// 2. Deletes (deleted_rows): one statement per deleted row index.
for row_index in &options.deleted_rows {
let Some(row) = options.rows.get(*row_index) else { continue };
let Some(id_index) = id_column_index else { continue };
let Some(id_value) = row.get(id_index).and_then(Value::as_str).filter(|s| !s.is_empty()) else {
continue;
};
let body = serde_json::json!({
"op": "delete",
"object": object,
"id": id_value
});
statements.push(format!("{SALESFORCE_DML_HEADER}\n{body}"));
}
// 3. Inserts (new_rows): one statement per new row, skipping null and empty-string cells.
for row in &options.new_rows {
let mut fields = Map::new();
for (index, field_name) in columns.iter().enumerate() {
let Some(field_name) = field_name.as_deref() else { continue };
// Id is server-generated for inserts; never send it.
if field_name.eq_ignore_ascii_case("Id") {
continue;
}
// Skip columns marked non-createable in the describe metadata.
if !salesforce_column_is_createable(column_info_for(column_info, field_name)) {
continue;
}
let value = row.get(index).unwrap_or(&Value::Null);
// Untouched cells must not be sent.
if value.is_null() {
continue;
}
if value.is_string() && value.as_str().unwrap_or("").is_empty() {
continue;
}
fields.insert(field_name.to_string(), value.clone());
}
if fields.is_empty() {
continue;
}
let body = serde_json::json!({
"op": "insert",
"object": object,
"fields": fields
});
statements.push(format!("{SALESFORCE_DML_HEADER}\n{body}"));
}
statements
}
/// Salesforce REST has no transactions — rollback statements are always empty.
pub(super) fn build_salesforce_data_grid_rollback_statements(_options: &DataGridSaveStatementOptions) -> Vec<String> {
Vec::new()
}
/// Validate that the Salesforce sObject has a resolvable Id column. Called
/// from `validate_data_grid_save` before the builder runs.
pub(super) fn validate_salesforce_id_column(options: &DataGridSaveStatementOptions) -> Option<String> {
if options.database_type != Some(DatabaseType::Salesforce) {
return None;
}
// Only validate when there are updates, deletes, or inserts that would
// need an Id (updates/deletes need it from existing rows; inserts don't
// strictly need it, but the grid always has an Id column from the query).
if options.dirty_rows.is_empty() && options.deleted_rows.is_empty() && options.new_rows.is_empty() {
return None;
}
let columns = resolve_salesforce_columns(options);
if find_salesforce_id_column_index(&columns, &options.table_meta.primary_keys).is_none() {
return Some(format!(
"Cannot save Salesforce {}: no Id column found. The grid must include the record Id to enable editing.",
options.table_meta.table_name
));
}
None
}
/// Resolve the field name for each display column: `source_columns[i]` when
/// present, else `columns[i]`.
fn resolve_salesforce_columns(options: &DataGridSaveStatementOptions) -> Vec<Option<String>> {
match &options.source_columns {
Some(source_columns) if source_columns.len() == options.columns.len() => source_columns.clone(),
_ => options.columns.iter().map(|column| Some(column.clone())).collect(),
}
}
/// Locate the Id column by matching `table_meta.primary_keys` (case-insensitive)
/// against the resolved field names.
fn find_salesforce_id_column_index(columns: &[Option<String>], primary_keys: &[String]) -> Option<usize> {
// Try primary keys first (case-insensitive).
for pk in primary_keys {
let pk_upper = pk.to_ascii_uppercase();
if let Some(index) =
columns.iter().position(|col| col.as_deref().is_some_and(|name| name.to_ascii_uppercase() == pk_upper))
{
return Some(index);
}
}
// Fallback: look for a column literally named "Id" (case-insensitive).
columns.iter().position(|col| col.as_deref().is_some_and(|name| name.eq_ignore_ascii_case("Id")))
}
/// Whether a column should be included in update payloads. Returns `true`
/// unless the column's `extra` metadata explicitly marks `updateable: false`.
/// Missing or unparseable `extra` → treat as writable (fail-open; Salesforce
/// enforces the real rules server-side).
fn salesforce_column_is_updateable(info: Option<&DataGridColumnInfo>) -> bool {
salesforce_extra_flag(info, "updateable").unwrap_or(true)
}
/// Whether a column should be included in insert payloads. Returns `true`
/// unless the column's `extra` metadata explicitly marks `createable: false`.
fn salesforce_column_is_createable(info: Option<&DataGridColumnInfo>) -> bool {
salesforce_extra_flag(info, "createable").unwrap_or(true)
}
/// Read a boolean flag from the `extra` JSON string on a `DataGridColumnInfo`.
/// Returns `None` when `extra` is absent, unparseable, or lacks the key.
fn salesforce_extra_flag(info: Option<&DataGridColumnInfo>, key: &str) -> Option<bool> {
let extra_str = info?.extra.as_deref()?;
let extra: Value = serde_json::from_str(extra_str).ok()?;
extra.get(key).and_then(Value::as_bool)
}
#[cfg(test)]
mod tests {
use super::*;
fn make_options(
columns: Vec<&str>,
primary_keys: Vec<&str>,
dirty_rows: Vec<(usize, Vec<(usize, Value)>)>,
deleted_rows: Vec<usize>,
new_rows: Vec<Vec<Value>>,
rows: Vec<Vec<Value>>,
) -> DataGridSaveStatementOptions {
DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Salesforce),
identifier_quote: None,
table_meta: DataGridTableMeta {
database: None,
catalog: None,
schema: None,
table_name: "Account".to_string(),
primary_keys: primary_keys.into_iter().map(str::to_string).collect(),
columns: None,
},
columns: columns.into_iter().map(str::to_string).collect(),
source_columns: None,
rows,
dirty_rows,
deleted_rows,
new_rows,
include_database_name: false,
}
}
fn make_options_with_extra(
columns: Vec<&str>,
primary_keys: Vec<&str>,
extras: Vec<Option<&str>>,
) -> DataGridSaveStatementOptions {
let cols: Vec<DataGridColumnInfo> = columns
.iter()
.zip(extras.iter())
.map(|(name, extra)| DataGridColumnInfo {
name: name.to_string(),
data_type: String::new(),
is_nullable: true,
is_primary_key: false,
column_default: None,
extra: extra.map(|s| s.to_string()),
})
.collect();
DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Salesforce),
identifier_quote: None,
table_meta: DataGridTableMeta {
database: None,
catalog: None,
schema: None,
table_name: "Account".to_string(),
primary_keys: primary_keys.into_iter().map(str::to_string).collect(),
columns: Some(cols),
},
columns: columns.into_iter().map(str::to_string).collect(),
source_columns: None,
rows: Vec::new(),
dirty_rows: Vec::new(),
deleted_rows: Vec::new(),
new_rows: Vec::new(),
include_database_name: false,
}
}
#[test]
fn update_generates_dml_statement() {
let opts = make_options(
vec!["Id", "Name", "Industry"],
vec!["Id"],
vec![(0, vec![(1, Value::String("Acme".to_string()))])],
vec![],
vec![],
vec![vec![
Value::String("001xx000003DGbY".to_string()),
Value::String("Old Name".to_string()),
Value::String("Tech".to_string()),
]],
);
let stmts = build_salesforce_data_grid_save_statements(&opts);
assert_eq!(stmts.len(), 1);
assert!(stmts[0].starts_with("DBX SALESFORCE DML\n"));
let body: Value = serde_json::from_str(stmts[0].split_once('\n').unwrap().1).unwrap();
assert_eq!(body["op"], "update");
assert_eq!(body["object"], "Account");
assert_eq!(body["id"], "001xx000003DGbY");
assert_eq!(body["fields"]["Name"], "Acme");
// Industry was not changed → not in fields.
assert!(body["fields"].get("Industry").is_none());
// Id is never in fields.
assert!(body["fields"].get("Id").is_none());
}
#[test]
fn delete_generates_dml_statement() {
let opts = make_options(
vec!["Id", "Name"],
vec!["Id"],
vec![],
vec![0],
vec![],
vec![vec![Value::String("001xx000003DGbY".to_string()), Value::String("Acme".to_string())]],
);
let stmts = build_salesforce_data_grid_save_statements(&opts);
assert_eq!(stmts.len(), 1);
let body: Value = serde_json::from_str(stmts[0].split_once('\n').unwrap().1).unwrap();
assert_eq!(body["op"], "delete");
assert_eq!(body["id"], "001xx000003DGbY");
}
#[test]
fn insert_generates_dml_statement_skipping_nulls_and_empties() {
let opts = make_options(
vec!["Id", "Name", "Phone", "Website"],
vec!["Id"],
vec![],
vec![],
vec![vec![
Value::Null,
Value::String("Acme".to_string()),
Value::String("".to_string()), // empty → skip
Value::Null, // null → skip
]],
vec![],
);
let stmts = build_salesforce_data_grid_save_statements(&opts);
assert_eq!(stmts.len(), 1);
let body: Value = serde_json::from_str(stmts[0].split_once('\n').unwrap().1).unwrap();
assert_eq!(body["op"], "insert");
assert_eq!(body["object"], "Account");
assert_eq!(body["fields"]["Name"], "Acme");
// Id, Phone, Website excluded.
assert!(body["fields"].get("Id").is_none());
assert!(body["fields"].get("Phone").is_none());
assert!(body["fields"].get("Website").is_none());
}
#[test]
fn id_excluded_from_update_fields() {
// Even if Id appears in the changes, it must not be in the fields payload.
let opts = make_options(
vec!["Id", "Name"],
vec!["Id"],
vec![(0, vec![(0, Value::String("new-id".to_string())), (1, Value::String("Acme".to_string()))])],
vec![],
vec![],
vec![vec![Value::String("001xx000003DGbY".to_string()), Value::String("Old".to_string())]],
);
let stmts = build_salesforce_data_grid_save_statements(&opts);
assert_eq!(stmts.len(), 1);
let body: Value = serde_json::from_str(stmts[0].split_once('\n').unwrap().1).unwrap();
assert!(body["fields"].get("Id").is_none());
assert_eq!(body["fields"]["Name"], "Acme");
}
#[test]
fn updateable_false_columns_skipped_on_update() {
let opts = make_options_with_extra(
vec!["Id", "Name", "SystemField"],
vec!["Id"],
vec![
None,
Some(r#"{"updateable":true,"createable":true}"#),
Some(r#"{"updateable":false,"createable":true}"#),
],
);
let mut opts = opts;
opts.rows = vec![vec![
Value::String("001xx000003DGbY".to_string()),
Value::String("Old".to_string()),
Value::String("system".to_string()),
]];
opts.dirty_rows =
vec![(0, vec![(1, Value::String("New".to_string())), (2, Value::String("changed".to_string()))])];
let stmts = build_salesforce_data_grid_save_statements(&opts);
assert_eq!(stmts.len(), 1);
let body: Value = serde_json::from_str(stmts[0].split_once('\n').unwrap().1).unwrap();
assert_eq!(body["fields"]["Name"], "New");
assert!(body["fields"].get("SystemField").is_none());
}
#[test]
fn createable_false_columns_skipped_on_insert() {
let opts = make_options_with_extra(
vec!["Id", "Name", "AutoField"],
vec!["Id"],
vec![
None,
Some(r#"{"updateable":true,"createable":true}"#),
Some(r#"{"updateable":true,"createable":false}"#),
],
);
let mut opts = opts;
opts.new_rows = vec![vec![Value::Null, Value::String("Acme".to_string()), Value::String("auto".to_string())]];
let stmts = build_salesforce_data_grid_save_statements(&opts);
assert_eq!(stmts.len(), 1);
let body: Value = serde_json::from_str(stmts[0].split_once('\n').unwrap().1).unwrap();
assert_eq!(body["fields"]["Name"], "Acme");
assert!(body["fields"].get("AutoField").is_none());
}
#[test]
fn missing_id_column_produces_validation_error() {
let opts = make_options(
vec!["Name", "Industry"],
vec![], // no primary keys
vec![(0, vec![(0, Value::String("Acme".to_string()))])],
vec![],
vec![],
vec![vec![Value::String("Old".to_string()), Value::String("Tech".to_string())]],
);
let error = validate_salesforce_id_column(&opts);
assert!(error.is_some());
assert!(error.unwrap().contains("no Id column found"));
}
#[test]
fn rollback_statements_are_empty() {
let opts = make_options(vec!["Id", "Name"], vec!["Id"], vec![], vec![], vec![], vec![]);
assert!(build_salesforce_data_grid_rollback_statements(&opts).is_empty());
}
#[test]
fn statement_order_is_updates_deletes_inserts() {
let opts = make_options(
vec!["Id", "Name"],
vec!["Id"],
vec![(0, vec![(1, Value::String("Updated".to_string()))])],
vec![1],
vec![vec![Value::Null, Value::String("New".to_string())]],
vec![
vec![Value::String("id1".to_string()), Value::String("Row1".to_string())],
vec![Value::String("id2".to_string()), Value::String("Row2".to_string())],
],
);
let stmts = build_salesforce_data_grid_save_statements(&opts);
assert_eq!(stmts.len(), 3);
let ops: Vec<&str> = stmts
.iter()
.map(|s| {
let body: Value = serde_json::from_str(s.split_once('\n').unwrap().1).unwrap();
// Leak to get a &'static str — fine in tests.
Box::leak(body["op"].as_str().unwrap().to_string().into_boxed_str()) as &str
})
.collect();
assert_eq!(ops, vec!["update", "delete", "insert"]);
}
#[test]
fn empty_change_set_after_filtering_skips_row() {
let opts = make_options(
vec!["Id", "Name"],
vec!["Id"],
// Only change is to Id itself → filtered out → empty fields → skip.
vec![(0, vec![(0, Value::String("new-id".to_string()))])],
vec![],
vec![],
vec![vec![Value::String("001xx000003DGbY".to_string()), Value::String("Old".to_string())]],
);
let stmts = build_salesforce_data_grid_save_statements(&opts);
assert!(stmts.is_empty());
}
#[test]
fn non_salesforce_database_type_returns_no_validation_error() {
let mut opts = make_options(vec!["Id"], vec!["Id"], vec![], vec![], vec![], vec![]);
opts.database_type = Some(DatabaseType::Postgres);
assert!(validate_salesforce_id_column(&opts).is_none());
}
}
+16
View File
@@ -21,6 +21,13 @@ use data_grid_tdengine_sql::{
validate_tdengine_existing_rows, validate_tdengine_inserted_rows,
};
#[path = "data_grid_salesforce_sql.rs"]
mod data_grid_salesforce_sql;
use data_grid_salesforce_sql::{
build_salesforce_data_grid_rollback_statements, build_salesforce_data_grid_save_statements,
validate_salesforce_id_column,
};
use crate::models::connection::DatabaseType;
use crate::sql_dialect::{
firebird_rows_clause, quote_table_identifier, table_pagination_strategy, uses_oracle_row_id,
@@ -1195,6 +1202,9 @@ fn build_neo4j_data_grid_column_distinct_values_sql(options: &DataGridColumnDist
}
fn validate_data_grid_save(options: &DataGridSaveStatementOptions) -> Option<String> {
if let Some(error) = validate_salesforce_id_column(options) {
return Some(error);
}
if let Some(error) = validate_iotdb_existing_rows(options) {
return Some(error);
}
@@ -1461,6 +1471,9 @@ fn build_data_grid_save_statements(
if options.database_type == Some(DatabaseType::Tdengine) {
return build_tdengine_data_grid_save_statements(options);
}
if options.database_type == Some(DatabaseType::Salesforce) {
return build_salesforce_data_grid_save_statements(options);
}
if uses_iotdb_table_model_save(options) {
return build_iotdb_data_grid_save_statements(options);
}
@@ -1656,6 +1669,9 @@ fn build_data_grid_rollback_statements(
if options.database_type == Some(DatabaseType::Tdengine) {
return build_tdengine_data_grid_rollback_statements(options);
}
if options.database_type == Some(DatabaseType::Salesforce) {
return build_salesforce_data_grid_rollback_statements(options);
}
if uses_iotdb_table_model_save(options) {
return build_iotdb_data_grid_rollback_statements(options);
}
+170
View File
@@ -424,6 +424,13 @@ pub fn is_write_sql_for_database(sql: &str, database_type: DatabaseType) -> bool
return is_write;
}
}
if database_type == DatabaseType::Salesforce {
// SOQL has no write verbs — every write arrives as a DBX pseudo-command —
// so the keyword scan below would misread a string literal
// (`Subject = 'Delete request'`) or a custom field (`Update__c`) as a
// write and lock a read-only session out of an ordinary SELECT.
return classify_salesforce_statement_risk(sql) != SalesforceStatementRisk::Read;
}
if let Some(risk) = classify_search_engine_query_risk(sql, database_type) {
return risk != SearchEngineQueryRisk::ReadOnly;
}
@@ -440,6 +447,68 @@ fn classify_dynamodb_statement_write(source: &str) -> Option<bool> {
}
}
/// How a Salesforce statement must be treated by write and risk classification.
///
/// `SfClient::execute_query` accepts exactly two shapes: a SOQL `SELECT`, which
/// the REST query endpoint runs read-only, and a `DBX SALESFORCE <…>`
/// pseudo-command carrying a JSON body. Anything else is rejected by Salesforce
/// anyway, so it is classified as an opaque write and fails closed rather than
/// being guessed at by the SQL keyword scan.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SalesforceStatementRisk {
/// SOQL `SELECT` — always a read, whatever its literals and field names say.
Read,
/// Single-record DML naming its object and, for update/delete, its record Id.
ScopedWrite,
/// Unscoped or unknown DML, unrecognized pseudo-commands, non-SOQL text.
OpaqueWrite,
}
pub fn classify_salesforce_statement_risk(source: &str) -> SalesforceStatementRisk {
use SalesforceStatementRisk::{OpaqueWrite, Read, ScopedWrite};
let header = source.lines().find(|line| !line.trim().is_empty()).unwrap_or("").trim();
if header.to_ascii_uppercase().starts_with("DBX SALESFORCE") {
return if salesforce_dml_is_scoped(source) { ScopedWrite } else { OpaqueWrite };
}
// Comments are stripped before the verb check, but the pseudo-command branch
// above deliberately reads the raw text: a `--` inside a JSON field value
// must not be mistaken for a line comment and truncate the payload.
let first_word = strip_sql_comments(source)
.split(|character: char| !character.is_alphanumeric() && character != '_')
.find(|token| !token.is_empty())
.unwrap_or("")
.to_ascii_uppercase();
if first_word == "SELECT" {
Read
} else {
OpaqueWrite
}
}
/// Whether a `DBX SALESFORCE DML` body targets exactly one identified record:
/// an insert needs only its object, while an update or delete without an Id has
/// nothing bounding it and stays opaque.
fn salesforce_dml_is_scoped(source: &str) -> bool {
// The header line never contains a brace, so the JSON body starts at the first `{`.
let Some(start) = source.find('{') else { return false };
let Ok(body) = serde_json::from_str::<serde_json::Value>(&source[start..]) else {
return false;
};
let names_object =
body.get("object").and_then(|value| value.as_str()).is_some_and(|object| !object.trim().is_empty());
if !names_object {
return false;
}
match body.get("op").and_then(|value| value.as_str()).map(str::to_ascii_lowercase).as_deref() {
Some("insert") => true,
Some("update") | Some("delete") => {
body.get("id").and_then(|value| value.as_str()).is_some_and(|id| !id.trim().is_empty())
}
_ => false,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SearchEngineQueryRisk {
ReadOnly,
@@ -2460,6 +2529,107 @@ mod tests {
);
}
#[test]
fn classify_salesforce_statement_risk_scoped_dml() {
let update = "DBX SALESFORCE DML\n{\"op\":\"update\",\"object\":\"Account\",\"id\":\"001x\",\"fields\":{\"Name\":\"Acme\"}}";
assert_eq!(classify_salesforce_statement_risk(update), SalesforceStatementRisk::ScopedWrite);
let delete = "DBX SALESFORCE DML\n{\"op\":\"delete\",\"object\":\"Account\",\"id\":\"001x\"}";
assert_eq!(classify_salesforce_statement_risk(delete), SalesforceStatementRisk::ScopedWrite);
let insert = "DBX SALESFORCE DML\n{\"op\":\"insert\",\"object\":\"Lead\",\"fields\":{\"Company\":\"Acme\"}}";
assert_eq!(classify_salesforce_statement_risk(insert), SalesforceStatementRisk::ScopedWrite);
}
#[test]
fn classify_salesforce_statement_risk_unscoped_dml_fails_closed() {
// No Id: nothing bounds the write, so it is not a safe write.
assert_eq!(
classify_salesforce_statement_risk(
"DBX SALESFORCE DML\n{\"op\":\"update\",\"object\":\"Account\",\"fields\":{}}"
),
SalesforceStatementRisk::OpaqueWrite
);
// Unknown op / unknown header / unparseable body all fail closed.
assert_eq!(
classify_salesforce_statement_risk(
"DBX SALESFORCE DML\n{\"op\":\"upsert\",\"object\":\"Account\",\"id\":\"001x\"}"
),
SalesforceStatementRisk::OpaqueWrite
);
assert_eq!(
classify_salesforce_statement_risk("DBX SALESFORCE FUTURE\n{}"),
SalesforceStatementRisk::OpaqueWrite
);
assert_eq!(
classify_salesforce_statement_risk("DBX SALESFORCE DML\n{not json"),
SalesforceStatementRisk::OpaqueWrite
);
}
#[test]
fn classify_salesforce_statement_risk_dml_body_survives_comment_lookalikes() {
// A `--` inside a field value must not be stripped as a line comment:
// the pseudo-command branch reads the raw text, so the body still parses
// and the write stays scoped.
let dml = "DBX SALESFORCE DML\n{\"op\":\"update\",\"object\":\"Account\",\"id\":\"001x\",\"fields\":{\"Description\":\"a--b\"}}";
assert_eq!(classify_salesforce_statement_risk(dml), SalesforceStatementRisk::ScopedWrite);
}
#[test]
fn classify_salesforce_statement_risk_soql_is_read() {
assert_eq!(classify_salesforce_statement_risk("SELECT Id FROM Account"), SalesforceStatementRisk::Read);
assert_eq!(classify_salesforce_statement_risk(" select Name from Lead "), SalesforceStatementRisk::Read);
// SOQL forms sqlparser cannot parse must still read as reads.
assert_eq!(
classify_salesforce_statement_risk("SELECT FIELDS(ALL) FROM Account LIMIT 200"),
SalesforceStatementRisk::Read
);
assert_eq!(
classify_salesforce_statement_risk("SELECT Id FROM Opportunity WHERE CloseDate = LAST_N_DAYS:7"),
SalesforceStatementRisk::Read
);
assert_eq!(
classify_salesforce_statement_risk("SELECT Id, (SELECT Id FROM Contacts) FROM Account"),
SalesforceStatementRisk::Read
);
assert_eq!(
classify_salesforce_statement_risk("/* report */ SELECT Id FROM Account"),
SalesforceStatementRisk::Read
);
}
#[test]
fn classify_salesforce_statement_risk_non_soql_is_opaque_write() {
// The driver only accepts SELECT and DBX pseudo-commands; anything else
// is a client error, not something to classify as a harmless read.
assert_eq!(classify_salesforce_statement_risk("DELETE FROM Account"), SalesforceStatementRisk::OpaqueWrite);
assert_eq!(
classify_salesforce_statement_risk("FIND {Acme} IN ALL FIELDS"),
SalesforceStatementRisk::OpaqueWrite
);
assert_eq!(classify_salesforce_statement_risk(""), SalesforceStatementRisk::OpaqueWrite);
}
#[test]
fn is_write_sql_for_database_salesforce_soql_is_read() {
assert!(!is_write_sql_for_database("SELECT Id FROM Account", DatabaseType::Salesforce));
}
#[test]
fn is_write_sql_for_database_salesforce_literals_are_not_writes() {
// The generic keyword scan would see DELETE/UPDATE here; SOQL cannot write.
assert!(!is_write_sql_for_database(
"SELECT Id FROM Case WHERE Subject = 'Delete request'",
DatabaseType::Salesforce
));
assert!(!is_write_sql_for_database("SELECT Update__c FROM Lead", DatabaseType::Salesforce));
}
#[test]
fn is_write_sql_for_database_salesforce_dml_is_write() {
let dml = "DBX SALESFORCE DML\n{\"op\":\"delete\",\"object\":\"Account\",\"id\":\"x\"}";
assert!(is_write_sql_for_database(dml, DatabaseType::Salesforce));
}
#[test]
fn mysql_explicit_transaction_openers_are_recognized() {
for sql in [
+9 -1
View File
@@ -550,9 +550,17 @@ fn err(reason: &str) -> QuerySqlBuildResult {
}
fn unsupported_pagination_type(database_type: Option<DatabaseType>) -> bool {
// SOQL has no derived tables and no OFFSET wrapping; the Salesforce driver
// pages through QueryLocator cursors (session_id) instead.
matches!(
database_type,
Some(DatabaseType::Neo4j | DatabaseType::MongoDb | DatabaseType::Redis | DatabaseType::Solr)
Some(
DatabaseType::Neo4j
| DatabaseType::MongoDb
| DatabaseType::Redis
| DatabaseType::Salesforce
| DatabaseType::Solr
)
)
}
@@ -823,7 +823,7 @@ pub fn profile_for(db_type: DatabaseType) -> DdlDialectProfile {
// Non-tabular / not applicable for relational CREATE TABLE
Redis | MongoDb | DynamoDb | Elasticsearch | Easysearch | Solr | Meilisearch | Qdrant | Milvus | Weaviate
| ChromaDb | Neo4j | Cassandra | Etcd | ZooKeeper | Nacos | Consul | InfluxDb3 | VictoriaMetrics
| MessageQueue | Mqtt | Hbase => conservative_ansi(db_type),
| MessageQueue | Mqtt | Hbase | Salesforce => conservative_ansi(db_type),
}
}
+32 -4
View File
@@ -14,6 +14,7 @@ pub enum DialectKind {
ManticoreSearch,
Informix,
Questdb,
Soql,
Unsupported,
}
@@ -35,6 +36,7 @@ impl DialectKind {
DialectKind::ManticoreSearch => Some(DatabaseType::ManticoreSearch),
DialectKind::Informix => Some(DatabaseType::Informix),
DialectKind::Questdb => Some(DatabaseType::Questdb),
DialectKind::Soql => Some(DatabaseType::Salesforce),
DialectKind::Unsupported => None,
}
}
@@ -52,6 +54,7 @@ impl DialectKind {
DialectKind::ManticoreSearch => "manticoresearch",
DialectKind::Informix => "informix",
DialectKind::Questdb => "questdb",
DialectKind::Soql => "soql",
DialectKind::Unsupported => "unsupported",
}
}
@@ -79,6 +82,7 @@ impl DialectKind {
"manticoresearch" => Some(DialectKind::ManticoreSearch),
"informix" => Some(DialectKind::Informix),
"questdb" => Some(DialectKind::Questdb),
"soql" | "salesforce" => Some(DialectKind::Soql),
_ => None,
}
}
@@ -585,6 +589,14 @@ impl DialectCapabilityDescriptor {
supports_partitioning: true,
..Default::default()
},
DialectKind::Soql => Self {
dialect: DialectKind::Soql,
flags: 0,
max_identifier_length: 40,
max_columns_per_table: 500,
max_query_size_bytes: 20_000,
..Default::default()
},
DialectKind::Unsupported => Self::default(),
}
}
@@ -941,10 +953,23 @@ impl DialectInfo {
pub fn all() -> Vec<Self> {
use DialectKind::*;
vec![Mysql, Postgres, Sqlite, DuckDb, SqlServer, Oracle, H2, ClickHouse, ManticoreSearch, Informix, Questdb]
.into_iter()
.map(Self::for_kind)
.collect()
vec![
Mysql,
Postgres,
Sqlite,
DuckDb,
SqlServer,
Oracle,
H2,
ClickHouse,
ManticoreSearch,
Informix,
Questdb,
Soql,
]
.into_iter()
.map(Self::for_kind)
.collect()
}
}
@@ -974,6 +999,7 @@ mod tests {
(DatabaseType::ManticoreSearch, DialectKind::ManticoreSearch),
(DatabaseType::Informix, DialectKind::Informix),
(DatabaseType::Questdb, DialectKind::Questdb),
(DatabaseType::Salesforce, DialectKind::Soql),
(DatabaseType::Redis, DialectKind::Unsupported),
(DatabaseType::MongoDb, DialectKind::Unsupported),
];
@@ -1001,6 +1027,7 @@ mod tests {
DialectKind::ManticoreSearch,
DialectKind::Informix,
DialectKind::Questdb,
DialectKind::Soql,
] {
let db_type = kind.to_database_type().unwrap();
let back = DialectKind::from_database_type(db_type);
@@ -1023,6 +1050,7 @@ mod tests {
DialectKind::ManticoreSearch,
DialectKind::Informix,
DialectKind::Questdb,
DialectKind::Soql,
DialectKind::Unsupported,
] {
let desc = DialectCapabilityDescriptor::for_dialect(*kind);
@@ -39,6 +39,7 @@ fn core_catalog_name(kind: DialectKind) -> &'static str {
DialectKind::ManticoreSearch => "ManticoreSearch",
DialectKind::Informix => "Informix",
DialectKind::Questdb => "QuestDB",
DialectKind::Soql => "SOQL",
DialectKind::Unsupported => "Unsupported",
}
}
@@ -959,6 +959,7 @@ fn kind_name(kind: DialectKind) -> String {
DialectKind::ManticoreSearch => "ManticoreSearch",
DialectKind::Informix => "Informix",
DialectKind::Questdb => "QuestDB",
DialectKind::Soql => "SOQL",
DialectKind::Unsupported => "Unsupported",
}
.to_string()
@@ -1333,6 +1334,7 @@ identifier_rules:
DialectKind::ManticoreSearch,
DialectKind::Informix,
DialectKind::Questdb,
DialectKind::Soql,
];
for kind in kinds {
let yaml = DialectYaml::from_descriptor(*kind);
@@ -117,6 +117,10 @@ pub fn quote_table_identifier(database_type: Option<DatabaseType>, name: &str) -
}
match database_type {
Some(DatabaseType::Iotdb) => name.to_string(),
// SOQL has no delimited identifiers: `SELECT * FROM "Account"` fails with
// MALFORMED_QUERY, and double quotes are string literals there. Object and
// field API names are always sent bare.
Some(DatabaseType::Salesforce) => name.to_string(),
// JDBC connections use the driver-reported identifier quote string
// (DatabaseMetaData.getIdentifierQuoteString()) inside the JDBC agent,
// so the Rust layer passes identifiers through unquoted.
@@ -426,5 +426,25 @@ expression: all
"supports_full_text_index": false,
"supports_spatial_index": false,
"supports_transactional_ddl": false
},
{
"kind": "soql",
"label": "soql",
"capabilities": [],
"max_identifier_length": 40,
"supports_schemas": false,
"supports_catalogs": false,
"max_columns_per_table": 500,
"supports_json_type": true,
"supports_array_type": false,
"supports_enum_type": false,
"supports_uuid_type": false,
"supports_auto_increment": false,
"supports_identity_columns": false,
"supports_sequences": false,
"supports_partitioning": false,
"supports_full_text_index": false,
"supports_spatial_index": false,
"supports_transactional_ddl": false
}
]
@@ -200,6 +200,9 @@ pub fn build_table_data_select_sql_with_database(
if database_type == Some(DatabaseType::Neo4j) {
return build_neo4j_table_select_sql(&options, limit);
}
if database_type == Some(DatabaseType::Salesforce) {
return build_salesforce_table_select_sql(&options, limit);
}
if database_type == Some(DatabaseType::VictoriaMetrics) {
return format!("{}[1h]", victoriametrics_metric_selector(&options.table_name));
}
@@ -863,6 +866,47 @@ pub(super) fn build_neo4j_table_select_sql(options: &TableDataSelectSqlOptions,
format!("MATCH (n:{label}){where_clause} RETURN {returns}{order}{skip} LIMIT {limit};")
}
/// Salesforce's `FIELDS(ALL)` selector is only legal with a LIMIT of 200 or less.
const SALESFORCE_FIELDS_ALL_MAX_LIMIT: usize = 200;
/// Builds the SOQL used by the data-table grid for a Salesforce sObject.
///
/// SOQL is not SQL in three ways that matter here: there is no `SELECT *`, there
/// are no delimited identifiers (`FROM "Account"` is a `MALFORMED_QUERY`), and
/// the "everything" projection is the `FIELDS(ALL)` selector, which Salesforce
/// only accepts with `LIMIT 200` or less. Known fields are therefore projected
/// by name — that keeps any page size working — and `FIELDS(ALL)` is the capped
/// fallback for the callers that build SQL before the describe cache has loaded.
///
/// The statement carries no trailing semicolon: SOQL does not accept one.
pub(super) fn build_salesforce_table_select_sql(options: &TableDataSelectSqlOptions, limit: usize) -> String {
let object = options.table_name.trim();
let columns: Vec<&str> = options
.columns
.iter()
.map(String::as_str)
.map(str::trim)
.filter(|column| !column.is_empty() && !column.eq_ignore_ascii_case(DBX_ROWID_COLUMN))
.collect();
let (projection, effective_limit) = if columns.is_empty() {
("FIELDS(ALL)".to_string(), limit.min(SALESFORCE_FIELDS_ALL_MAX_LIMIT))
} else {
(columns.join(", "), limit)
};
// Passed through verbatim: a grid filter is the user's own predicate, and
// SOQL WHERE accepts the same parenthesised shape. Wrapping it in parentheses
// keeps a caller-supplied `OR` from binding outside the filter.
let predicate = normalize_where_input(options.where_input.as_deref());
let where_clause = if predicate.is_empty() { String::new() } else { format!(" WHERE ({predicate})") };
let order_by = options.order_by.as_deref().map(str::trim).filter(|order_by| !order_by.is_empty());
let order = order_by.map(|order_by| format!(" ORDER BY {order_by}")).unwrap_or_default();
// SOQL caps OFFSET at 2000 rows. Past that the org rejects the query, and
// surfacing its own error beats silently returning an earlier page.
let offset =
options.offset.filter(|offset| *offset > 0).map(|offset| format!(" OFFSET {offset}")).unwrap_or_default();
format!("SELECT {projection} FROM {object}{where_clause}{order} LIMIT {effective_limit}{offset}")
}
pub(super) fn build_questdb_table_select_sql(
table: &str,
where_clause: &str,
@@ -1179,4 +1223,86 @@ mod tests {
assert_eq!(build_table_data_select_sql(options), "SELECT [id], [name] FROM [users] ORDER BY [id] ASC");
}
#[test]
fn salesforce_table_select_projects_described_fields_as_soql() {
// SOQL has no `SELECT *` and no delimited identifiers, so the ANSI shape the
// grid used to emit (`SELECT * FROM "Account" LIMIT 100`) fails twice over:
// the star is not a SOQL selector and the quote is read as a string literal,
// which Salesforce reports as MALFORMED_QUERY at column 14. Fields from the
// describe go out bare and by name. No trailing semicolon either — SOQL
// rejects one.
let mut options = opts(DatabaseType::Salesforce, Some("sales"), Some("org"), "Account");
options.columns = vec!["Id".to_string(), "Name".to_string(), "First_Name__c".to_string()];
options.limit = Some(100);
// A Salesforce org is a single scope: schema/database qualification has no
// SOQL spelling and would land in the FROM clause as a parse error.
assert_eq!(build_table_data_select_sql(options), "SELECT Id, Name, First_Name__c FROM Account LIMIT 100");
}
#[test]
fn salesforce_table_select_caps_the_fields_all_fallback() {
// Callers that build SQL before the describe cache has loaded have no field
// list. `FIELDS(ALL)` is the SOQL "everything" projection, but Salesforce
// only accepts it with LIMIT 200 or less, so a larger page size is clamped
// instead of being sent as a query the org would refuse outright.
assert_eq!(
build_table_data_select_sql(TableDataSelectSqlOptions {
database_type: Some(DatabaseType::Salesforce),
table_name: "Account".to_string(),
limit: Some(1000),
..Default::default()
}),
"SELECT FIELDS(ALL) FROM Account LIMIT 200"
);
assert_eq!(
build_table_data_select_sql(TableDataSelectSqlOptions {
database_type: Some(DatabaseType::Salesforce),
table_name: "Account".to_string(),
..Default::default()
}),
"SELECT FIELDS(ALL) FROM Account LIMIT 100"
);
}
#[test]
fn salesforce_table_select_keeps_an_explicit_projection_at_any_page_size() {
// The 200-row clamp is a property of FIELDS(), not of SOQL: naming the
// fields keeps a 1000-row page legal.
let mut options = opts(DatabaseType::Salesforce, None, None, "Account");
options.columns = vec!["Id".to_string(), "Name".to_string()];
options.limit = Some(1000);
assert_eq!(build_table_data_select_sql(options), "SELECT Id, Name FROM Account LIMIT 1000");
}
#[test]
fn salesforce_table_select_composes_filter_order_and_offset() {
let mut options = opts(DatabaseType::Salesforce, None, None, "Account");
options.columns = vec!["Id".to_string(), "Name".to_string()];
options.where_input = Some("WHERE Industry = 'Energy' OR AnnualRevenue > 0".to_string());
options.order_by = Some("Name ASC".to_string());
options.limit = Some(50);
options.offset = Some(150);
// The leading WHERE is stripped and the predicate is parenthesised so a
// caller-supplied OR cannot bind outside the grid filter.
assert_eq!(
build_table_data_select_sql(options),
"SELECT Id, Name FROM Account WHERE (Industry = 'Energy' OR AnnualRevenue > 0) ORDER BY Name ASC LIMIT 50 OFFSET 150"
);
}
#[test]
fn salesforce_table_select_drops_the_synthetic_row_id() {
// The grid's synthetic row-id column is a DBX artifact for drivers without a
// primary key; it is not an sObject field, so projecting it would be an
// invalid field name in SOQL.
let mut options = opts(DatabaseType::Salesforce, None, None, "Account");
options.columns = vec!["Id".to_string(), DBX_ROWID_COLUMN.to_string()];
options.include_row_id = true;
assert_eq!(build_table_data_select_sql(options), "SELECT Id FROM Account LIMIT 10");
}
}
+7
View File
@@ -34,6 +34,13 @@ fn quotes_identifiers_by_database_type() {
assert_eq!(quote_table_identifier(Some(DatabaseType::Jdbc), "users_1"), "users_1");
assert_eq!(quote_table_identifier(Some(DatabaseType::Jdbc), "user name"), "user name");
assert_eq!(quote_table_identifier(Some(DatabaseType::Iotdb), "root.test.device2"), "root.test.device2");
// SOQL has no delimited identifiers and reads `"` as a string literal, so
// `SELECT * FROM "Account"` fails with MALFORMED_QUERY. Object and field API
// names always go out bare, and an org is a single scope: no schema qualifier.
assert_eq!(quote_table_identifier(Some(DatabaseType::Salesforce), "Account"), "Account");
assert_eq!(quote_table_identifier(Some(DatabaseType::Salesforce), "First_Name__c"), "First_Name__c");
assert_eq!(quote_table_data_identifier(Some(DatabaseType::Salesforce), "Account", None), "Account");
assert_eq!(qualified_table_name(Some(DatabaseType::Salesforce), Some("sales"), "Account"), "Account");
assert_eq!(quote_table_identifier(Some(DatabaseType::Spanner), "user`name"), "`user``name`");
// ArgoDB shares the Hive-family dialect: backticks quote identifiers and
// double quotes are string literals, and schemas qualify table names.
+97
View File
@@ -695,6 +695,13 @@ pub fn classify_sql_risk(sql: &str, dialect: &str) -> Result<SqlRisk, String> {
/// Classify SQL risk using both the parser dialect and the concrete database
/// type so dialect-specific write forms cannot be mistaken for read queries.
pub fn classify_sql_risk_for_database(sql: &str, database_type: DatabaseType) -> Result<SqlRisk, String> {
if database_type == DatabaseType::Salesforce {
// SOQL is not SQL: sqlparser either misreads it or fails, and the
// keyword fallback would then judge a statement by the words inside its
// string literals. The Salesforce classifier knows the only two shapes
// the driver accepts.
return Ok(salesforce_risk_to_sql_risk(crate::query_execution_sql::classify_salesforce_statement_risk(sql)));
}
if let Some(risk) = crate::query_execution_sql::classify_search_engine_query_risk(sql, database_type) {
return Ok(match risk {
crate::query_execution_sql::SearchEngineQueryRisk::ReadOnly => SqlRisk::ReadOnly,
@@ -707,11 +714,28 @@ pub fn classify_sql_risk_for_database(sql: &str, database_type: DatabaseType) ->
classify_sql_risk_with_database(sql, normalized, Some(database_type))
}
/// Map the Salesforce classifier onto the shared risk tiers. A single-record DML
/// that names its record is an ordinary write, so "safe write" permission is
/// enough for it; an unscoped or unrecognized statement takes the high-risk tier
/// and needs the central dangerous-operation permission.
fn salesforce_risk_to_sql_risk(risk: crate::query_execution_sql::SalesforceStatementRisk) -> SqlRisk {
use crate::query_execution_sql::SalesforceStatementRisk;
match risk {
SalesforceStatementRisk::Read => SqlRisk::ReadOnly,
SalesforceStatementRisk::ScopedWrite => SqlRisk::Write,
SalesforceStatementRisk::OpaqueWrite => SqlRisk::Ddl,
}
}
/// Return whether MCP must require the central dangerous-operation permission.
/// Parse failures fail closed for writes. Safe-write mode permits plain INSERT
/// and single-table UPDATE/DELETE statements with an effective predicate;
/// broader or opaque mutations require central high-risk permission.
pub fn is_dangerous_sql_for_database(sql: &str, database_type: DatabaseType) -> bool {
if database_type == DatabaseType::Salesforce {
return crate::query_execution_sql::classify_salesforce_statement_risk(sql)
== crate::query_execution_sql::SalesforceStatementRisk::OpaqueWrite;
}
if let Some(risk) = crate::query_execution_sql::classify_search_engine_query_risk(sql, database_type) {
return risk == crate::query_execution_sql::SearchEngineQueryRisk::Dangerous;
}
@@ -744,6 +768,12 @@ pub fn mcp_sql_has_forbidden_database_switch(sql: &str, database_type: DatabaseT
if crate::query_execution_sql::classify_search_engine_query_risk(sql, database_type).is_some() {
return false;
}
if database_type == DatabaseType::Salesforce {
// SOQL has no USE, and the Salesforce pool is keyed per connection rather
// than per database, so no statement can redirect a later call. Returning
// early also keeps the SQL parser off the JSON body of a pseudo-command.
return false;
}
let database_type_name = format!("{database_type:?}");
let normalized = normalize_dialect(&database_type_name);
let dialect = resolve_dialect(normalized);
@@ -1218,6 +1248,7 @@ impl Visitor for ProofFunctionVisitor<'_> {
#[cfg(test)]
mod tests {
use super::*;
use crate::query_execution_sql::is_write_sql_for_database;
#[test]
fn mysql_proof_accepts_plain_reads_and_allowlisted_functions() {
@@ -1861,4 +1892,70 @@ mod tests {
assert!(is_dangerous_sql_for_database("DELETE /products", database_type));
}
}
#[test]
fn classifies_salesforce_soql_as_read_even_when_sqlparser_cannot() {
let database_type = DatabaseType::Salesforce;
for soql in [
"SELECT Id, Name FROM Account",
// SOQL-only forms: FIELDS(), date literals, relationship subqueries.
"SELECT FIELDS(ALL) FROM Account LIMIT 200",
"SELECT Id FROM Opportunity WHERE CloseDate = LAST_N_DAYS:7",
"SELECT Id, (SELECT Id FROM Contacts) FROM Account",
// A literal that names a write verb is still a read.
"SELECT Id FROM Case WHERE Subject = 'Delete request'",
] {
assert_eq!(classify_sql_risk_for_database(soql, database_type).unwrap(), SqlRisk::ReadOnly, "{soql}");
assert!(!is_dangerous_sql_for_database(soql, database_type), "{soql}");
assert!(!is_write_sql_for_database(soql, database_type), "{soql}");
assert!(!mcp_sql_has_forbidden_database_switch(soql, database_type), "{soql}");
}
}
#[test]
fn classifies_scoped_salesforce_dml_as_a_safe_write() {
let database_type = DatabaseType::Salesforce;
let update = "DBX SALESFORCE DML\n{\"op\":\"update\",\"object\":\"Opportunity\",\"id\":\"006x\",\"fields\":{\"StageName\":\"Closed Won\"}}";
let insert = "DBX SALESFORCE DML\n{\"op\":\"insert\",\"object\":\"Lead\",\"fields\":{\"Company\":\"Acme\"}}";
let delete = "DBX SALESFORCE DML\n{\"op\":\"delete\",\"object\":\"Lead\",\"id\":\"00Qx\"}";
for dml in [update, insert, delete] {
// A single identified record is a scoped write: "safe write" permission
// is enough, the central dangerous-operation permission is not.
assert_eq!(classify_sql_risk_for_database(dml, database_type).unwrap(), SqlRisk::Write, "{dml}");
assert!(!is_dangerous_sql_for_database(dml, database_type), "{dml}");
assert!(is_write_sql_for_database(dml, database_type), "{dml}");
// The JSON body is never parsed as SQL, so a `USE`-shaped value inside
// it cannot trip the database-switch guard.
assert!(!mcp_sql_has_forbidden_database_switch(dml, database_type), "{dml}");
}
assert!(!mcp_sql_has_forbidden_database_switch(
"DBX SALESFORCE DML\n{\"op\":\"update\",\"object\":\"Account\",\"id\":\"001x\",\"fields\":{\"Description\":\"USE prod\"}}",
database_type
));
// The tokenizer fallback treats a `;` inside a JSON value as a statement
// boundary, so without the Salesforce branch this write would be refused
// as a database switch. SOQL cannot switch databases at all.
assert!(!mcp_sql_has_forbidden_database_switch(
"DBX SALESFORCE DML\n{\"op\":\"update\",\"object\":\"Account\",\"id\":\"001x\",\"fields\":{\"Description\":\"a; USE prod\"}}",
database_type
));
}
#[test]
fn classifies_unscoped_salesforce_writes_as_high_risk() {
let database_type = DatabaseType::Salesforce;
for statement in [
// No Id: nothing bounds the write.
"DBX SALESFORCE DML\n{\"op\":\"update\",\"object\":\"Account\",\"fields\":{\"Name\":\"x\"}}",
// Unknown operation and unknown pseudo-command headers fail closed.
"DBX SALESFORCE DML\n{\"op\":\"upsert\",\"object\":\"Account\",\"externalId\":\"E-1\"}",
"DBX SALESFORCE BULK\n{}",
// Non-SOQL text the driver would reject anyway.
"DELETE FROM Account",
] {
assert_eq!(classify_sql_risk_for_database(statement, database_type).unwrap(), SqlRisk::Ddl, "{statement}");
assert!(is_dangerous_sql_for_database(statement, database_type), "{statement}");
assert!(is_write_sql_for_database(statement, database_type), "{statement}");
}
}
}
+15
View File
@@ -1125,6 +1125,7 @@ impl ConnectionConfig {
let scheme = if self.ssl { "https" } else { "http" };
format!("{scheme}://{host}:{port}")
}
DatabaseType::Salesforce => salesforce_instance_url(raw_host, self.ssl, port),
DatabaseType::Dameng => format!("dm://{host}:{port}{db_part}"),
DatabaseType::Kingbase => format!("kingbase://{host}:{port}{db_part}"),
DatabaseType::Highgo => format!("highgo://{host}:{port}{db_part}"),
@@ -1310,6 +1311,7 @@ impl ConnectionConfig {
let scheme = if self.ssl { "https" } else { "http" };
format!("{scheme}://{host}:{port}")
}
DatabaseType::Salesforce => salesforce_instance_url(raw_host, self.ssl, port),
DatabaseType::Dameng => {
format!("dm://{}:{}@{host}:{port}{db_part}", username, password)
}
@@ -2604,6 +2606,19 @@ fn bracket_ipv6(host: &str) -> String {
}
}
/// Salesforce connections store the org instance in `host`: either a full URL
/// (`https://acme.my.salesforce.com`) or a bare hostname. The access token
/// lives in the password field and never appears in the URL, so the redacted
/// and full forms are identical.
fn salesforce_instance_url(raw_host: &str, ssl: bool, port: u16) -> String {
let trimmed = raw_host.trim().trim_end_matches('/');
if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
return trimmed.to_string();
}
let scheme = if ssl { "https" } else { "http" };
format!("{scheme}://{}:{port}", bracket_ipv6(trimmed))
}
/// Returns `true` when `host` contains two or more comma-separated entries
/// where each entry already embeds its own `:port` suffix.
///
+9
View File
@@ -463,6 +463,15 @@ async fn serve() {
.route("/connection/mcp/add", post(routes::connection::mcp_add_connection))
.route("/connection/mcp/duplicate", post(routes::connection::mcp_duplicate_connection))
.route("/connection/mcp/remove", post(routes::connection::mcp_remove_connection))
.route(
"/connection/salesforce-oauth-browser-authorize",
post(routes::connection::salesforce_oauth_browser_authorize),
)
.route("/connection/salesforce-oauth-device-start", post(routes::connection::salesforce_oauth_device_start))
.route("/connection/salesforce-oauth-device-poll", post(routes::connection::salesforce_oauth_device_poll))
.route("/connection/salesforce-oauth-refresh", post(routes::connection::salesforce_oauth_refresh))
.route("/connection/salesforce-oauth-password-login", post(routes::connection::salesforce_oauth_password_login))
.route("/salesforce/current-user", get(routes::connection::salesforce_current_user))
.route("/plugins", get(routes::plugins::list_plugins))
.route("/plugins/trusted-keys", get(routes::plugins::list_plugin_trusted_keys))
.route("/plugins/trusted-keys/save", post(routes::plugins::save_plugin_trusted_key))
+86 -1
View File
@@ -1,7 +1,7 @@
use std::collections::HashSet;
use std::sync::Arc;
use axum::extract::State;
use axum::extract::{Query, State};
use axum::http::HeaderMap;
use axum::Json;
use dbx_core::connection::{
@@ -14,6 +14,10 @@ use dbx_core::nacos::config::{
use dbx_core::runtime_config::{
release_runtime_config_on_disconnect, should_retain_runtime_config, TEST_PROBE_ID_PREFIX,
};
use dbx_core::salesforce_oauth::{
device_authorization_request, device_poll, password_grant_token, refresh_access_token, SfDeviceAuthorization,
SfDevicePoll, SfOauthParams, SfRefreshedToken, SfTokenSet,
};
use dbx_core::session_credentials::{PurposeSessionCredentialWriteToken, SessionCredentialWriteToken};
use serde::{Deserialize, Serialize};
@@ -865,6 +869,87 @@ async fn remove_connection_pools_for_connection_ids(state: &WebState, connection
}
}
// ── Salesforce OAuth ──────────────────────────────────────────────
//
// Web mode does not have a system browser opener; the browser flow is refused
// with a clear message pointing users at the device flow which works fine
// headless. The device flow endpoints proxy directly into dbx-drivers.
pub async fn salesforce_oauth_browser_authorize(
State(_state): State<Arc<WebState>>,
Json(_body): Json<SalesforceOauthParamsRequest>,
) -> Result<Json<SfTokenSet>, AppError> {
Err(AppError::from("Browser OAuth is not available in web mode. Use the device code flow instead.".to_string()))
}
pub async fn salesforce_oauth_device_start(
State(_state): State<Arc<WebState>>,
Json(body): Json<SalesforceOauthParamsRequest>,
) -> Result<Json<SfDeviceAuthorization>, AppError> {
device_authorization_request(&body.params).await.map(Json).map_err(AppError::from)
}
pub async fn salesforce_oauth_device_poll(
State(_state): State<Arc<WebState>>,
Json(body): Json<SalesforceDevicePollRequest>,
) -> Result<Json<SfDevicePoll>, AppError> {
device_poll(&body.params, &body.device_code, body.interval_secs).await.map(Json).map_err(AppError::from)
}
pub async fn salesforce_oauth_refresh(
State(_state): State<Arc<WebState>>,
Json(body): Json<SalesforceRefreshRequest>,
) -> Result<Json<SfRefreshedToken>, AppError> {
refresh_access_token(&body.params, &body.refresh_token).await.map(Json).map_err(AppError::from)
}
pub async fn salesforce_oauth_password_login(
State(_state): State<Arc<WebState>>,
Json(body): Json<SalesforcePasswordLoginRequest>,
) -> Result<Json<SfTokenSet>, AppError> {
password_grant_token(&body.params, &body.username, &body.password).await.map(Json).map_err(AppError::from)
}
#[derive(Deserialize)]
pub struct SalesforceOauthParamsRequest {
pub params: SfOauthParams,
}
#[derive(Deserialize)]
pub struct SalesforceDevicePollRequest {
pub params: SfOauthParams,
#[serde(rename = "deviceCode")]
pub device_code: String,
#[serde(rename = "intervalSecs")]
pub interval_secs: u64,
}
#[derive(Deserialize)]
pub struct SalesforceRefreshRequest {
pub params: SfOauthParams,
#[serde(rename = "refreshToken")]
pub refresh_token: String,
}
#[derive(Deserialize)]
pub struct SalesforcePasswordLoginRequest {
pub params: SfOauthParams,
pub username: String,
pub password: String,
}
#[derive(Deserialize)]
pub struct SalesforceCurrentUserQuery {
pub connection_id: String,
}
pub async fn salesforce_current_user(
State(state): State<Arc<WebState>>,
Query(q): Query<SalesforceCurrentUserQuery>,
) -> Result<Json<dbx_core::connection::SalesforceCurrentUser>, AppError> {
state.app.salesforce_current_user(&q.connection_id).await.map(Json).map_err(AppError::from)
}
#[cfg(test)]
mod tests {
use super::{
+1
View File
@@ -522,6 +522,7 @@ mod tests {
read_only: false,
allow_dangerous_sql: true,
}],
allow_salesforce_dml: false,
}],
..Default::default()
}