feat(salesforce): add Salesforce SOQL data source
This commit is contained in:
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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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(¤t.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()
|
||||
}
|
||||
|
||||
@@ -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(_)
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
+1128
-7
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()
|
||||
})
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
+20
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -522,6 +522,7 @@ mod tests {
|
||||
read_only: false,
|
||||
allow_dangerous_sql: true,
|
||||
}],
|
||||
allow_salesforce_dml: false,
|
||||
}],
|
||||
..Default::default()
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user