From b0a9a995fb9c8f4fec54c9257974f258a51ee652 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Tue, 23 Jun 2026 15:33:11 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(connection):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E7=94=9F=E4=BA=A7=E8=BF=9E=E6=8E=A5=E5=8F=AA=E8=AF=BB?= =?UTF-8?q?=E4=BF=9D=E6=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../需求进度追踪-生产连接只读保护-20260623.md | 74 ++++++ frontend/src/components/ConnectionModal.tsx | 1 + frontend/src/components/DataGrid.tsx | 2 +- frontend/src/components/QueryEditor.tsx | 18 +- .../connectionModal/ConnectionModalStep2.tsx | 28 ++ .../connectionModalConfig.keepalive.test.ts | 25 ++ .../connectionModal/connectionModalConfig.ts | 7 + frontend/src/store.ts | 1 + frontend/src/types.ts | 1 + frontend/src/utils/connectionReadOnly.ts | 250 ++++++++++++++++++ .../src/utils/connectionRpcConfig.test.ts | 13 + frontend/src/utils/connectionRpcConfig.ts | 1 + .../src/utils/dataSourceCapabilities.test.ts | 21 ++ frontend/src/utils/dataSourceCapabilities.ts | 14 +- frontend/wailsjs/go/models.ts | 2 + internal/app/connection_readonly.go | 169 ++++++++++++ internal/app/connection_readonly_test.go | 49 ++++ internal/app/methods_db.go | 51 +++- internal/app/methods_db_transaction.go | 3 + internal/app/methods_file.go | 17 +- internal/app/methods_sync.go | 7 + internal/app/sql_sanitize.go | 4 +- internal/app/sql_sanitize_test.go | 12 + internal/connection/types.go | 1 + shared/i18n/de-DE.json | 4 + shared/i18n/en-US.json | 4 + shared/i18n/ja-JP.json | 4 + shared/i18n/ru-RU.json | 4 + shared/i18n/zh-CN.json | 4 + shared/i18n/zh-TW.json | 4 + 30 files changed, 776 insertions(+), 19 deletions(-) create mode 100644 docs/需求追踪/需求进度追踪-生产连接只读保护-20260623.md create mode 100644 frontend/src/utils/connectionReadOnly.ts create mode 100644 internal/app/connection_readonly.go create mode 100644 internal/app/connection_readonly_test.go diff --git a/docs/需求追踪/需求进度追踪-生产连接只读保护-20260623.md b/docs/需求追踪/需求进度追踪-生产连接只读保护-20260623.md new file mode 100644 index 0000000..60fab97 --- /dev/null +++ b/docs/需求追踪/需求进度追踪-生产连接只读保护-20260623.md @@ -0,0 +1,74 @@ +# 需求进度追踪 - 生产连接只读保护 + +## 1. 需求摘要 +- 需求名称:生产连接只读保护 +- 提出日期:2026-06-23 +- 负责人:Codex +- 目标:为 SQL 类数据库与 MongoDB 连接增加连接级只读保护,启用后仅允许查询,阻止写入、DDL、导入和同步目标操作 +- 非目标:不为所有侧栏写操作都新增前端隐藏逻辑;不引入新的环境分级体系;不改造 JVM 只读能力 + +## 2. 范围与验收 +- 范围: +- 连接配置模型、保存/回填与 RPC 序列化链路 +- 连接弹窗只读开关、查询编辑器本地拦截、DataGrid 导入入口收口 +- 后端 SQL/Mongo 查询判定与写操作统一守卫 +- 验收标准: +- 支持的数据源出现“生产连接/只允许查询”开关 +- 启用后普通查询仍可执行,非查询 SQL / Mongo 写命令被前后端阻止 +- 导入、结构变更、清表、同步目标等关键写入口被后端拒绝 +- 依赖与约束: +- 保持现有数据源能力判定与 QueryEditor 执行链路 +- MongoDB 前端判定以保守拦截为主,最终正确性由后端守卫兜底 + +## 3. 里程碑与进度 +- [x] 阶段 1(需求澄清):确认采用连接级 `readOnly` 布尔字段,不新建环境系统 +- [x] 阶段 2(影响分析):梳理前端能力面板、查询执行、导入与后端写入口 +- [x] 阶段 3(方案设计):确定“前端预拦截 + 后端最终守卫”双层保护 +- [x] 阶段 4(实施计划):接入配置链路、能力判定、查询判定与写入口守卫 +- [x] 阶段 5(实现与自检):补文案、测试与定向验证 +- [x] 阶段 6(评审与交付):确认范围、风险、回滚点和验证命令 +- [ ] 阶段 7(发布与观察):待体验验证 + +## 4. 变更清单 +- 已完成: +- 新增连接级 `readOnly` 配置字段及前后端序列化支持 +- 连接弹窗为 SQL 类数据库与 MongoDB 增加生产连接保护开关 +- QueryEditor 增加本地非查询拦截,DataGrid 导入入口在只读连接下禁用 +- 后端为查询、DDL、导入、清表、同步等写入口增加统一只读守卫 +- MongoDB 查询判定改为命令级白名单,不再把所有 JSON 命令都视为只读 +- 补充前端/后端定向测试与需求追踪文档 +- 进行中: +- 等待体验包验证连接弹窗、查询拦截和写操作拒绝文案 +- 待处理: +- 如需进一步优化体验,再补侧栏对象级写菜单的前端隐藏/禁用 + +## 5. 风险与阻塞 +- 风险: +- 前端 SQL/Mongo 只读判定是保守策略,边界命令可能仍需后端兜底 +- 现有部分侧栏写菜单仍可能显示,但执行时会被后端拒绝 +- 阻塞: +- 暂无 +- 缓解措施: +- 关键写入口统一走后端守卫;前端只负责提前反馈与减少误操作 + +## 6. 决策记录 +- 决策 1:只对 SQL 类数据库和 MongoDB 支持连接级生产保护,其他数据源忽略 `readOnly` +- 决策 2:采用顶层 `readOnly` 布尔字段,避免新增环境枚举和迁移成本 +- 决策 3:MongoDB 只读判定按命令白名单处理,防止把写命令误放行 + +## 7. 验证记录 +- 验证项: +- 前端数据源能力判定、RPC 配置、连接配置测试 +- 后端只读连接守卫与 SQL/Mongo 查询判定测试 +- 结果: +- 通过 +- 证据(日志/截图/链接): +- `go test ./internal/app -run 'Test(EnsureReadOnlyConnectionAllows|SupportsConnectionReadOnlyMode|IsReadOnlySQLQuery)'` +- `npm --prefix frontend test -- src/utils/dataSourceCapabilities.test.ts src/utils/connectionRpcConfig.test.ts src/components/connectionModal/connectionModalConfig.keepalive.test.ts` + +## 8. 下一步 +- 下一步行动: +- 用体验包回归验证生产连接下的查询、导入、建库删库、同步目标和 Mongo 命令拦截 +- 如需更完整 UX,再补侧栏写菜单的只读态收口 +- 负责人: +- Codex diff --git a/frontend/src/components/ConnectionModal.tsx b/frontend/src/components/ConnectionModal.tsx index 9221a60..d857d8a 100644 --- a/frontend/src/components/ConnectionModal.tsx +++ b/frontend/src/components/ConnectionModal.tsx @@ -1387,6 +1387,7 @@ const ConnectionModal: React.FC<{ user: config.user, password: config.password, database: config.database, + readOnly: config.readOnly === true, uri: config.uri || "", connectionParams: config.connectionParams || diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index d78dbc9..980e89c 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -539,7 +539,7 @@ const DataGrid: React.FC = ({ const supportsCopyInsert = dataSourceCaps.supportsCopyInsert; const supportsSqlQueryExport = dataSourceCaps.supportsSqlQueryExport; const isQueryResultExport = exportScope === 'queryResult'; - const canImport = exportScope === 'table' && !!tableName; + const canImport = exportScope === 'table' && !!tableName && !readOnly; const canExport = !!connectionId && (isQueryResultExport || !!tableName); const canViewDdl = exportScope === 'table' && !!connectionId && !!tableName; const canOpenObjectDesigner = exportScope === 'table' && objectType === 'table' && !!connectionId && !!tableName; diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index aaf8117..3758615 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -8,6 +8,7 @@ import { TabData, ColumnDefinition } from '../types'; import { useStore } from '../store'; import { DBQuery, DBQueryWithCancel, DBQueryMulti, DBQueryMultiTransactional, DBGetTables, DBGetAllColumns, DBGetDatabases, DBGetColumns, CancelQuery, GenerateQueryID, WriteSQLFile, ExportSQLFile } from '../../wailsjs/go/app/App'; import { GONAVI_ROW_KEY } from './DataGrid'; +import { findConnectionMutatingStatements } from '../utils/connectionReadOnly'; import { getDataSourceCapabilities, shouldShowOceanBaseRowNumberColumn } from '../utils/dataSourceCapabilities'; import { applyMongoQueryAutoLimit, convertMongoShellToJsonCommand } from "../utils/mongodb"; import { getShortcutDisplayLabel, getShortcutPlatform, getShortcutPrimaryModifierDisplayLabel, isEditableElement, isImeComposingKeyEvent, isShortcutMatch, comboToMonacoKeyBinding, resolveShortcutBinding } from "../utils/shortcuts"; @@ -3003,13 +3004,18 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc return; } const connCaps = getDataSourceCapabilities(conn.config); - if (!connCaps.supportsQueryEditor) { - message.error(translate('query_editor.message.unsupported_source')); - if (runSeqRef.current === runSeq) setLoading(false); - return; - } + if (!connCaps.supportsQueryEditor) { + message.error(translate('query_editor.message.unsupported_source')); + if (runSeqRef.current === runSeq) setLoading(false); + return; + } + if (findConnectionMutatingStatements(conn.config, executableSQL).length > 0) { + message.warning(translate('query_editor.message.connection_readonly_blocked')); + if (runSeqRef.current === runSeq) setLoading(false); + return; + } - const config = { + const config = { ...conn.config, port: Number(conn.config.port), password: conn.config.password || "", diff --git a/frontend/src/components/connectionModal/ConnectionModalStep2.tsx b/frontend/src/components/connectionModal/ConnectionModalStep2.tsx index 92b2fb4..991f20e 100644 --- a/frontend/src/components/connectionModal/ConnectionModalStep2.tsx +++ b/frontend/src/components/connectionModal/ConnectionModalStep2.tsx @@ -37,6 +37,7 @@ import { import ConnectionModalMongoSections from "../ConnectionModalMongoSections"; import ConnectionModalRedisSections from "../ConnectionModalRedisSections"; import { t } from "../../i18n"; +import { supportsConnectionReadOnlyMode } from "../../utils/connectionReadOnly"; import { getConnectionConfigLayoutKindLabel, getStoredSecretPlaceholder, @@ -192,6 +193,11 @@ const ConnectionModalStep2: React.FC = (props) => { } = props; const renderStep2 = () => { + const showConnectionReadOnlyField = supportsConnectionReadOnlyMode({ + type: dbType, + driver: form.getFieldValue("driver"), + oceanBaseProtocol, + }); const baseInfoSection = (
{ ), })} + {showConnectionReadOnlyField && + renderConfigSectionCard({ + sectionKey: "readOnly", + icon: , + children: ( + + clearConnectionTestResultForChoice()} + > + {t("connection.modal.field.readOnly.checkbox")} + + + ), + })} + {isMySQLLike && renderConfigSectionCard({ sectionKey: "connectionMode", @@ -2306,6 +2333,7 @@ const renderStep2 = () => { keepAliveIntervalMinutes: 240, uri: "", connectionParams: "", + readOnly: false, oceanBaseProtocol: "mysql", mysqlTopology: "single", rocketmqTopology: "single", diff --git a/frontend/src/components/connectionModal/connectionModalConfig.keepalive.test.ts b/frontend/src/components/connectionModal/connectionModalConfig.keepalive.test.ts index f928ac9..edb7dfa 100644 --- a/frontend/src/components/connectionModal/connectionModalConfig.keepalive.test.ts +++ b/frontend/src/components/connectionModal/connectionModalConfig.keepalive.test.ts @@ -97,4 +97,29 @@ describe("connectionModalConfig keepalive", () => { expect(config.keepAliveEnabled).toBe(false); expect(config.keepAliveIntervalMinutes).toBe(15); }); + + it("persists readOnly only for datasource types that support production guard", async () => { + const sqlConfig = await buildConnectionConfig({ + values: { + ...buildBaseValues(), + type: "postgres", + readOnly: true, + }, + forPersist: true, + translate, + }); + const redisConfig = await buildConnectionConfig({ + values: { + ...buildBaseValues(), + type: "redis", + port: 6379, + readOnly: true, + }, + forPersist: true, + translate, + }); + + expect(sqlConfig.readOnly).toBe(true); + expect(redisConfig.readOnly).toBe(false); + }); }); diff --git a/frontend/src/components/connectionModal/connectionModalConfig.ts b/frontend/src/components/connectionModal/connectionModalConfig.ts index 20652f7..4099ddc 100644 --- a/frontend/src/components/connectionModal/connectionModalConfig.ts +++ b/frontend/src/components/connectionModal/connectionModalConfig.ts @@ -1,4 +1,5 @@ import type { ConnectionConfig, SavedConnection } from "../../types"; +import { supportsConnectionReadOnlyMode } from "../../utils/connectionReadOnly"; import { resolveConnectionSecretDraft } from "../../utils/connectionSecretDraft"; import { getConnectionTypeDefaultPort as getDefaultPortByType, @@ -805,6 +806,12 @@ export const buildConnectionConfig = async ({ password: keepPassword ? mergedValues.password || "" : "", savePassword: savePassword, database: mergedValues.database || "", + readOnly: + supportsConnectionReadOnlyMode({ + type, + driver: mergedValues.driver, + oceanBaseProtocol: selectedOceanBaseProtocol, + }) && mergedValues.readOnly === true, useSSL: effectiveUseSSL, sslMode: effectiveUseSSL ? sslMode : "disable", sslCAPath: sslCAPath, diff --git a/frontend/src/store.ts b/frontend/src/store.ts index b9a3b39..8fb6b24 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -800,6 +800,7 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => { password: savePassword ? toTrimmedString(raw.password) : "", savePassword, database: toTrimmedString(raw.database), + readOnly: raw.readOnly === true, useSSL: sslCapable ? !!raw.useSSL : false, sslMode: sslCapable ? sslMode : "disable", sslCAPath: sslCapable ? toTrimmedString(raw.sslCAPath) : "", diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 766cca2..e81e6f3 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -282,6 +282,7 @@ export interface ConnectionConfig { password?: string; savePassword?: boolean; database?: string; + readOnly?: boolean; useSSL?: boolean; sslMode?: "preferred" | "required" | "skip-verify" | "disable"; sslCAPath?: string; diff --git a/frontend/src/utils/connectionReadOnly.ts b/frontend/src/utils/connectionReadOnly.ts new file mode 100644 index 0000000..3703130 --- /dev/null +++ b/frontend/src/utils/connectionReadOnly.ts @@ -0,0 +1,250 @@ +import type { ConnectionConfig } from "../types"; +import { convertMongoShellToJsonCommand } from "./mongodb"; +import { resolveSqlDialect } from "./sqlDialect"; +import { findSqlStatementRanges } from "./sqlStatementSelection"; + +type ConnectionReadOnlyLike = Pick< + ConnectionConfig, + "type" | "driver" | "oceanBaseProtocol" | "readOnly" +> | null | undefined; + +const CONNECTION_READ_ONLY_TYPES = new Set([ + "mysql", + "goldendb", + "mariadb", + "oceanbase", + "diros", + "starrocks", + "sphinx", + "postgres", + "kingbase", + "highgo", + "vastbase", + "opengauss", + "gaussdb", + "sqlserver", + "iris", + "sqlite", + "duckdb", + "oracle", + "dameng", + "tdengine", + "clickhouse", + "trino", + "mongodb", +]); + +const SQL_READ_ONLY_KEYWORDS = new Set([ + "select", + "with", + "show", + "describe", + "desc", + "explain", + "pragma", + "values", + "consume", +]); + +const SQL_MUTATING_WITH_KEYWORDS = /\b(insert|update|delete|replace|merge|upsert)\b/i; +const SQL_SELECT_INTO_PATTERN = /^\s*select\b[\s\S]*\binto\b/i; + +const MONGO_READ_ONLY_COMMANDS = new Set([ + "aggregate", + "buildinfo", + "collstats", + "connectionstatus", + "count", + "countdocuments", + "dbstats", + "distinct", + "explain", + "find", + "findone", + "getparameter", + "hello", + "hostinfo", + "ismaster", + "listcollections", + "listdatabases", + "listindexes", + "ping", + "serverstatus", +]); + +const MONGO_WRITE_COMMANDS = new Set([ + "bulkwrite", + "collmod", + "create", + "createindexes", + "delete", + "drop", + "dropdatabase", + "dropindexes", + "findandmodify", + "insert", + "mapreduce", + "renamecollection", + "update", +]); + +const MONGO_META_KEYS = new Set([ + "$db", + "$readpreference", + "api", + "apideprecationerrors", + "apistrict", + "comment", + "let", + "lsid", + "maxtimems", + "ordered", + "readconcern", + "writeconcern", +]); + +const stripLeadingSqlComments = (statement: string): string => { + let text = String(statement || "").trim(); + while (text) { + if (text.startsWith("--")) { + const next = text.indexOf("\n"); + text = next >= 0 ? text.slice(next + 1).trimStart() : ""; + continue; + } + if (text.startsWith("#")) { + const next = text.indexOf("\n"); + text = next >= 0 ? text.slice(next + 1).trimStart() : ""; + continue; + } + if (text.startsWith("/*")) { + const next = text.indexOf("*/"); + text = next >= 0 ? text.slice(next + 2).trimStart() : ""; + continue; + } + break; + } + return text; +}; + +const extractLeadingSqlKeyword = (statement: string): string => { + const text = stripLeadingSqlComments(statement); + const match = text.match(/^[A-Za-z_][A-Za-z0-9_]*/); + return match ? match[0].toLowerCase() : ""; +}; + +const resolveConnectionReadOnlyType = ( + config: ConnectionReadOnlyLike, +): string => { + if (!config) return ""; + return String( + resolveSqlDialect(String(config.type || ""), String(config.driver || ""), { + oceanBaseProtocol: config.oceanBaseProtocol, + }), + ) + .trim() + .toLowerCase(); +}; + +const isReadOnlySqlStatement = (statement: string): boolean => { + const text = stripLeadingSqlComments(statement); + if (!text) return true; + const keyword = extractLeadingSqlKeyword(text); + if (!keyword || !SQL_READ_ONLY_KEYWORDS.has(keyword)) { + return false; + } + if (keyword === "select") { + return !SQL_SELECT_INTO_PATTERN.test(text); + } + if (keyword === "with") { + return !SQL_SELECT_INTO_PATTERN.test(text) && + !SQL_MUTATING_WITH_KEYWORDS.test(text); + } + return true; +}; + +const normalizeMongoCommandText = (statement: string): string => { + const trimmed = String(statement || "").trim(); + if (!trimmed) return ""; + if (trimmed.startsWith("{")) { + return trimmed; + } + const converted = convertMongoShellToJsonCommand(trimmed); + if (converted.recognized && converted.command) { + return converted.command; + } + return ""; +}; + +const resolveMongoCommandKey = (command: Record): string => { + const keys = Object.keys(command).map((key) => key.trim()); + const effectiveKeys = keys.filter((key) => { + const normalized = key.toLowerCase(); + return normalized !== "" && !MONGO_META_KEYS.has(normalized); + }); + for (const key of effectiveKeys) { + if (MONGO_WRITE_COMMANDS.has(key.toLowerCase())) { + return key; + } + } + for (const key of effectiveKeys) { + if (MONGO_READ_ONLY_COMMANDS.has(key.toLowerCase())) { + return key; + } + } + return effectiveKeys[0] || ""; +}; + +const isReadOnlyMongoStatement = (statement: string): boolean => { + const commandText = normalizeMongoCommandText(statement); + if (!commandText) return false; + try { + const parsed = JSON.parse(commandText) as Record; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return false; + } + const commandKey = resolveMongoCommandKey(parsed).toLowerCase(); + if (!commandKey) return false; + if (MONGO_WRITE_COMMANDS.has(commandKey)) { + return false; + } + return MONGO_READ_ONLY_COMMANDS.has(commandKey); + } catch { + return false; + } +}; + +const isConnectionReadOnlyStatement = ( + config: ConnectionReadOnlyLike, + statement: string, +): boolean => { + const dialect = resolveConnectionReadOnlyType(config); + if (dialect === "mongodb") { + return isReadOnlyMongoStatement(statement); + } + return isReadOnlySqlStatement(statement); +}; + +export const supportsConnectionReadOnlyMode = ( + config: ConnectionReadOnlyLike, +): boolean => { + return CONNECTION_READ_ONLY_TYPES.has(resolveConnectionReadOnlyType(config)); +}; + +export const isConnectionForcedReadOnly = ( + config: ConnectionReadOnlyLike, +): boolean => { + return supportsConnectionReadOnlyMode(config) && config?.readOnly === true; +}; + +export const findConnectionMutatingStatements = ( + config: ConnectionReadOnlyLike, + sql: string, +): string[] => { + if (!isConnectionForcedReadOnly(config)) { + return []; + } + return findSqlStatementRanges(String(sql || "")) + .map((range) => range.text.trim()) + .filter((statement) => statement.length > 0) + .filter((statement) => !isConnectionReadOnlyStatement(config, statement)); +}; diff --git a/frontend/src/utils/connectionRpcConfig.test.ts b/frontend/src/utils/connectionRpcConfig.test.ts index 1da959e..80f06ff 100644 --- a/frontend/src/utils/connectionRpcConfig.test.ts +++ b/frontend/src/utils/connectionRpcConfig.test.ts @@ -169,6 +169,19 @@ describe('buildRpcConnectionConfig', () => { expect(result.sslKeyPath).toBe('C:/certs/client-key.pem'); }); + it('preserves the connection-level readOnly guard flag for RPC calls', () => { + const result = buildRpcConnectionConfig({ + id: 'conn-prod', + type: 'postgres', + host: 'db.local', + port: 5432, + user: 'postgres', + readOnly: true, + } as any); + + expect(result.readOnly).toBe(true); + }); + it('fills default nested config blocks needed by RPC calls', () => { const result = buildRpcConnectionConfig({ id: 'conn-redis', diff --git a/frontend/src/utils/connectionRpcConfig.ts b/frontend/src/utils/connectionRpcConfig.ts index c096bee..f5e8046 100644 --- a/frontend/src/utils/connectionRpcConfig.ts +++ b/frontend/src/utils/connectionRpcConfig.ts @@ -129,6 +129,7 @@ export function buildRpcConnectionConfig( user: toStringValue(rpcMerged.user), password: toStringValue(rpcMerged.password), database: toStringValue(rpcMerged.database), + readOnly: rpcMerged.readOnly === true, useSSH: rpcMerged.useSSH === true, ssh: normalizeSSHConfig(rpcMerged.ssh), useProxy: rpcMerged.useProxy === true, diff --git a/frontend/src/utils/dataSourceCapabilities.test.ts b/frontend/src/utils/dataSourceCapabilities.test.ts index a2674a2..2d1720b 100644 --- a/frontend/src/utils/dataSourceCapabilities.test.ts +++ b/frontend/src/utils/dataSourceCapabilities.test.ts @@ -240,6 +240,27 @@ describe('dataSourceCapabilities', () => { }); }); + it('forces supported SQL connections marked read-only into query-only mode', () => { + expect(getDataSourceCapabilities({ type: 'postgres', readOnly: true })).toMatchObject({ + type: 'postgres', + supportsCreateDatabase: false, + supportsRenameDatabase: false, + supportsDropDatabase: false, + supportsMessagePublish: false, + forceReadOnlyQueryResult: true, + }); + }); + + it('ignores readOnly for datasource types that do not support connection-level production guard', () => { + expect(getDataSourceCapabilities({ type: 'redis', readOnly: true })).toMatchObject({ + type: 'redis', + supportsQueryEditor: false, + supportsCreateDatabase: false, + supportsDropDatabase: false, + forceReadOnlyQueryResult: false, + }); + }); + it('treats RabbitMQ as a queryable messaging datasource with publish support', () => { expect(getDataSourceCapabilities({ type: 'rabbitmq' })).toMatchObject({ type: 'rabbitmq', diff --git a/frontend/src/utils/dataSourceCapabilities.ts b/frontend/src/utils/dataSourceCapabilities.ts index 737e93b..915c203 100644 --- a/frontend/src/utils/dataSourceCapabilities.ts +++ b/frontend/src/utils/dataSourceCapabilities.ts @@ -1,7 +1,8 @@ import type { ConnectionConfig } from '../types'; +import { isConnectionForcedReadOnly } from './connectionReadOnly'; import { normalizeOceanBaseProtocol } from './oceanBaseProtocol'; -type ConnectionLike = Pick | null | undefined; +type ConnectionLike = Pick | null | undefined; const normalizeDataSourceToken = (raw: string): string => { const normalized = String(raw || '').trim().toLowerCase(); @@ -208,16 +209,17 @@ const DROP_DATABASE_TYPES = new Set([ export const getDataSourceCapabilities = (config: ConnectionLike): DataSourceCapabilities => { const type = resolveDataSourceType(config); + const forcedReadOnly = isConnectionForcedReadOnly(config); return { type, supportsQueryEditor: !QUERY_EDITOR_DISABLED_TYPES.has(type), supportsSqlQueryExport: SQL_QUERY_EXPORT_TYPES.has(type), supportsCopyInsert: COPY_INSERT_TYPES.has(type), - supportsCreateDatabase: CREATE_DATABASE_TYPES.has(type), - supportsRenameDatabase: RENAME_DATABASE_TYPES.has(type), - supportsDropDatabase: DROP_DATABASE_TYPES.has(type), - supportsMessagePublish: MESSAGE_PUBLISH_TYPES.has(type), - forceReadOnlyQueryResult: FORCE_READ_ONLY_QUERY_TYPES.has(type), + supportsCreateDatabase: !forcedReadOnly && CREATE_DATABASE_TYPES.has(type), + supportsRenameDatabase: !forcedReadOnly && RENAME_DATABASE_TYPES.has(type), + supportsDropDatabase: !forcedReadOnly && DROP_DATABASE_TYPES.has(type), + supportsMessagePublish: !forcedReadOnly && MESSAGE_PUBLISH_TYPES.has(type), + forceReadOnlyQueryResult: forcedReadOnly || FORCE_READ_ONLY_QUERY_TYPES.has(type), preferManualTotalCount: MANUAL_TOTAL_COUNT_TYPES.has(type), supportsApproximateTableCount: APPROXIMATE_TABLE_COUNT_TYPES.has(type), supportsApproximateTotalPages: APPROXIMATE_TOTAL_PAGE_TYPES.has(type), diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 113c599..ee7dcf3 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -912,6 +912,7 @@ export namespace connection { password: string; savePassword?: boolean; database: string; + readOnly?: boolean; useSSL?: boolean; sslMode?: string; sslCAPath?: string; @@ -963,6 +964,7 @@ export namespace connection { this.password = source["password"]; this.savePassword = source["savePassword"]; this.database = source["database"]; + this.readOnly = source["readOnly"]; this.useSSL = source["useSSL"]; this.sslMode = source["sslMode"]; this.sslCAPath = source["sslCAPath"]; diff --git a/internal/app/connection_readonly.go b/internal/app/connection_readonly.go new file mode 100644 index 0000000..89e73dc --- /dev/null +++ b/internal/app/connection_readonly.go @@ -0,0 +1,169 @@ +package app + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + "GoNavi-Wails/internal/connection" +) + +var connectionReadOnlySupportedTypes = map[string]struct{}{ + "clickhouse": {}, + "dameng": {}, + "diros": {}, + "duckdb": {}, + "gaussdb": {}, + "highgo": {}, + "iris": {}, + "kingbase": {}, + "mariadb": {}, + "mongodb": {}, + "mysql": {}, + "oceanbase": {}, + "opengauss": {}, + "oracle": {}, + "postgres": {}, + "sphinx": {}, + "sqlite": {}, + "sqlserver": {}, + "starrocks": {}, + "tdengine": {}, + "trino": {}, + "vastbase": {}, +} + +var mongoReadOnlyCommands = map[string]struct{}{ + "aggregate": {}, + "buildinfo": {}, + "collstats": {}, + "connectionstatus": {}, + "count": {}, + "countdocuments": {}, + "dbstats": {}, + "distinct": {}, + "explain": {}, + "find": {}, + "findone": {}, + "getparameter": {}, + "hello": {}, + "hostinfo": {}, + "ismaster": {}, + "listcollections": {}, + "listdatabases": {}, + "listindexes": {}, + "ping": {}, + "serverstatus": {}, +} + +var mongoWriteCommands = map[string]struct{}{ + "bulkwrite": {}, + "collmod": {}, + "create": {}, + "createindexes": {}, + "delete": {}, + "drop": {}, + "dropdatabase": {}, + "dropindexes": {}, + "findandmodify": {}, + "insert": {}, + "mapreduce": {}, + "renamecollection": {}, + "update": {}, +} + +var mongoMetaCommandKeys = map[string]struct{}{ + "$db": {}, + "$readpreference": {}, + "api": {}, + "apideprecationerrors": {}, + "apistrict": {}, + "comment": {}, + "let": {}, + "lsid": {}, + "maxtimems": {}, + "ordered": {}, + "readconcern": {}, + "writeconcern": {}, +} + +func supportsConnectionReadOnlyMode(config connection.ConnectionConfig) bool { + _, ok := connectionReadOnlySupportedTypes[resolveDDLDBType(config)] + return ok +} + +func isConnectionForcedReadOnly(config connection.ConnectionConfig) bool { + return config.ReadOnly && supportsConnectionReadOnlyMode(config) +} + +func readOnlyConnectionQueryBlockedMessage() string { + return "当前连接已启用生产保护,仅允许执行查询操作" +} + +func readOnlyConnectionActionBlockedMessage(action string) string { + label := strings.TrimSpace(action) + if label == "" { + return readOnlyConnectionQueryBlockedMessage() + } + return fmt.Sprintf("当前连接已启用生产保护,禁止执行%s", label) +} + +func ensureReadOnlyConnectionAllowsQuery(config connection.ConnectionConfig, query string) error { + if !isConnectionForcedReadOnly(config) { + return nil + } + for _, statement := range splitSQLStatements(query) { + if trimmed := strings.TrimSpace(statement); trimmed != "" && !isReadOnlySQLQuery(resolveDDLDBType(config), trimmed) { + return errors.New(readOnlyConnectionQueryBlockedMessage()) + } + } + return nil +} + +func ensureReadOnlyConnectionAllowsAction(config connection.ConnectionConfig, action string) error { + if !isConnectionForcedReadOnly(config) { + return nil + } + return errors.New(readOnlyConnectionActionBlockedMessage(action)) +} + +func isReadOnlyMongoCommand(query string) bool { + trimmed := strings.TrimSpace(query) + if !strings.HasPrefix(trimmed, "{") { + return false + } + var doc map[string]interface{} + if err := json.Unmarshal([]byte(trimmed), &doc); err != nil { + return false + } + commandKey := resolveMongoCommandKey(doc) + if commandKey == "" { + return false + } + if _, blocked := mongoWriteCommands[commandKey]; blocked { + return false + } + _, allowed := mongoReadOnlyCommands[commandKey] + return allowed +} + +func resolveMongoCommandKey(doc map[string]interface{}) string { + commandKey := "" + for key := range doc { + normalized := strings.ToLower(strings.TrimSpace(key)) + if normalized == "" { + continue + } + if _, isMeta := mongoMetaCommandKeys[normalized]; isMeta { + continue + } + if _, isWrite := mongoWriteCommands[normalized]; isWrite { + return normalized + } + if _, isRead := mongoReadOnlyCommands[normalized]; isRead { + commandKey = normalized + } + } + return commandKey +} diff --git a/internal/app/connection_readonly_test.go b/internal/app/connection_readonly_test.go new file mode 100644 index 0000000..5e65549 --- /dev/null +++ b/internal/app/connection_readonly_test.go @@ -0,0 +1,49 @@ +package app + +import ( + "strings" + "testing" + + "GoNavi-Wails/internal/connection" +) + +func TestSupportsConnectionReadOnlyMode(t *testing.T) { + if !supportsConnectionReadOnlyMode(connection.ConnectionConfig{Type: "postgres"}) { + t.Fatal("postgres should support connection-level production guard") + } + if !supportsConnectionReadOnlyMode(connection.ConnectionConfig{Type: "mongodb"}) { + t.Fatal("mongodb should support connection-level production guard") + } + if supportsConnectionReadOnlyMode(connection.ConnectionConfig{Type: "redis"}) { + t.Fatal("redis should not support connection-level production guard") + } +} + +func TestEnsureReadOnlyConnectionAllowsQuery(t *testing.T) { + sqlConfig := connection.ConnectionConfig{Type: "postgres", ReadOnly: true} + if err := ensureReadOnlyConnectionAllowsQuery(sqlConfig, "SELECT * FROM users"); err != nil { + t.Fatalf("read-only postgres connection should allow select: %v", err) + } + if err := ensureReadOnlyConnectionAllowsQuery(sqlConfig, "UPDATE users SET name = 'next'"); err == nil { + t.Fatal("read-only postgres connection should block update") + } + + mongoConfig := connection.ConnectionConfig{Type: "mongodb", ReadOnly: true} + if err := ensureReadOnlyConnectionAllowsQuery(mongoConfig, `{"find":"users","filter":{"active":true}}`); err != nil { + t.Fatalf("read-only mongodb connection should allow find: %v", err) + } + if err := ensureReadOnlyConnectionAllowsQuery(mongoConfig, `{"delete":"users","deletes":[{"q":{"active":false},"limit":0}]}`); err == nil { + t.Fatal("read-only mongodb connection should block delete") + } +} + +func TestEnsureReadOnlyConnectionAllowsAction(t *testing.T) { + config := connection.ConnectionConfig{Type: "postgres", ReadOnly: true} + err := ensureReadOnlyConnectionAllowsAction(config, "删除数据库") + if err == nil { + t.Fatal("read-only connection should block mutating actions") + } + if !strings.Contains(err.Error(), "删除数据库") { + t.Fatalf("blocked action message should include action label, got %q", err.Error()) + } +} diff --git a/internal/app/methods_db.go b/internal/app/methods_db.go index ebb27d5..beee092 100644 --- a/internal/app/methods_db.go +++ b/internal/app/methods_db.go @@ -179,6 +179,9 @@ func (a *App) CreateDatabase(config connection.ConnectionConfig, dbName string) if dbName == "" { return connection.QueryResult{Success: false, Message: "数据库名称不能为空"} } + if err := ensureReadOnlyConnectionAllowsAction(config, "创建数据库"); err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } runConfig := config runConfig.Database = "" @@ -321,6 +324,9 @@ func resolveSchemaDDLTargetDatabase(config connection.ConnectionConfig, dbName s } func (a *App) CreateSchema(config connection.ConnectionConfig, dbName string, schemaName string) connection.QueryResult { + if err := ensureReadOnlyConnectionAllowsAction(config, "创建模式"); err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } dbType := resolveDDLDBType(config) targetDbName, err := resolveSchemaDDLTargetDatabase(config, dbName) if err != nil { @@ -346,6 +352,9 @@ func (a *App) CreateSchema(config connection.ConnectionConfig, dbName string, sc } func (a *App) RenameSchema(config connection.ConnectionConfig, dbName string, oldSchemaName string, newSchemaName string) connection.QueryResult { + if err := ensureReadOnlyConnectionAllowsAction(config, "重命名模式"); err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } dbType := resolveDDLDBType(config) targetDbName, err := resolveSchemaDDLTargetDatabase(config, dbName) if err != nil { @@ -369,6 +378,9 @@ func (a *App) RenameSchema(config connection.ConnectionConfig, dbName string, ol } func (a *App) DropSchema(config connection.ConnectionConfig, dbName string, schemaName string) connection.QueryResult { + if err := ensureReadOnlyConnectionAllowsAction(config, "删除模式"); err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } dbType := resolveDDLDBType(config) targetDbName, err := resolveSchemaDDLTargetDatabase(config, dbName) if err != nil { @@ -624,6 +636,9 @@ func (a *App) RenameDatabase(config connection.ConnectionConfig, oldName string, if oldName == "" || newName == "" { return connection.QueryResult{Success: false, Message: "数据库名称不能为空"} } + if err := ensureReadOnlyConnectionAllowsAction(config, "重命名数据库"); err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } if strings.EqualFold(oldName, newName) { return connection.QueryResult{Success: false, Message: "新旧数据库名称不能相同"} } @@ -667,6 +682,9 @@ func (a *App) DropDatabase(config connection.ConnectionConfig, dbName string) co if dbName == "" { return connection.QueryResult{Success: false, Message: "数据库名称不能为空"} } + if err := ensureReadOnlyConnectionAllowsAction(config, "删除数据库"); err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } dbType := resolveDDLDBType(config) var ( @@ -701,6 +719,9 @@ func (a *App) RenameTable(config connection.ConnectionConfig, dbName string, old if oldTableName == "" || newTableName == "" { return connection.QueryResult{Success: false, Message: "表名不能为空"} } + if err := ensureReadOnlyConnectionAllowsAction(config, "重命名表"); err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } if strings.EqualFold(oldTableName, newTableName) { return connection.QueryResult{Success: false, Message: "新旧表名不能相同"} } @@ -753,6 +774,9 @@ func (a *App) DropTable(config connection.ConnectionConfig, dbName string, table if tableName == "" { return connection.QueryResult{Success: false, Message: "表名不能为空"} } + if err := ensureReadOnlyConnectionAllowsAction(config, "删除表"); err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } dbType := resolveDDLDBType(config) switch dbType { @@ -816,13 +840,17 @@ func (a *App) DBQueryWithCancel(config connection.ConnectionConfig, dbName strin queryID = generateQueryID() } + query = sanitizeSQLForPgLike(resolveDDLDBType(config), query) + if err := ensureReadOnlyConnectionAllowsQuery(config, query); err != nil { + return connection.QueryResult{Success: false, Message: err.Error(), QueryID: queryID} + } + dbInst, err := a.getDatabase(runConfig) if err != nil { logger.Error(err, "DBQuery 获取连接失败:%s", formatConnSummary(runConfig)) return connection.QueryResult{Success: false, Message: err.Error(), QueryID: queryID} } - query = sanitizeSQLForPgLike(resolveDDLDBType(config), query) ctx, cancel := newQueryExecutionContext(runConfig) defer cancel() @@ -930,13 +958,17 @@ func (a *App) DBQueryMulti(config connection.ConnectionConfig, dbName string, qu queryID = generateQueryID() } + query = sanitizeSQLForPgLike(resolveDDLDBType(config), query) + if err := ensureReadOnlyConnectionAllowsQuery(config, query); err != nil { + return connection.QueryResult{Success: false, Message: err.Error(), QueryID: queryID} + } + dbInst, err := a.getDatabase(runConfig) if err != nil { logger.Error(err, "DBQueryMulti 获取连接失败:%s", formatConnSummary(runConfig)) return connection.QueryResult{Success: false, Message: err.Error(), QueryID: queryID} } - query = sanitizeSQLForPgLike(resolveDDLDBType(config), query) ctx, cancel := newQueryExecutionContext(runConfig) defer cancel() @@ -1340,6 +1372,11 @@ func looksLikeSQLServerProcedureInvocation(query string) bool { func (a *App) DBQueryIsolated(config connection.ConnectionConfig, dbName string, query string) connection.QueryResult { runConfig := normalizeRunConfig(config, dbName) + query = sanitizeSQLForPgLike(resolveDDLDBType(config), query) + if err := ensureReadOnlyConnectionAllowsQuery(config, query); err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + dbInst, err := a.openDatabaseIsolated(runConfig) if err != nil { logger.Error(err, "DBQueryIsolated 获取连接失败:%s", formatConnSummary(runConfig)) @@ -1351,7 +1388,6 @@ func (a *App) DBQueryIsolated(config connection.ConnectionConfig, dbName string, } }() - query = sanitizeSQLForPgLike(resolveDDLDBType(config), query) ctx, cancel := newQueryExecutionContext(runConfig) defer cancel() @@ -2205,6 +2241,9 @@ func (a *App) DropView(config connection.ConnectionConfig, dbName string, viewNa if viewName == "" { return connection.QueryResult{Success: false, Message: "视图名称不能为空"} } + if err := ensureReadOnlyConnectionAllowsAction(config, "删除视图"); err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } dbType := resolveDDLDBType(config) switch dbType { @@ -2237,6 +2276,9 @@ func (a *App) DropFunction(config connection.ConnectionConfig, dbName string, ro if routineName == "" { return connection.QueryResult{Success: false, Message: "函数/存储过程名称不能为空"} } + if err := ensureReadOnlyConnectionAllowsAction(config, "删除函数或存储过程"); err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } if routineType != "FUNCTION" && routineType != "PROCEDURE" { routineType = "FUNCTION" } @@ -2280,6 +2322,9 @@ func (a *App) RenameView(config connection.ConnectionConfig, dbName string, oldN if oldName == "" || newName == "" { return connection.QueryResult{Success: false, Message: "视图名称不能为空"} } + if err := ensureReadOnlyConnectionAllowsAction(config, "重命名视图"); err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } if strings.EqualFold(oldName, newName) { return connection.QueryResult{Success: false, Message: "新旧视图名称不能相同"} } diff --git a/internal/app/methods_db_transaction.go b/internal/app/methods_db_transaction.go index 6089737..aa87885 100644 --- a/internal/app/methods_db_transaction.go +++ b/internal/app/methods_db_transaction.go @@ -28,6 +28,9 @@ func (a *App) DBQueryMultiTransactional(config connection.ConnectionConfig, dbNa } query = sanitizeSQLForPgLike(transactionDBType, query) + if err := ensureReadOnlyConnectionAllowsQuery(config, query); err != nil { + return connection.QueryResult{Success: false, Message: err.Error(), QueryID: queryID} + } if !shouldUseManagedSQLTransaction(transactionDBType, query) { return a.DBQueryMulti(config, dbName, query, queryID) } diff --git a/internal/app/methods_file.go b/internal/app/methods_file.go index 59e4aa3..ebe2d08 100644 --- a/internal/app/methods_file.go +++ b/internal/app/methods_file.go @@ -1821,6 +1821,9 @@ func (a *App) PreviewImportFile(filePath string) connection.QueryResult { } func (a *App) ImportData(config connection.ConnectionConfig, dbName, tableName string) connection.QueryResult { + if err := ensureReadOnlyConnectionAllowsAction(config, "导入数据"); err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } selection, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{ Title: fmt.Sprintf("Import into %s", tableName), Filters: []runtime.FileFilter{ @@ -2164,6 +2167,9 @@ func formatImportSQLValue(dbType, columnType string, value interface{}) string { // ImportDataWithProgress 执行导入并发送进度事件 func (a *App) ImportDataWithProgress(config connection.ConnectionConfig, dbName, tableName, filePath string) connection.QueryResult { + if err := ensureReadOnlyConnectionAllowsAction(config, "导入数据"); err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } runConfig := normalizeRunConfig(config, dbName) dbInst, err := a.getDatabase(runConfig) if err != nil { @@ -2211,6 +2217,9 @@ func (a *App) ImportDataWithProgress(config connection.ConnectionConfig, dbName, } func (a *App) ApplyChanges(config connection.ConnectionConfig, dbName, tableName string, changes connection.ChangeSet) connection.QueryResult { + if err := ensureReadOnlyConnectionAllowsAction(config, "提交结果修改"); err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } runConfig := normalizeRunConfig(config, dbName) dbInst, err := a.getDatabase(runConfig) @@ -2237,6 +2246,9 @@ type ChangePreview struct { } func (a *App) PreviewChanges(config connection.ConnectionConfig, dbName, tableName string, changes connection.ChangeSet) connection.QueryResult { + if err := ensureReadOnlyConnectionAllowsAction(config, "预览结果修改"); err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } runConfig := normalizeRunConfig(config, dbName) dbInst, err := a.getDatabase(runConfig) @@ -2733,6 +2745,10 @@ func tableDataClearActionLabels(mode tableDataClearMode) (actionLabel string, pr } func (a *App) runTableDataClear(config connection.ConnectionConfig, dbName string, tableNames []string, mode tableDataClearMode) connection.QueryResult { + actionLabel, progressLabel := tableDataClearActionLabels(mode) + if err := ensureReadOnlyConnectionAllowsAction(config, actionLabel); err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } runConfig := normalizeRunConfig(config, dbName) // 参数校验 @@ -2767,7 +2783,6 @@ func (a *App) runTableDataClear(config connection.ConnectionConfig, dbName strin return connection.QueryResult{Success: false, Message: err.Error()} } - actionLabel, progressLabel := tableDataClearActionLabels(mode) logger.Warnf("%s 开始:%s db=%s tables=%v(共 %d 张)", actionLabel, formatConnSummary(runConfig), dbName, objects, len(objects)) var executedSQLs []string diff --git a/internal/app/methods_sync.go b/internal/app/methods_sync.go index a6c388e..bce7638 100644 --- a/internal/app/methods_sync.go +++ b/internal/app/methods_sync.go @@ -60,6 +60,13 @@ func (a *App) resolveDataSyncEndpointConfig(raw connection.ConnectionConfig, sel // DataSync executes a data synchronization task func (a *App) DataSync(config sync.SyncConfig) sync.SyncResult { + if err := ensureReadOnlyConnectionAllowsAction(config.TargetConfig, "数据同步写入"); err != nil { + return sync.SyncResult{ + Success: false, + Message: err.Error(), + Logs: []string{err.Error()}, + } + } jobID := strings.TrimSpace(config.JobID) if jobID == "" { jobID = fmt.Sprintf("sync-%d", time.Now().UnixNano()) diff --git a/internal/app/sql_sanitize.go b/internal/app/sql_sanitize.go index b2dfe5d..ba09652 100644 --- a/internal/app/sql_sanitize.go +++ b/internal/app/sql_sanitize.go @@ -436,8 +436,8 @@ func sqlServerControlFlowMayReturnMessages(query string) bool { } func isReadOnlySQLQuery(dbType string, query string) bool { - if strings.ToLower(strings.TrimSpace(dbType)) == "mongodb" && strings.HasPrefix(strings.TrimSpace(query), "{") { - return true + if strings.ToLower(strings.TrimSpace(dbType)) == "mongodb" { + return isReadOnlyMongoCommand(query) } keyword, withHasWrite := sqlDataOperationInfo(query) diff --git a/internal/app/sql_sanitize_test.go b/internal/app/sql_sanitize_test.go index 870f08a..6947dd8 100644 --- a/internal/app/sql_sanitize_test.go +++ b/internal/app/sql_sanitize_test.go @@ -99,6 +99,18 @@ func TestIsReadOnlySQLQuery_TreatsKafkaConsumeAsReadOnly(t *testing.T) { } } +func TestIsReadOnlySQLQuery_TreatsMongoFindAsReadOnly(t *testing.T) { + if !isReadOnlySQLQuery("mongodb", `{"find":"users","filter":{"active":true}}`) { + t.Fatal("MongoDB find command should be treated as read-only") + } +} + +func TestIsReadOnlySQLQuery_TreatsMongoDeleteAsWrite(t *testing.T) { + if isReadOnlySQLQuery("mongodb", `{"delete":"users","deletes":[{"q":{"active":false},"limit":0}]}`) { + t.Fatal("MongoDB delete command should not be treated as read-only") + } +} + func TestIsBatchableWriteSQLStatement_OnlyMatchesRealWriteStatements(t *testing.T) { if !isBatchableWriteSQLStatement("mysql", "INSERT INTO demo(id) VALUES (1)") { t.Fatal("expected INSERT to be treated as batchable write") diff --git a/internal/connection/types.go b/internal/connection/types.go index c2a8f32..e059f7c 100644 --- a/internal/connection/types.go +++ b/internal/connection/types.go @@ -87,6 +87,7 @@ type ConnectionConfig struct { Password string `json:"password"` SavePassword bool `json:"savePassword,omitempty"` // Persist password in saved connection Database string `json:"database"` + ReadOnly bool `json:"readOnly,omitempty"` // Production guard: allow query-only operations on supported databases UseSSL bool `json:"useSSL,omitempty"` // MySQL-like SSL/TLS switch SSLMode string `json:"sslMode,omitempty"` // preferred | required | skip-verify | disable SSLCAPath string `json:"sslCAPath,omitempty"` // TLS root CA / server certificate path diff --git a/shared/i18n/de-DE.json b/shared/i18n/de-DE.json index 27f4200..99ea9c4 100644 --- a/shared/i18n/de-DE.json +++ b/shared/i18n/de-DE.json @@ -750,6 +750,9 @@ "connection_modal.field.service_name": "Servicename", "connection_modal.field.defaultDatabase.placeholder": "Zum Beispiel: appdb", "connection_modal.field.serviceName.placeholder": "Zum Beispiel: ORCLPDB1", + "connection_modal.field.readOnly.label": "Produktionsschutz", + "connection_modal.field.readOnly.help": "Wenn aktiviert, sind für diese Verbindung nur Abfragen erlaubt. Import, Schemaänderungen, Schreibvorgänge und die Nutzung als Synchronisationsziel werden blockiert.", + "connection_modal.field.readOnly.checkbox": "Diese Verbindung als Produktionsverbindung markieren und nur Abfragen erlauben", "connection_modal.field.clickHouseProtocol.auto": "Automatisch", "connection_modal.field.oceanBaseProtocol.label": "OceanBase-Protokoll", "connection_modal.field.oceanBaseProtocol.help.primary": "Wählen Sie für MySQL-Mandanten MySQL und für Oracle-Mandanten Oracle. GoNavi wählt anhand des Ports automatisch: Für den OB MySQL wire-Port wird die OBClient-Capability-Injektion verwendet (derselbe Pfad wie in Navicat), für den OBProxy Oracle listener-Port Standard-TNS.", @@ -2216,6 +2219,7 @@ "query_editor.message.select_database_first": "Wählen Sie zuerst eine Datenbank aus.", "query_editor.message.connection_not_found": "Verbindung nicht gefunden.", "query_editor.message.unsupported_source": "Diese Datenquelle unterstützt den SQL-Abfrageeditor nicht. Verwenden Sie stattdessen die zugehörige Seite.", + "query_editor.message.connection_readonly_blocked": "Für diese Verbindung ist der Produktionsschutz aktiv; es sind nur Abfragen erlaubt.", "query_editor.message.no_executable_sql": "Kein ausführbares SQL.", "query_editor.message.no_selectable_sql": "Keine auswählbare SQL-Anweisung.", "query_editor.message.statement_failed_prefix": "Anweisung {{index}} fehlgeschlagen: ", diff --git a/shared/i18n/en-US.json b/shared/i18n/en-US.json index 833b023..00604c6 100644 --- a/shared/i18n/en-US.json +++ b/shared/i18n/en-US.json @@ -750,6 +750,9 @@ "connection_modal.field.service_name": "Service name", "connection_modal.field.defaultDatabase.placeholder": "For example: appdb", "connection_modal.field.serviceName.placeholder": "For example: ORCLPDB1", + "connection_modal.field.readOnly.label": "Production guard", + "connection_modal.field.readOnly.help": "When enabled, this connection only allows queries. Import, schema changes, data writes, and sync target operations are blocked.", + "connection_modal.field.readOnly.checkbox": "Mark this as a production connection and allow queries only", "connection_modal.field.clickHouseProtocol.auto": "Auto", "connection_modal.field.oceanBaseProtocol.label": "OceanBase protocol", "connection_modal.field.oceanBaseProtocol.help.primary": "Choose MySQL for MySQL tenants and Oracle for Oracle tenants. GoNavi selects automatically by port: OB MySQL wire ports use OBClient capability injection (the same path as Navicat), while OBProxy Oracle listener ports use standard TNS.", @@ -2226,6 +2229,7 @@ "query_editor.message.select_database_first": "Select a database first.", "query_editor.message.connection_not_found": "Connection not found.", "query_editor.message.unsupported_source": "This data source does not support the SQL query editor. Use its dedicated page instead.", + "query_editor.message.connection_readonly_blocked": "This connection has production guard enabled and only allows query operations.", "query_editor.message.no_executable_sql": "No executable SQL.", "query_editor.message.no_selectable_sql": "No selectable SQL statement.", "query_editor.message.statement_failed_prefix": "Statement {{index}} failed: ", diff --git a/shared/i18n/ja-JP.json b/shared/i18n/ja-JP.json index 06372dc..5cd2be0 100644 --- a/shared/i18n/ja-JP.json +++ b/shared/i18n/ja-JP.json @@ -750,6 +750,9 @@ "connection_modal.field.service_name": "サービス名", "connection_modal.field.defaultDatabase.placeholder": "例: appdb", "connection_modal.field.serviceName.placeholder": "例: ORCLPDB1", + "connection_modal.field.readOnly.label": "本番接続ガード", + "connection_modal.field.readOnly.help": "有効にすると、この接続では問い合わせのみ許可されます。インポート、スキーマ変更、データ書き込み、同期先としての利用は拒否されます。", + "connection_modal.field.readOnly.checkbox": "この接続を本番接続として扱い、問い合わせのみ許可する", "connection_modal.field.clickHouseProtocol.auto": "自動", "connection_modal.field.oceanBaseProtocol.label": "OceanBase プロトコル", "connection_modal.field.oceanBaseProtocol.help.primary": "MySQL テナントには MySQL、Oracle テナントには Oracle を選択します。GoNavi はポートに応じて自動選択します。OB MySQL wire ポートでは OBClient capability injection(Navicat と同じ経路)を使い、OBProxy Oracle listener ポートでは標準 TNS を使います。", @@ -2216,6 +2219,7 @@ "query_editor.message.select_database_first": "先にデータベースを選択してください。", "query_editor.message.connection_not_found": "接続が見つかりません。", "query_editor.message.unsupported_source": "このデータソースは SQL クエリエディターに対応していません。専用ページを使用してください。", + "query_editor.message.connection_readonly_blocked": "この接続では本番保護が有効なため、問い合わせ操作のみ実行できます。", "query_editor.message.no_executable_sql": "実行できる SQL がありません。", "query_editor.message.no_selectable_sql": "選択できる SQL ステートメントがありません。", "query_editor.message.statement_failed_prefix": "{{index}} 番目のステートメントが失敗しました: ", diff --git a/shared/i18n/ru-RU.json b/shared/i18n/ru-RU.json index c15bdf2..3ae0f60 100644 --- a/shared/i18n/ru-RU.json +++ b/shared/i18n/ru-RU.json @@ -750,6 +750,9 @@ "connection_modal.field.service_name": "Имя сервиса", "connection_modal.field.defaultDatabase.placeholder": "Например: appdb", "connection_modal.field.serviceName.placeholder": "Например: ORCLPDB1", + "connection_modal.field.readOnly.label": "Защита прод-подключения", + "connection_modal.field.readOnly.help": "Если включено, для этого подключения разрешены только запросы. Импорт, изменения схемы, запись данных и использование как цели синхронизации будут запрещены.", + "connection_modal.field.readOnly.checkbox": "Пометить это подключение как production и разрешить только запросы", "connection_modal.field.clickHouseProtocol.auto": "Авто", "connection_modal.field.oceanBaseProtocol.label": "Протокол OceanBase", "connection_modal.field.oceanBaseProtocol.help.primary": "Для арендаторов MySQL выберите MySQL, для арендаторов Oracle выберите Oracle. GoNavi автоматически выбирает режим по порту: для порта OB MySQL wire используется внедрение возможностей OBClient (тот же путь, что и в Navicat), для порта OBProxy Oracle listener используется стандартный TNS.", @@ -2216,6 +2219,7 @@ "query_editor.message.select_database_first": "Сначала выберите базу данных.", "query_editor.message.connection_not_found": "Подключение не найдено.", "query_editor.message.unsupported_source": "Этот источник данных не поддерживает редактор SQL-запросов. Используйте соответствующую страницу.", + "query_editor.message.connection_readonly_blocked": "Для этого подключения включена защита production, разрешены только операции запроса.", "query_editor.message.no_executable_sql": "Нет SQL для выполнения.", "query_editor.message.no_selectable_sql": "Нет SQL-инструкции для выбора.", "query_editor.message.statement_failed_prefix": "Ошибка в инструкции {{index}}: ", diff --git a/shared/i18n/zh-CN.json b/shared/i18n/zh-CN.json index 15cb104..a443369 100644 --- a/shared/i18n/zh-CN.json +++ b/shared/i18n/zh-CN.json @@ -750,6 +750,9 @@ "connection_modal.field.service_name": "服务名", "connection_modal.field.defaultDatabase.placeholder": "例如:appdb", "connection_modal.field.serviceName.placeholder": "例如:ORCLPDB1", + "connection_modal.field.readOnly.label": "生产连接保护", + "connection_modal.field.readOnly.help": "启用后当前连接仅允许查询,禁止导入、结构变更、数据写入和作为同步目标。", + "connection_modal.field.readOnly.checkbox": "标记为生产连接,只允许查询", "connection_modal.field.clickHouseProtocol.auto": "自动", "connection_modal.field.oceanBaseProtocol.label": "OceanBase 协议", "connection_modal.field.oceanBaseProtocol.help.primary": "MySQL 租户请选择 MySQL;Oracle 租户请选择 Oracle。GoNavi 会根据端口自动选择:OB MySQL wire 端口走 OBClient capability 注入(与 Navicat 相同路径),OBProxy Oracle listener 端口走标准 TNS。", @@ -2226,6 +2229,7 @@ "query_editor.message.select_database_first": "请先选择数据库。", "query_editor.message.connection_not_found": "未找到连接。", "query_editor.message.unsupported_source": "当前数据源不支持 SQL 查询编辑器,请使用对应专用页面。", + "query_editor.message.connection_readonly_blocked": "当前连接已启用生产保护,仅允许执行查询操作。", "query_editor.message.no_executable_sql": "没有可执行的 SQL。", "query_editor.message.no_selectable_sql": "没有可选择的 SQL 语句。", "query_editor.message.statement_failed_prefix": "第 {{index}} 条语句执行失败:", diff --git a/shared/i18n/zh-TW.json b/shared/i18n/zh-TW.json index bca30a1..e77b2d1 100644 --- a/shared/i18n/zh-TW.json +++ b/shared/i18n/zh-TW.json @@ -750,6 +750,9 @@ "connection_modal.field.service_name": "服務名稱", "connection_modal.field.defaultDatabase.placeholder": "例如:appdb", "connection_modal.field.serviceName.placeholder": "例如:ORCLPDB1", + "connection_modal.field.readOnly.label": "正式連線保護", + "connection_modal.field.readOnly.help": "啟用後目前連線僅允許查詢,禁止匯入、結構變更、資料寫入與作為同步目標。", + "connection_modal.field.readOnly.checkbox": "標記為正式連線,只允許查詢", "connection_modal.field.clickHouseProtocol.auto": "自動", "connection_modal.field.oceanBaseProtocol.label": "OceanBase 協議", "connection_modal.field.oceanBaseProtocol.help.primary": "MySQL 租戶請選擇 MySQL;Oracle 租戶請選擇 Oracle。GoNavi 會依連接埠自動選擇:OB MySQL wire 連接埠走 OBClient capability 注入(與 Navicat 相同路徑),OBProxy Oracle listener 連接埠走標準 TNS。", @@ -2216,6 +2219,7 @@ "query_editor.message.select_database_first": "請先選擇資料庫。", "query_editor.message.connection_not_found": "找不到連線。", "query_editor.message.unsupported_source": "此資料來源不支援 SQL 查詢編輯器。請改用其專用頁面。", + "query_editor.message.connection_readonly_blocked": "目前連線已啟用正式保護,僅允許執行查詢操作。", "query_editor.message.no_executable_sql": "沒有可執行的 SQL。", "query_editor.message.no_selectable_sql": "沒有可選取的 SQL 陳述式。", "query_editor.message.statement_failed_prefix": "第 {{index}} 個陳述式執行失敗:",