feat(readonly): add read-only mode for all database connections
## Background Add a read-only toggle for database connections that blocks all write operations (INSERT, UPDATE, DELETE, DROP, etc.) when enabled, allowing only read queries (SELECT, SHOW, EXPLAIN, etc.). Closes #889. ## Changes ### Data Model - Added `read_only: bool` field to ConnectionConfig (Rust struct, TS interfaces, and ConnectionConfigData deserialization layer) - Used `#[serde(default, skip_serializing_if = "is_false")]` for backward/forward compatibility with existing configs ### Frontend UI - Added "Read Only" checkbox in connection dialog - 7-language i18n support (en/es/it/pt-BR/zh-CN/zh-TW) ### SQL Classification (query_execution_sql.rs) - Added `is_write_sql()` with two-layer defense: - Layer 1: First-keyword check (must start with known read keyword) - Layer 2: Embedded dangerous keyword detection (catches CTE-wrapped writes like `WITH ... AS (DELETE FROM ...)`) - `FROM` keyword supported as DuckDB SELECT-less syntax indicator - Added `check_read_only()` returning descriptive error with connection name - Added 16 unit tests covering: pure reads/writes, CTE, case insensitivity, string literal masking, comment stripping, stored procedure calls, edge cases ### SQL Execution Guards (query.rs / transfer.rs) - Added `check_read_only_for_connection()` and `_multi()` helper functions with lazy name clone (only allocates when read_only is true) - 6 interception points: `do_execute`, MySQL batch, SQL Server batch, DuckDB batch, transaction execution, transfer execution ### Non-SQL Write Guards — Tauri Commands - Added `ensure_connection_writable()` helper (connection.rs) - Mongo: 6 write entry points (insert/update/delete, single + batch) - Redis: 15 write entry points (SET, DEL, HSET, HDEL, LPUSH, LSET, LREM, SADD, SREM, ZADD, ZREM, EXPIRE, FLUSHDB, delete_keys, execute_command) - execute_command uses RedisCommandSafety classification to allow safe read commands through raw command interface - etcd: 2 write entry points (put, delete) - sql_file: SQL file execution guarded - MCP Bridge: 4 write entry points (Insert/Update/Delete/SQL query) ### Non-SQL Write Guards — Web API - Local `ensure_writable()` helper in each route module - Redis: 12 write endpoints including classified execute_command - Mongo: 6 write endpoints - etcd: 2 write endpoints - sql_file, table_import, transfer: early rejection ### Test Updates - Updated ConnectionConfig construction in 7 test files with `read_only: false` initialization ## Defense-in-Depth - Layer 1: Command/Route-level early rejection (saves resources) - Layer 2: Core SQL classifier (`is_write_sql` — first keyword + embedded scan) - Layer 3: Core execution-time interception (do_execute/transfer/transaction)
This commit is contained in:
1 parent
7798f2231e
commit
a440415e86
35 files changed
+597
-24
No files matched your search
@@ -199,6 +199,7 @@ mod tests {
|
||||
jdbc_driver_class: None,
|
||||
jdbc_driver_paths: Vec::new(),
|
||||
one_time: false,
|
||||
read_only: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,21 @@ use serde::Deserialize;
|
||||
use crate::error::AppError;
|
||||
use crate::state::WebState;
|
||||
|
||||
/// Check if a connection is read-only and return an error if so.
|
||||
async fn ensure_writable(
|
||||
app: &dbx_core::connection::AppState,
|
||||
connection_id: &str,
|
||||
action: &str,
|
||||
) -> Result<(), AppError> {
|
||||
if let Some(name) = dbx_core::query::connection_readonly_name(app, connection_id).await {
|
||||
return Err(AppError(format!(
|
||||
"Read-only mode: connection '{}' has read-only protection enabled. {} blocked.",
|
||||
name, action
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EtcdListPrefixRequest {
|
||||
@@ -60,6 +75,7 @@ pub async fn put(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<EtcdPutRequest>,
|
||||
) -> Result<Json<dbx_core::agent_kv::KvPutResponse>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "Put").await?;
|
||||
let result = dbx_core::agent_kv::kv_put_core(&state.app, &req.connection_id, &req.key, req.value, req.lease)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
@@ -70,6 +86,7 @@ pub async fn delete(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<EtcdKeyRequest>,
|
||||
) -> Result<Json<dbx_core::agent_kv::KvDeleteResponse>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "Delete").await?;
|
||||
let result =
|
||||
dbx_core::agent_kv::kv_delete_core(&state.app, &req.connection_id, &req.key).await.map_err(AppError)?;
|
||||
Ok(Json(result))
|
||||
|
||||
@@ -7,6 +7,21 @@ use serde::Deserialize;
|
||||
use crate::error::AppError;
|
||||
use crate::state::WebState;
|
||||
|
||||
/// Check if a connection is read-only and return an error if so.
|
||||
async fn ensure_writable(
|
||||
app: &dbx_core::connection::AppState,
|
||||
connection_id: &str,
|
||||
action: &str,
|
||||
) -> Result<(), AppError> {
|
||||
if let Some(name) = dbx_core::query::connection_readonly_name(app, connection_id).await {
|
||||
return Err(AppError(format!(
|
||||
"Read-only mode: connection '{}' has read-only protection enabled. {} blocked.",
|
||||
name, action
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MongoConnectionRequest {
|
||||
@@ -159,6 +174,7 @@ pub async fn insert_document(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<MongoInsertRequest>,
|
||||
) -> Result<Json<String>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "Insert").await?;
|
||||
let result = dbx_core::mongo_ops::mongo_insert_document_core(
|
||||
&state.app,
|
||||
&req.connection_id,
|
||||
@@ -175,6 +191,7 @@ pub async fn insert_documents(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<MongoInsertDocumentsRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "Insert").await?;
|
||||
let result = dbx_core::mongo_ops::mongo_insert_documents_core(
|
||||
&state.app,
|
||||
&req.connection_id,
|
||||
@@ -191,6 +208,7 @@ pub async fn update_document(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<MongoUpdateRequest>,
|
||||
) -> Result<Json<u64>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "Update").await?;
|
||||
let result = dbx_core::mongo_ops::mongo_update_document_core(
|
||||
&state.app,
|
||||
&req.connection_id,
|
||||
@@ -208,6 +226,7 @@ pub async fn update_documents(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<MongoUpdateDocumentsRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "Update").await?;
|
||||
let result = dbx_core::mongo_ops::mongo_update_documents_core(
|
||||
&state.app,
|
||||
&req.connection_id,
|
||||
@@ -226,6 +245,7 @@ pub async fn delete_document(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<MongoDeleteRequest>,
|
||||
) -> Result<Json<u64>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "Delete").await?;
|
||||
let result = dbx_core::mongo_ops::mongo_delete_document_core(
|
||||
&state.app,
|
||||
&req.connection_id,
|
||||
@@ -242,6 +262,7 @@ pub async fn delete_documents(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<MongoDeleteDocumentsRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "Delete").await?;
|
||||
let result = dbx_core::mongo_ops::mongo_delete_documents_core(
|
||||
&state.app,
|
||||
&req.connection_id,
|
||||
|
||||
@@ -7,6 +7,21 @@ use serde::Deserialize;
|
||||
use crate::error::AppError;
|
||||
use crate::state::WebState;
|
||||
|
||||
/// Check if a connection is read-only and return an error if so.
|
||||
async fn ensure_writable(
|
||||
app: &dbx_core::connection::AppState,
|
||||
connection_id: &str,
|
||||
action: &str,
|
||||
) -> Result<(), AppError> {
|
||||
if let Some(name) = dbx_core::query::connection_readonly_name(app, connection_id).await {
|
||||
return Err(AppError(format!(
|
||||
"Read-only mode: connection '{}' has read-only protection enabled. {} blocked.",
|
||||
name, action
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RedisConnectionRequest {
|
||||
@@ -162,6 +177,7 @@ pub async fn set_string(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<RedisSetStringRequest>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "SET").await?;
|
||||
dbx_core::redis_ops::redis_set_string_in_db_core(
|
||||
&state.app,
|
||||
&req.connection_id,
|
||||
@@ -179,6 +195,7 @@ pub async fn delete_key(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<RedisKeyRequest>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "Delete key").await?;
|
||||
dbx_core::redis_ops::redis_delete_key_in_db_core(&state.app, &req.connection_id, req.db, &req.key_raw)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
@@ -189,6 +206,7 @@ pub async fn hash_set(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<RedisHashRequest>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "HSET").await?;
|
||||
let value = req.value.as_deref().unwrap_or("");
|
||||
dbx_core::redis_ops::redis_hash_set_in_db_core(
|
||||
&state.app,
|
||||
@@ -207,6 +225,7 @@ pub async fn hash_del(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<RedisHashRequest>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "HDEL").await?;
|
||||
dbx_core::redis_ops::redis_hash_del_in_db_core(&state.app, &req.connection_id, req.db, &req.key_raw, &req.field)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
@@ -217,6 +236,7 @@ pub async fn list_push(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<RedisListRequest>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "LPUSH").await?;
|
||||
let value = req.value.as_deref().unwrap_or("");
|
||||
dbx_core::redis_ops::redis_list_push_in_db_core(&state.app, &req.connection_id, req.db, &req.key_raw, value)
|
||||
.await
|
||||
@@ -228,6 +248,7 @@ pub async fn list_set(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<RedisListRequest>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "LSET").await?;
|
||||
let index = req.index.unwrap_or(0);
|
||||
let value = req.value.as_deref().unwrap_or("");
|
||||
dbx_core::redis_ops::redis_list_set_in_db_core(&state.app, &req.connection_id, req.db, &req.key_raw, index, value)
|
||||
@@ -240,6 +261,7 @@ pub async fn list_remove(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<RedisListRequest>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "LREM").await?;
|
||||
let index = req.index.unwrap_or(0);
|
||||
dbx_core::redis_ops::redis_list_remove_in_db_core(&state.app, &req.connection_id, req.db, &req.key_raw, index)
|
||||
.await
|
||||
@@ -251,6 +273,7 @@ pub async fn set_add(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<RedisSetRequest>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "SADD").await?;
|
||||
dbx_core::redis_ops::redis_set_add_in_db_core(&state.app, &req.connection_id, req.db, &req.key_raw, &req.member)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
@@ -261,6 +284,7 @@ pub async fn set_remove(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<RedisSetRequest>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "SREM").await?;
|
||||
dbx_core::redis_ops::redis_set_remove_in_db_core(&state.app, &req.connection_id, req.db, &req.key_raw, &req.member)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
@@ -271,6 +295,7 @@ pub async fn delete_keys(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<RedisKeysRequest>,
|
||||
) -> Result<Json<u64>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "Delete keys").await?;
|
||||
let result =
|
||||
dbx_core::redis_ops::redis_delete_keys_in_db_core(&state.app, &req.connection_id, req.db, &req.key_raws)
|
||||
.await
|
||||
@@ -282,6 +307,7 @@ pub async fn flush_db(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<RedisDbRequest>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "FLUSHDB").await?;
|
||||
dbx_core::redis_ops::redis_flush_db_core(&state.app, &req.connection_id, req.db).await.map_err(AppError)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
@@ -290,6 +316,18 @@ pub async fn execute_command(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<RedisCommandRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
// In read-only mode, only allow safe read commands
|
||||
if let Some(name) = dbx_core::query::connection_readonly_name(&state.app, &req.connection_id).await {
|
||||
let cmd_name = req.command.split_whitespace().next().unwrap_or("");
|
||||
if dbx_core::db::redis_driver::classify_command(cmd_name)
|
||||
!= dbx_core::db::redis_driver::RedisCommandSafety::Allowed
|
||||
{
|
||||
return Err(AppError(format!(
|
||||
"Read-only mode: connection '{}' has read-only protection enabled. Command '{}' blocked.",
|
||||
name, cmd_name
|
||||
)));
|
||||
}
|
||||
}
|
||||
let result = dbx_core::redis_ops::redis_execute_command_core(&state.app, &req.connection_id, req.db, &req.command)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
|
||||
@@ -63,6 +63,15 @@ pub async fn execute_sql_file(
|
||||
Json(body): Json<SqlFileExecuteWrapper>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let req = body.request;
|
||||
|
||||
// Fast-fail: reject early if the connection is read-only (individual statements are also checked in do_execute)
|
||||
if let Some(name) = dbx_core::query::connection_readonly_name(&state.app, &req.connection_id).await {
|
||||
return Err(AppError(format!(
|
||||
"Read-only mode: connection '{}' has read-only protection enabled. SQL file execution blocked.",
|
||||
name
|
||||
)));
|
||||
}
|
||||
|
||||
let execution_id = req.execution_id.clone();
|
||||
let file_path = validated_uploaded_sql_path(&state.data_dir, &req.file_path)?;
|
||||
let token = CancellationToken::new();
|
||||
|
||||
@@ -56,6 +56,15 @@ pub async fn execute_import(
|
||||
Json(body): Json<ExecuteImportWrapper>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let req = body.request;
|
||||
|
||||
// Reject import early if the connection is read-only
|
||||
if let Some(name) = dbx_core::query::connection_readonly_name(&state.app, &req.connection_id).await {
|
||||
return Err(AppError(format!(
|
||||
"Read-only mode: connection '{}' has read-only protection enabled. Import blocked.",
|
||||
name
|
||||
)));
|
||||
}
|
||||
|
||||
let import_id = req.import_id.clone();
|
||||
|
||||
let (tx, _) = tokio::sync::broadcast::channel::<String>(256);
|
||||
|
||||
@@ -27,6 +27,15 @@ pub async fn start_transfer(
|
||||
Json(body): Json<StartTransferRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let req = body.request;
|
||||
|
||||
// Reject transfer early if the target connection is read-only
|
||||
if let Some(name) = dbx_core::query::connection_readonly_name(&state.app, &req.target_connection_id).await {
|
||||
return Err(AppError(format!(
|
||||
"Read-only mode: target connection '{}' has read-only protection enabled. Transfer blocked.",
|
||||
name
|
||||
)));
|
||||
}
|
||||
|
||||
let transfer_id = req.transfer_id.clone();
|
||||
|
||||
// Create a broadcast channel for progress
|
||||
|
||||
Reference in new issue
Block a user