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:
runstone committed 2026-06-10 10:58:52 +08:00
1 parent 7798f2231e
commit a440415e86
35 files changed
+597 -24

No files matched your search

+7 -1
View File
@@ -1,7 +1,7 @@
use std::sync::Arc;
use tauri::State;
use crate::commands::connection::AppState;
use crate::commands::connection::{ensure_connection_writable, AppState};
use dbx_core::db::mongo_driver::MongoDocumentResult;
#[tauri::command]
@@ -74,6 +74,7 @@ pub async fn mongo_insert_document(
collection: String,
doc_json: String,
) -> Result<String, String> {
ensure_connection_writable(&state, &connection_id, "Insert").await?;
dbx_core::mongo_ops::mongo_insert_document_core(&state, &connection_id, &database, &collection, &doc_json).await
}
@@ -85,6 +86,7 @@ pub async fn mongo_insert_documents(
collection: String,
docs_json: String,
) -> Result<u64, String> {
ensure_connection_writable(&state, &connection_id, "Insert").await?;
dbx_core::mongo_ops::mongo_insert_documents_core(&state, &connection_id, &database, &collection, &docs_json).await
}
@@ -97,6 +99,7 @@ pub async fn mongo_update_document(
id: String,
doc_json: String,
) -> Result<u64, String> {
ensure_connection_writable(&state, &connection_id, "Update").await?;
dbx_core::mongo_ops::mongo_update_document_core(&state, &connection_id, &database, &collection, &id, &doc_json)
.await
}
@@ -111,6 +114,7 @@ pub async fn mongo_update_documents(
update_json: String,
many: bool,
) -> Result<u64, String> {
ensure_connection_writable(&state, &connection_id, "Update").await?;
dbx_core::mongo_ops::mongo_update_documents_core(
&state,
&connection_id,
@@ -131,6 +135,7 @@ pub async fn mongo_delete_document(
collection: String,
id: String,
) -> Result<u64, String> {
ensure_connection_writable(&state, &connection_id, "Delete").await?;
dbx_core::mongo_ops::mongo_delete_document_core(&state, &connection_id, &database, &collection, &id).await
}
@@ -143,6 +148,7 @@ pub async fn mongo_delete_documents(
filter_json: String,
many: bool,
) -> Result<u64, String> {
ensure_connection_writable(&state, &connection_id, "Delete").await?;
dbx_core::mongo_ops::mongo_delete_documents_core(&state, &connection_id, &database, &collection, &filter_json, many)
.await
}