From afc9c2ffdf868d8286014b07db9cb5d6ff7fd9c4 Mon Sep 17 00:00:00 2001 From: Guillaume Vaillant <7757451+pokertour@users.noreply.github.com> Date: Mon, 9 Nov 2026 22:24:28 +0100 Subject: [PATCH 1/2] feat(connections): add enforced read-only mode per connection --- packages/web-ui/src/api/contract.ts | 1 + .../connections/ConnectionChips.tsx | 7 +- .../src/components/layout/MainLayout.tsx | 2 + .../src/components/layout/ReadOnlyBanner.tsx | 19 + .../components/modals/NewConnectionModal.tsx | 34 ++ .../web-ui/src/contexts/DatabaseContext.ts | 1 + .../web-ui/src/contexts/DatabaseProvider.tsx | 31 +- packages/web-ui/src/hooks/useDrivers.ts | 3 + .../web-ui/src/hooks/useProductionGuard.ts | 15 +- packages/web-ui/src/i18n/locales/de.json | 7 + packages/web-ui/src/i18n/locales/en.json | 7 + packages/web-ui/src/i18n/locales/es.json | 7 + packages/web-ui/src/i18n/locales/fr.json | 7 + packages/web-ui/src/i18n/locales/it.json | 7 + packages/web-ui/src/i18n/locales/ja.json | 7 + packages/web-ui/src/i18n/locales/ko.json | 7 + packages/web-ui/src/i18n/locales/pt-BR.json | 7 + packages/web-ui/src/i18n/locales/ru.json | 7 + packages/web-ui/src/i18n/locales/tl.json | 7 + packages/web-ui/src/i18n/locales/vi.json | 7 + packages/web-ui/src/i18n/locales/zh-TW.json | 7 + packages/web-ui/src/i18n/locales/zh.json | 7 + packages/web-ui/src/types/plugins.ts | 2 + packages/web-ui/src/utils/connectionPolicy.ts | 27 ++ .../components/layout/MainLayout.test.tsx | 4 + .../modals/NewConnectionModal.test.tsx | 43 +++ .../tests/contexts/DatabaseProvider.test.tsx | 58 +++ .../tests/hooks/useProductionGuard.test.tsx | 67 ++++ .../tests/utils/connectionPolicy.test.ts | 58 +++ plugins/PLUGIN_GUIDE.md | 48 +++ plugins/manifest.schema.json | 5 + src-tauri/src/application/connections.rs | 3 + src-tauri/src/application/database_objects.rs | 13 + .../src/application/database_transfers.rs | 28 +- src-tauri/src/application/mod.rs | 1 + src-tauri/src/application/queries.rs | 95 ++++- src-tauri/src/application/read_only.rs | 71 ++++ src-tauri/src/application/read_only/tests.rs | 338 ++++++++++++++++++ src-tauri/src/application/records.rs | 3 + src-tauri/src/application/table_transfers.rs | 18 +- src-tauri/src/clipboard_import.rs | 1 + src-tauri/src/commands.rs | 1 + src-tauri/src/drivers/driver_trait.rs | 4 + src-tauri/src/drivers/mysql/mod.rs | 1 + src-tauri/src/drivers/postgres/mod.rs | 1 + src-tauri/src/drivers/sqlite/mod.rs | 12 +- src-tauri/src/mcp/mod.rs | 21 +- src-tauri/src/models.rs | 7 + src-tauri/src/plugins/driver.rs | 38 ++ src-tauri/src/plugins/read_only_tests.rs | 101 ++++++ src-tauri/src/pool_manager.rs | 47 ++- src-tauri/src/pool_manager/read_only_tests.rs | 89 +++++ .../tests/postgres_integration/parity.rs | 1 + 53 files changed, 1361 insertions(+), 49 deletions(-) create mode 100644 packages/web-ui/src/components/layout/ReadOnlyBanner.tsx create mode 100644 packages/web-ui/src/utils/connectionPolicy.ts create mode 100644 packages/web-ui/tests/hooks/useProductionGuard.test.tsx create mode 100644 packages/web-ui/tests/utils/connectionPolicy.test.ts create mode 100644 src-tauri/src/application/read_only.rs create mode 100644 src-tauri/src/application/read_only/tests.rs create mode 100644 src-tauri/src/plugins/read_only_tests.rs create mode 100644 src-tauri/src/pool_manager/read_only_tests.rs diff --git a/packages/web-ui/src/api/contract.ts b/packages/web-ui/src/api/contract.ts index e3ca2c9b6..358110736 100644 --- a/packages/web-ui/src/api/contract.ts +++ b/packages/web-ui/src/api/contract.ts @@ -129,6 +129,7 @@ export interface ThemePackageRequest { export type UiStateEntries = Readonly>; export interface ConnectionParameters { + read_only?: boolean; driver: string; database: string | string[]; default_database?: string; diff --git a/packages/web-ui/src/components/connections/ConnectionChips.tsx b/packages/web-ui/src/components/connections/ConnectionChips.tsx index 18dfd26e9..05039db17 100644 --- a/packages/web-ui/src/components/connections/ConnectionChips.tsx +++ b/packages/web-ui/src/components/connections/ConnectionChips.tsx @@ -1,5 +1,5 @@ import type {SyntheticEvent} from 'react'; -import {Shield, PlugZap} from 'lucide-react'; +import {Shield, PlugZap, LockKeyhole} from 'lucide-react'; import {Link} from 'react-router-dom'; import {useTranslation} from 'react-i18next'; import type {SavedConnection} from '@/contexts/DatabaseContext'; @@ -84,6 +84,11 @@ export const ConnectionChips = ({ )} + {(conn.params.read_only || driverManifest?.capabilities.readonly) && ( + }> + {t('readOnly.label')} + + )} {conn.params.ssh_enabled && ( }> diff --git a/packages/web-ui/src/components/layout/MainLayout.tsx b/packages/web-ui/src/components/layout/MainLayout.tsx index 4f2ef4d76..969a56828 100644 --- a/packages/web-ui/src/components/layout/MainLayout.tsx +++ b/packages/web-ui/src/components/layout/MainLayout.tsx @@ -15,6 +15,7 @@ import {ROOT_COMMAND_SCOPE_ID} from '@/utils/commandScopeStore'; import {CommandPaletteModal} from '@/components/modals/CommandPaletteModal'; import {CommandPaletteScopeBridge} from './CommandPaletteScopeBridge'; import {ProductionBanner} from './ProductionBanner'; +import {ReadOnlyBanner} from './ReadOnlyBanner'; import {RightSidebar} from './RightSidebar'; import {Sidebar} from './Sidebar'; const SplitPaneLayout = lazy(() => @@ -42,6 +43,7 @@ const MainLayoutContent = () => {
{isEditorRoute(location.pathname) && } + {isEditorRoute(location.pathname) && } {/* Routed pages size themselves with h-full, which resolves against this wrapper — not against
. Without it the production banner would push the page down without shrinking it, clipping the bottom diff --git a/packages/web-ui/src/components/layout/ReadOnlyBanner.tsx b/packages/web-ui/src/components/layout/ReadOnlyBanner.tsx new file mode 100644 index 000000000..c4127861c --- /dev/null +++ b/packages/web-ui/src/components/layout/ReadOnlyBanner.tsx @@ -0,0 +1,19 @@ +import {LockKeyhole} from 'lucide-react'; +import {useTranslation} from 'react-i18next'; +import {useDatabase} from '@/hooks/useDatabase'; + +export function ReadOnlyBanner() { + const {t} = useTranslation(); + const {activeConnectionId, connections} = useDatabase(); + const connection = connections.find((entry) => entry.id === activeConnectionId); + if (!connection?.params.read_only) return null; + return ( +
+
+ ); +} diff --git a/packages/web-ui/src/components/modals/NewConnectionModal.tsx b/packages/web-ui/src/components/modals/NewConnectionModal.tsx index b28b1cc96..e50be41a3 100644 --- a/packages/web-ui/src/components/modals/NewConnectionModal.tsx +++ b/packages/web-ui/src/components/modals/NewConnectionModal.tsx @@ -82,6 +82,7 @@ import { supportsQueryTimeout, } from '@/utils/driverCapabilities'; import {toErrorMessage} from '@/utils/errors'; +import {supportsReadOnlyQueries} from '@/utils/connectionPolicy'; import {classifyConnectionError, type ClassifiedConnectionError} from '@/utils/connectionErrors'; import {ConnectionDiagnosticsModal} from './connection/ConnectionDiagnosticsModal'; import {testStepLabelKey, type ConnectionTestLogEntry} from '@/utils/connectionTest'; @@ -126,6 +127,7 @@ const paradigmAccent = (p: string): string => PARADIGM_ACCENT[p] ?? '#64748b'; interface ConnectionParams { driver: string; + read_only?: boolean; host?: string; port?: number; username?: string; @@ -2440,6 +2442,12 @@ export const NewConnectionModal = ({ const params = withoutBlankSslMode( withInlineK8sPaths(paramsBase, inlinePaths.options), ) as ConnectionParams; + if (params.read_only && !supportsReadOnlyQueries(activeDriver?.capabilities)) { + setStatus('error'); + setMessage(t('readOnly.unsupported')); + setTestResult('error'); + return; + } // Only drivers with capabilities.query_timeout honour the field; // drop a stale value so a plugin connection can't save a no-op. if (!showsQueryTimeout) { @@ -3216,6 +3224,32 @@ export const NewConnectionModal = ({ // ── rendered Advanced tab content (driver-specific options + startup SQL) ── const advancedTabContent = (
+
+ +

+ {t( + supportsReadOnlyQueries(activeDriver?.capabilities) + ? 'readOnly.description' + : 'readOnly.unsupported', + )} +

+
{/* MySQL: PIPES_AS_CONCAT compatibility (Vitess/PlanetScale) */} {driver === 'mysql' && (
diff --git a/packages/web-ui/src/contexts/DatabaseContext.ts b/packages/web-ui/src/contexts/DatabaseContext.ts index 3b6cf1c8a..1e622dcc1 100644 --- a/packages/web-ui/src/contexts/DatabaseContext.ts +++ b/packages/web-ui/src/contexts/DatabaseContext.ts @@ -54,6 +54,7 @@ export interface SavedConnection { plugin_secret_keys?: string[]; params: { driver: string; + read_only?: boolean; host?: string; database: string | string[]; default_database?: string; diff --git a/packages/web-ui/src/contexts/DatabaseProvider.tsx b/packages/web-ui/src/contexts/DatabaseProvider.tsx index dbc647710..1a745b4df 100644 --- a/packages/web-ui/src/contexts/DatabaseProvider.tsx +++ b/packages/web-ui/src/contexts/DatabaseProvider.tsx @@ -19,6 +19,7 @@ import type {PluginManifest} from '@/types/plugins'; import {clearAutocompleteCache} from '@/utils/autocomplete'; import {loadOptionalMetadata} from '@/utils/connectionMetadata'; import {toErrorMessage} from '@/utils/errors'; +import {effectiveConnectionCapabilities} from '@/utils/connectionPolicy'; import {useSettings} from '@/hooks/useSettings'; import {useToast} from '@/hooks/useToast'; import {findConnectionsForDrivers} from '@/utils/connectionManager'; @@ -148,7 +149,12 @@ export const DatabaseProvider = ({children}: {children: ReactNode}) => { const activeData = getActiveConnectionData(); const activeDriver = activeData?.driver ?? null; - const activeCapabilities = activeData?.capabilities ?? null; + const activeReadOnly = connections.find((connection) => connection.id === activeConnectionId) + ?.params.read_only; + const activeCapabilities = useMemo( + () => effectiveConnectionCapabilities(activeData?.capabilities ?? null, activeReadOnly), + [activeData?.capabilities, activeReadOnly], + ); const activeConnectionName = activeData?.connectionName ?? null; const activeDatabaseName = activeData?.databaseName ?? null; const tables = activeData?.tables ?? []; @@ -2092,11 +2098,24 @@ export const DatabaseProvider = ({children}: {children: ReactNode}) => { [client], ); + const connectionDataWithPolicy = useMemo(() => { + const effective = {...connectionDataMap}; + for (const connection of connections) { + const data = effective[connection.id]; + if (data && connection.params.read_only) { + effective[connection.id] = { + ...data, + capabilities: effectiveConnectionCapabilities(data.capabilities, true), + }; + } + } + return effective; + }, [connectionDataMap, connections]); + const getConnectionData = useCallback( - (connectionId: string): ConnectionData | undefined => { - return connectionDataMap[connectionId]; - }, - [connectionDataMap], + (connectionId: string): ConnectionData | undefined => + connectionDataWithPolicy[connectionId], + [connectionDataWithPolicy], ); const isConnectionOpen = useCallback( @@ -2414,7 +2433,7 @@ export const DatabaseProvider = ({children}: {children: ReactNode}) => { value={{ activeConnectionId, openConnectionIds, - connectionDataMap, + connectionDataMap: connectionDataWithPolicy, activeTable, activeDriver, activeCapabilities, diff --git a/packages/web-ui/src/hooks/useDrivers.ts b/packages/web-ui/src/hooks/useDrivers.ts index 851e9fc51..3ee2f7796 100644 --- a/packages/web-ui/src/hooks/useDrivers.ts +++ b/packages/web-ui/src/hooks/useDrivers.ts @@ -44,6 +44,7 @@ const FALLBACK_DRIVERS: PluginManifest[] = [ supports_ssl: true, query_timeout: true, sql_dialect: 'postgres', + read_only_queries: true, }, }, { @@ -103,6 +104,7 @@ const FALLBACK_DRIVERS: PluginManifest[] = [ create_foreign_keys: true, supports_ssl: true, sql_dialect: 'mysql', + read_only_queries: true, }, }, { @@ -130,6 +132,7 @@ const FALLBACK_DRIVERS: PluginManifest[] = [ alter_column: false, create_foreign_keys: false, sql_dialect: 'sqlite', + read_only_queries: true, }, }, ]; diff --git a/packages/web-ui/src/hooks/useProductionGuard.ts b/packages/web-ui/src/hooks/useProductionGuard.ts index 494a21397..50a6830d2 100644 --- a/packages/web-ui/src/hooks/useProductionGuard.ts +++ b/packages/web-ui/src/hooks/useProductionGuard.ts @@ -1,12 +1,15 @@ import {createContext, useCallback, useContext} from 'react'; import {useDatabase} from './useDatabase'; import {isReadOnlyQuery} from '@/utils/sqlAnalysis'; +import {useTranslation} from 'react-i18next'; +import {useAlert} from './useAlert'; /** - * Confirmation gate for writes against production connections. + * Connection write gate: read-only policy first, then production confirmation. * * `useProductionGuard()` returns `guardWrite(connectionId, sql?)`: it - * resolves `true` immediately unless the connection is classified as + * refuses writes on a read-only connection regardless of snooze or provider. + * Otherwise it resolves `true` immediately unless the connection is classified as * production AND the operation is (or may be) a write, in which case a * confirmation dialog is shown by `ProductionGuardProvider`. Ticking * "don't ask again" silences the prompt for that connection until the @@ -27,11 +30,17 @@ export const snoozedConnectionIds = new Set(); export function useProductionGuard() { const {connections} = useDatabase(); const request = useContext(ProductionGuardContext); + const {t} = useTranslation(); + const {showAlert} = useAlert(); return useCallback( async (connectionId: string | null | undefined, sql?: string): Promise => { if (!connectionId) return true; const conn = connections.find((c) => c.id === connectionId); + if (conn?.params.read_only && (sql === undefined || !isReadOnlyQuery(sql))) { + showAlert(t('readOnly.writeBlocked'), {kind: 'warning'}); + return false; + } if (conn?.environment !== 'production') return true; if (sql !== undefined && isReadOnlyQuery(sql)) return true; if (snoozedConnectionIds.has(connectionId)) return true; @@ -39,6 +48,6 @@ export function useProductionGuard() { if (!request) return true; return request(connectionId, conn.name, sql); }, - [connections, request], + [connections, request, showAlert, t], ); } diff --git a/packages/web-ui/src/i18n/locales/de.json b/packages/web-ui/src/i18n/locales/de.json index d6a7daa28..166664801 100644 --- a/packages/web-ui/src/i18n/locales/de.json +++ b/packages/web-ui/src/i18n/locales/de.json @@ -1,4 +1,11 @@ { + "readOnly": { + "label": "Schreibgeschützter Modus", + "description": "Blockiert Schreibzugriffe auf diese Verbindung. Startskripte werden in diesem Modus übersprungen.", + "unsupported": "Dieser Treiber unterstützt keine geschützten schreibgeschützten Abfragen.", + "writeBlocked": "Diese Verbindung ist schreibgeschützt. Deaktivieren Sie diesen Modus in den Verbindungseinstellungen, um Schreibzugriffe zu erlauben.", + "banner": "{{name}} — schreibgeschützt" + }, "serverFilePicker": { "title": "Serverdateien durchsuchen", "subtitle": "Datei oder Ordner auf dem Tabularis-Host auswählen", diff --git a/packages/web-ui/src/i18n/locales/en.json b/packages/web-ui/src/i18n/locales/en.json index 56f8b80bc..e2dd9798a 100644 --- a/packages/web-ui/src/i18n/locales/en.json +++ b/packages/web-ui/src/i18n/locales/en.json @@ -1,4 +1,11 @@ { + "readOnly": { + "label": "Read-only mode", + "description": "Block writes on this connection. Startup scripts are skipped while enabled.", + "unsupported": "This driver does not support protected read-only queries.", + "writeBlocked": "This connection is read-only. Disable read-only mode in its settings to allow writes.", + "banner": "{{name}} — read-only mode" + }, "serverFilePicker": { "title": "Browse server files", "subtitle": "Select a file or folder on the Tabularis host", diff --git a/packages/web-ui/src/i18n/locales/es.json b/packages/web-ui/src/i18n/locales/es.json index eaa2fdc92..27b474074 100644 --- a/packages/web-ui/src/i18n/locales/es.json +++ b/packages/web-ui/src/i18n/locales/es.json @@ -1,4 +1,11 @@ { + "readOnly": { + "label": "Modo de solo lectura", + "description": "Bloquea las escrituras en esta conexión. Los scripts de inicio se omiten en este modo.", + "unsupported": "Este controlador no admite consultas protegidas de solo lectura.", + "writeBlocked": "Esta conexión es de solo lectura. Desactiva este modo en su configuración para permitir escrituras.", + "banner": "{{name}} — solo lectura" + }, "serverFilePicker": { "title": "Explorar archivos del servidor", "subtitle": "Selecciona un archivo o carpeta en el host de Tabularis", diff --git a/packages/web-ui/src/i18n/locales/fr.json b/packages/web-ui/src/i18n/locales/fr.json index 1590c6222..2ff2e412f 100644 --- a/packages/web-ui/src/i18n/locales/fr.json +++ b/packages/web-ui/src/i18n/locales/fr.json @@ -1,4 +1,11 @@ { + "readOnly": { + "label": "Mode lecture seule", + "description": "Bloque les écritures sur cette connexion. Les scripts de démarrage sont ignorés dans ce mode.", + "unsupported": "Ce pilote ne prend pas en charge les requêtes protégées en lecture seule.", + "writeBlocked": "Cette connexion est en lecture seule. Désactivez ce mode dans ses paramètres pour autoriser les écritures.", + "banner": "{{name}} — mode lecture seule" + }, "serverFilePicker": { "title": "Parcourir les fichiers du serveur", "subtitle": "Sélectionnez un fichier ou dossier sur l'hôte Tabularis", diff --git a/packages/web-ui/src/i18n/locales/it.json b/packages/web-ui/src/i18n/locales/it.json index 9a6b2f389..ccc53ab04 100644 --- a/packages/web-ui/src/i18n/locales/it.json +++ b/packages/web-ui/src/i18n/locales/it.json @@ -1,4 +1,11 @@ { + "readOnly": { + "label": "Modalità sola lettura", + "description": "Blocca le scritture su questa connessione. Gli script di avvio vengono ignorati in questa modalità.", + "unsupported": "Questo driver non supporta query protette in sola lettura.", + "writeBlocked": "Questa connessione è in sola lettura. Disattiva questa modalità nelle impostazioni per consentire le scritture.", + "banner": "{{name}} — sola lettura" + }, "serverFilePicker": { "title": "Sfoglia i file del server", "subtitle": "Seleziona un file o una cartella sull'host Tabularis", diff --git a/packages/web-ui/src/i18n/locales/ja.json b/packages/web-ui/src/i18n/locales/ja.json index 0703df166..449b39b1f 100644 --- a/packages/web-ui/src/i18n/locales/ja.json +++ b/packages/web-ui/src/i18n/locales/ja.json @@ -1,4 +1,11 @@ { + "readOnly": { + "label": "読み取り専用モード", + "description": "この接続への書き込みをブロックします。有効な間は起動スクリプトを実行しません。", + "unsupported": "このドライバーは保護された読み取り専用クエリに対応していません。", + "writeBlocked": "この接続は読み取り専用です。書き込みを許可するには接続設定でこのモードを無効にしてください。", + "banner": "{{name}} — 読み取り専用" + }, "serverFilePicker": { "title": "サーバーファイルを参照", "subtitle": "Tabularis ホスト上のファイルまたはフォルダーを選択します", diff --git a/packages/web-ui/src/i18n/locales/ko.json b/packages/web-ui/src/i18n/locales/ko.json index 360a22933..94f416eab 100644 --- a/packages/web-ui/src/i18n/locales/ko.json +++ b/packages/web-ui/src/i18n/locales/ko.json @@ -1,4 +1,11 @@ { + "readOnly": { + "label": "읽기 전용 모드", + "description": "이 연결의 쓰기를 차단합니다. 활성화된 동안 시작 스크립트를 실행하지 않습니다.", + "unsupported": "이 드라이버는 보호된 읽기 전용 쿼리를 지원하지 않습니다.", + "writeBlocked": "이 연결은 읽기 전용입니다. 쓰기를 허용하려면 연결 설정에서 이 모드를 해제하세요.", + "banner": "{{name}} — 읽기 전용" + }, "serverFilePicker": { "title": "서버 파일 찾아보기", "subtitle": "Tabularis 호스트에서 파일 또는 폴더를 선택하세요", diff --git a/packages/web-ui/src/i18n/locales/pt-BR.json b/packages/web-ui/src/i18n/locales/pt-BR.json index 434bdc24d..261b74b4d 100644 --- a/packages/web-ui/src/i18n/locales/pt-BR.json +++ b/packages/web-ui/src/i18n/locales/pt-BR.json @@ -1,4 +1,11 @@ { + "readOnly": { + "label": "Modo somente leitura", + "description": "Bloqueia gravações nesta conexão. Os scripts de inicialização são ignorados neste modo.", + "unsupported": "Este driver não oferece consultas protegidas somente leitura.", + "writeBlocked": "Esta conexão é somente leitura. Desative este modo nas configurações para permitir gravações.", + "banner": "{{name}} — somente leitura" + }, "serverFilePicker": { "title": "Explorar arquivos do servidor", "subtitle": "Selecione um arquivo ou pasta no host do Tabularis", diff --git a/packages/web-ui/src/i18n/locales/ru.json b/packages/web-ui/src/i18n/locales/ru.json index 8b833c5e7..bdc0e94fa 100644 --- a/packages/web-ui/src/i18n/locales/ru.json +++ b/packages/web-ui/src/i18n/locales/ru.json @@ -1,4 +1,11 @@ { + "readOnly": { + "label": "Режим только для чтения", + "description": "Блокирует запись через это подключение. Скрипты запуска в этом режиме пропускаются.", + "unsupported": "Этот драйвер не поддерживает защищённые запросы только для чтения.", + "writeBlocked": "Это подключение доступно только для чтения. Отключите этот режим в настройках подключения, чтобы разрешить запись.", + "banner": "{{name}} — только чтение" + }, "serverFilePicker": { "title": "Обзор файлов сервера", "subtitle": "Выберите файл или папку на сервере Tabularis", diff --git a/packages/web-ui/src/i18n/locales/tl.json b/packages/web-ui/src/i18n/locales/tl.json index 16771135c..402270d92 100644 --- a/packages/web-ui/src/i18n/locales/tl.json +++ b/packages/web-ui/src/i18n/locales/tl.json @@ -1,4 +1,11 @@ { + "readOnly": { + "label": "Read-only na mode", + "description": "Hinaharangan ang pagsusulat sa koneksyong ito. Nilalaktawan ang mga startup script habang naka-enable.", + "unsupported": "Hindi sinusuportahan ng driver na ito ang mga protektadong read-only na query.", + "writeBlocked": "Read-only ang koneksyong ito. I-disable ang mode na ito sa mga setting nito upang payagan ang pagsusulat.", + "banner": "{{name}} — read-only" + }, "serverFilePicker": { "title": "Mag-browse ng mga file sa server", "subtitle": "Pumili ng file o folder sa Tabularis host", diff --git a/packages/web-ui/src/i18n/locales/vi.json b/packages/web-ui/src/i18n/locales/vi.json index 12a34ab3f..c92d36747 100644 --- a/packages/web-ui/src/i18n/locales/vi.json +++ b/packages/web-ui/src/i18n/locales/vi.json @@ -1,4 +1,11 @@ { + "readOnly": { + "label": "Chế độ chỉ đọc", + "description": "Chặn thao tác ghi trên kết nối này. Bỏ qua tập lệnh khởi động khi bật chế độ này.", + "unsupported": "Trình điều khiển này không hỗ trợ truy vấn chỉ đọc được bảo vệ.", + "writeBlocked": "Kết nối này ở chế độ chỉ đọc. Tắt chế độ này trong cài đặt kết nối để cho phép ghi.", + "banner": "{{name}} — chỉ đọc" + }, "serverFilePicker": { "title": "Duyệt tệp trên máy chủ", "subtitle": "Chọn một tệp hoặc thư mục trên máy chủ Tabularis", diff --git a/packages/web-ui/src/i18n/locales/zh-TW.json b/packages/web-ui/src/i18n/locales/zh-TW.json index eee4bb9a9..e2072bebb 100644 --- a/packages/web-ui/src/i18n/locales/zh-TW.json +++ b/packages/web-ui/src/i18n/locales/zh-TW.json @@ -1,4 +1,11 @@ { + "readOnly": { + "label": "唯讀模式", + "description": "阻止透過此連線寫入。啟用時略過啟動指令碼。", + "unsupported": "此驅動程式不支援受保護的唯讀查詢。", + "writeBlocked": "此連線為唯讀。請在連線設定中停用此模式以允許寫入。", + "banner": "{{name}} — 唯讀模式" + }, "serverFilePicker": { "title": "瀏覽伺服器檔案", "subtitle": "在 Tabularis 主機上選擇檔案或資料夾", diff --git a/packages/web-ui/src/i18n/locales/zh.json b/packages/web-ui/src/i18n/locales/zh.json index c61dca14b..90fdf810f 100644 --- a/packages/web-ui/src/i18n/locales/zh.json +++ b/packages/web-ui/src/i18n/locales/zh.json @@ -1,4 +1,11 @@ { + "readOnly": { + "label": "只读模式", + "description": "阻止通过此连接写入。启用时跳过启动脚本。", + "unsupported": "此驱动程序不支持受保护的只读查询。", + "writeBlocked": "此连接为只读。请在连接设置中关闭此模式以允许写入。", + "banner": "{{name}} — 只读模式" + }, "serverFilePicker": { "title": "浏览服务器文件", "subtitle": "在 Tabularis 主机上选择文件或文件夹", diff --git a/packages/web-ui/src/types/plugins.ts b/packages/web-ui/src/types/plugins.ts index afd4a2afe..858fb0ed1 100644 --- a/packages/web-ui/src/types/plugins.ts +++ b/packages/web-ui/src/types/plugins.ts @@ -74,6 +74,8 @@ export interface DriverCapabilities { manage_tables?: boolean; /** When true, the driver is read-only: all data modification operations (INSERT, UPDATE, DELETE) are disabled in the UI. Table/column management is also hidden regardless of manage_tables. Defaults to false. */ readonly?: boolean; + /** Implements the protected execute_read_only_query RPC. Defaults to false. */ + read_only_queries?: boolean; /** Offers "Export table to SQL" on table nodes. Defaults to true; non-SQL plugins set false. */ table_export?: boolean; /** Offers "Import table from SQL" on database and schema nodes. Defaults to true; ignored (off) when readonly. */ diff --git a/packages/web-ui/src/utils/connectionPolicy.ts b/packages/web-ui/src/utils/connectionPolicy.ts new file mode 100644 index 000000000..58c861f00 --- /dev/null +++ b/packages/web-ui/src/utils/connectionPolicy.ts @@ -0,0 +1,27 @@ +import type {DriverCapabilities} from '@/types/plugins'; + +/** Apply a saved connection policy without mutating the driver's manifest. */ +export function effectiveConnectionCapabilities( + capabilities: DriverCapabilities | null, + readOnly: boolean | undefined, +): DriverCapabilities | null { + if (!capabilities || !readOnly) return capabilities; + return { + ...capabilities, + readonly: true, + manage_tables: false, + alter_column: false, + alter_primary_key: false, + create_foreign_keys: false, + table_import: false, + routine_management: false, + user_management: false, + // Visual EXPLAIN has no protected driver contract. Raw EXPLAIN remains + // available through the SQL editor's protected execution path. + explain: false, + }; +} + +export function supportsReadOnlyQueries(capabilities: DriverCapabilities | undefined): boolean { + return capabilities?.readonly === true || capabilities?.read_only_queries === true; +} diff --git a/packages/web-ui/tests/components/layout/MainLayout.test.tsx b/packages/web-ui/tests/components/layout/MainLayout.test.tsx index 581e11da1..690a0e22d 100644 --- a/packages/web-ui/tests/components/layout/MainLayout.test.tsx +++ b/packages/web-ui/tests/components/layout/MainLayout.test.tsx @@ -29,6 +29,10 @@ vi.mock('@/components/layout/ProductionBanner', () => ({ ProductionBanner: () =>
, })); +vi.mock('@/components/layout/ReadOnlyBanner', () => ({ + ReadOnlyBanner: () =>
, +})); + vi.mock('@/components/layout/CommandPaletteScopeBridge', () => ({ CommandPaletteScopeBridge: ({scopeId}: {scopeId: string}) => (
diff --git a/packages/web-ui/tests/components/modals/NewConnectionModal.test.tsx b/packages/web-ui/tests/components/modals/NewConnectionModal.test.tsx index 32a0d1a0e..cd707a8a3 100644 --- a/packages/web-ui/tests/components/modals/NewConnectionModal.test.tsx +++ b/packages/web-ui/tests/components/modals/NewConnectionModal.test.tsx @@ -2665,6 +2665,49 @@ describe('NewConnectionModal database input of a flat multi-database plugin', () }); }); +describe('NewConnectionModal read-only mode', () => { + beforeEach(() => { + vi.clearAllMocks(); + driverState.defaultPort = 15432; + driverState.multiDatabase = false; + vi.mocked(invoke).mockResolvedValue('ok'); + sshMocks.loadSshConnections.mockResolvedValue([]); + eventMocks.listen.mockResolvedValue(eventMocks.unlisten); + k8sMocks.loadK8sConnections.mockResolvedValue([]); + }); + + it('explains why a legacy plugin cannot enable protected execution', async () => { + renderModal(createInitialConnection({driver: 'postgresql', host: 'localhost'})); + fireEvent.click(await screen.findByText('newConnection.advanced')); + expect(screen.getByRole('checkbox', {name: 'readOnly.label'})).toBeDisabled(); + expect(screen.getByText('readOnly.unsupported')).toBeInTheDocument(); + }); + + it('retains a saved policy after a plugin loses support and permits explicitly disabling it', async () => { + renderModal( + createInitialConnection({driver: 'postgresql', host: 'localhost', read_only: true}), + ); + fireEvent.click(await screen.findByText('newConnection.advanced')); + const checkbox = screen.getByRole('checkbox', {name: 'readOnly.label'}); + expect(checkbox).toBeChecked(); + fireEvent.click(screen.getByText('newConnection.save')); + await waitFor(() => + expect(screen.getAllByText('readOnly.unsupported').length).toBeGreaterThan(1), + ); + expect(invoke).not.toHaveBeenCalledWith('update_connection', expect.anything()); + fireEvent.click(checkbox); + fireEvent.click(screen.getByText('newConnection.save')); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith( + 'update_connection', + expect.objectContaining({ + params: expect.objectContaining({read_only: false}), + }), + ), + ); + }); +}); + describe('NewConnectionModal query timeout', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/packages/web-ui/tests/contexts/DatabaseProvider.test.tsx b/packages/web-ui/tests/contexts/DatabaseProvider.test.tsx index 66d14e8da..f7132d43b 100644 --- a/packages/web-ui/tests/contexts/DatabaseProvider.test.tsx +++ b/packages/web-ui/tests/contexts/DatabaseProvider.test.tsx @@ -115,6 +115,64 @@ describe('DatabaseProvider', () => { vi.restoreAllMocks(); }); + it('scopes read-only capabilities per connection and keeps their identity stable', async () => { + const records = [ + { + ...mockConnections[0], + id: 'protected', + params: {...mockConnections[0].params, read_only: true}, + }, + {...mockConnections[0], id: 'writable'}, + ]; + const manifest = { + ...mockMysqlManifest, + capabilities: { + ...mockMysqlManifest.capabilities, + single_database: true, + read_only_queries: true, + manage_tables: true, + }, + }; + vi.mocked(invoke).mockImplementation(async (command) => { + if (command === 'get_connections') return records; + if (command === 'get_connections_with_groups') + return { + connections: records.map((connection) => ({ + ...connection, + params: {...connection.params}, + })), + groups: [], + }; + if (command === 'get_driver_manifest') return manifest; + return []; + }); + const wrapper = ({children}: {children: React.ReactNode}) => + React.createElement(DatabaseProvider, null, children); + const {result} = renderHook(useDatabase, {wrapper}); + await act(async () => { + await result.current.loadConnections(); + await result.current.connect('protected'); + }); + const protectedData = result.current.getConnectionData('protected'); + expect(result.current.connectionDataMap.protected).toBe(protectedData); + expect(protectedData?.capabilities).toMatchObject({readonly: true, manage_tables: false}); + expect(result.current.getConnectionData('protected')).toBe(protectedData); + expect(result.current.activeCapabilities?.readonly).toBe(true); + await act(async () => { + await result.current.connect('writable', {activate: false}); + }); + expect(result.current.getConnectionData('writable')?.capabilities?.readonly).not.toBe(true); + expect(result.current.activeCapabilities?.readonly).toBe(true); + expect(manifest.capabilities.manage_tables).toBe(true); + records[0].params.read_only = false; + await act(async () => { + await result.current.loadConnections(); + }); + expect(result.current.getConnectionData('protected')?.capabilities?.readonly).not.toBe( + true, + ); + }); + it('shares concurrent connection-list loads but refreshes on a later call', async () => { const wrapper = ({children}: {children: React.ReactNode}) => React.createElement(DatabaseProvider, null, children); diff --git a/packages/web-ui/tests/hooks/useProductionGuard.test.tsx b/packages/web-ui/tests/hooks/useProductionGuard.test.tsx new file mode 100644 index 000000000..d5b8fa582 --- /dev/null +++ b/packages/web-ui/tests/hooks/useProductionGuard.test.tsx @@ -0,0 +1,67 @@ +import {renderHook} from '@testing-library/react'; +import {afterEach, describe, expect, it, vi} from 'vitest'; +import { + ProductionGuardContext, + snoozedConnectionIds, + useProductionGuard, +} from '@/hooks/useProductionGuard'; +import type {ReactNode} from 'react'; + +const mocks = vi.hoisted(() => ({ + connection: { + id: 'connection', + name: 'Production', + environment: 'production', + params: {read_only: true}, + }, + showAlert: vi.fn(), + request: vi.fn(async () => true), +})); +vi.mock('@/hooks/useDatabase', () => ({useDatabase: () => ({connections: [mocks.connection]})})); +vi.mock('@/hooks/useAlert', () => ({useAlert: () => ({showAlert: mocks.showAlert})})); +vi.mock('react-i18next', () => ({useTranslation: () => ({t: (key: string) => key})})); + +function wrapper({children}: {children: ReactNode}) { + return ( + + {children} + + ); +} + +describe('read-only policy and the production guard', () => { + afterEach(() => { + mocks.connection.params.read_only = true; + mocks.connection.environment = 'production'; + snoozedConnectionIds.clear(); + vi.clearAllMocks(); + }); + + it('blocks writes even when production confirmation is snoozed', async () => { + snoozedConnectionIds.add('connection'); + const {result} = renderHook(useProductionGuard, {wrapper}); + expect(await result.current('connection', 'DELETE FROM items')).toBe(false); + expect(mocks.showAlert).toHaveBeenCalledWith('readOnly.writeBlocked', {kind: 'warning'}); + expect(mocks.request).not.toHaveBeenCalled(); + }); + + it('blocks non-SQL mutations on development connections without a provider', async () => { + mocks.connection.environment = 'development'; + const {result} = renderHook(useProductionGuard); + expect(await result.current('connection')).toBe(false); + }); + + it('allows reads without prompting', async () => { + const {result} = renderHook(useProductionGuard, {wrapper}); + expect(await result.current('connection', 'SELECT * FROM items')).toBe(true); + expect(mocks.request).not.toHaveBeenCalled(); + }); + + it('retains the PR473 production confirmation when read-only mode is off', async () => { + mocks.connection.params.read_only = false; + const {result} = renderHook(useProductionGuard, {wrapper}); + expect(await result.current('connection', 'DELETE FROM items')).toBe(true); + expect(mocks.request).toHaveBeenCalledWith('connection', 'Production', 'DELETE FROM items'); + expect(mocks.showAlert).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/web-ui/tests/utils/connectionPolicy.test.ts b/packages/web-ui/tests/utils/connectionPolicy.test.ts new file mode 100644 index 000000000..7048e5164 --- /dev/null +++ b/packages/web-ui/tests/utils/connectionPolicy.test.ts @@ -0,0 +1,58 @@ +import {describe, expect, it} from 'vitest'; +import {effectiveConnectionCapabilities, supportsReadOnlyQueries} from '@/utils/connectionPolicy'; +import type {DriverCapabilities} from '@/types/plugins'; + +const capabilities: DriverCapabilities = { + schemas: true, + views: true, + routines: true, + file_based: false, + folder_based: false, + identifier_quote: '`', + alter_primary_key: true, + sql_dialect: 'mysql', + manage_tables: true, + table_import: true, + explain: true, + user_management: true, + routine_management: true, + read_only_queries: true, +}; + +describe('connectionPolicy', () => { + it('requires explicit opt-in for writable plugin drivers', () => { + expect(supportsReadOnlyQueries(undefined)).toBe(false); + expect(supportsReadOnlyQueries({...capabilities, read_only_queries: undefined})).toBe( + false, + ); + expect(supportsReadOnlyQueries(capabilities)).toBe(true); + expect( + supportsReadOnlyQueries({...capabilities, read_only_queries: false, readonly: true}), + ).toBe(true); + }); + + it('disables mutations without changing the plugin manifest or its SQL dialect', () => { + const effective = effectiveConnectionCapabilities(capabilities, true); + expect(effective).toMatchObject({ + readonly: true, + manage_tables: false, + alter_primary_key: false, + table_import: false, + explain: false, + user_management: false, + routine_management: false, + identifier_quote: '`', + sql_dialect: 'mysql', + views: true, + }); + expect(capabilities.manage_tables).toBe(true); + expect(capabilities.readonly).toBeUndefined(); + }); + + it('preserves normal and intrinsically read-only driver capabilities', () => { + expect(effectiveConnectionCapabilities(capabilities, undefined)).toBe(capabilities); + expect(effectiveConnectionCapabilities(null, true)).toBeNull(); + const immutable = {...capabilities, readonly: true}; + expect(effectiveConnectionCapabilities(immutable, false)?.readonly).toBe(true); + }); +}); diff --git a/plugins/PLUGIN_GUIDE.md b/plugins/PLUGIN_GUIDE.md index 5db7a9476..6a3359e57 100644 --- a/plugins/PLUGIN_GUIDE.md +++ b/plugins/PLUGIN_GUIDE.md @@ -162,6 +162,7 @@ Upgrade the registry before publishing the new format. Add `id` equal to the **e | `alter_primary_key` | bool | `true` if the database supports altering primary keys after table creation. | | `manage_tables` | bool | `true` to enable table and column management UI (Create Table, Add/Modify/Drop Column, Drop Table). Does not control index or FK operations. Defaults to `true`. | | `readonly` | bool | When `true`, the driver is read-only: all data modification operations (INSERT, UPDATE, DELETE) are disabled in the UI. The add/delete row buttons, inline cell editing, and context menu edit actions are hidden. Table and column management is also hidden regardless of `manage_tables`. Defaults to `false`. | +| `read_only_queries` | bool | Implements the optional database-enforced `execute_read_only_query` RPC, allowing users to enable read-only mode per connection. Defaults to `false`. See [Optional protected read-only queries](#optional-protected-read-only-queries). | | `table_export` | bool | Offers **Export table to SQL...** on table nodes. Defaults to `true`; set `false` for non-SQL plugins. See [Table Export and Import](#table-export-and-import). | | `table_import` | bool | Offers **Import table from SQL...** on database and schema nodes. Defaults to `true`; set `false` for non-SQL plugins. Always off when `readonly` is `true`. | | `explain` | bool | `true` if the driver implements the `explain_query` method (EXPLAIN / query plan support). Enables the Visual EXPLAIN button in the SQL editor and notebook cells; when `false` or omitted, the Visual EXPLAIN UI is hidden for connections using this driver. Defaults to `false`. | @@ -183,6 +184,53 @@ For URI passthrough drivers, an opaque placeholder (such as `jdbc:h2:mem:test`) } ``` +### Optional protected read-only queries + +Writable drivers can opt in to the connection's **Read-only mode** by declaring +`capabilities.read_only_queries: true`. This is distinct from `readonly: true`, +which describes a driver that is always read-only. The default is `false`: older +plugins continue working, but cannot enable this connection policy. + +Implement the `execute_read_only_query` JSON-RPC method. Its request and response +have the same shape as `execute_query` (a bare `QueryResult` response): + +```json +{ + "jsonrpc": "2.0", + "id": 42, + "method": "execute_read_only_query", + "params": { + "params": {"driver": "example", "database": "analytics", "read_only": true}, + "query": "SELECT * FROM items", + "limit": 100, + "page": 1, + "schema": null + } +} +``` + +The host preflights the SQL using the declared dialect, but a keyword filter +cannot detect side effects in stored functions. The plugin must enforce write +protection using its database's transaction, session or equivalent mechanism, +including for calls whose SQL looks like a read. If protection cannot be enabled, +return an error. An unsupported method, timeout or protection failure never falls +back to `execute_query`. Restore or discard the physical connection on every +success, error and cancellation path; protection must not leak to writable users. + +`ConnectionParams.read_only` is persisted per connection and forwarded to all +driver calls, including connection testing and metadata discovery. The host omits +the saved `startup_script` while it is enabled. Plugins must also ensure their +own initialization and metadata operations cannot write in this mode. A driver +that loses the capability must refuse protected execution until the user updates +the plugin or explicitly disables the policy. + +Read-only batches are preflighted in full, then each statement uses the protected +RPC independently. Explicit transaction/session-control statements are refused; +connection-local state is not shared between these calls. Visual EXPLAIN is +unavailable because its separate RPC has no protected execution contract; raw +EXPLAIN statements can use the protected query RPC. Existing `readonly` drivers +continue using their normal execution method and need no new RPC. + ### Declare `sql_dialect` (or `sql_syntax`) Always set `sql_dialect` to the closest preset, and add `sql_syntax` when no preset describes your engine. A plugin that declares neither (or only `generic`) still loads, but the host can't know how its SQL is tokenized, so it assumes the worst: diff --git a/plugins/manifest.schema.json b/plugins/manifest.schema.json index deac0d427..3e43961e3 100644 --- a/plugins/manifest.schema.json +++ b/plugins/manifest.schema.json @@ -405,6 +405,11 @@ "default": false, "description": "When true, the driver is read-only: all data modification operations (INSERT, UPDATE, DELETE) are disabled in the UI. Table and column management is also hidden regardless of manage_tables. Defaults to false." }, + "read_only_queries": { + "type": "boolean", + "default": false, + "description": "Implements execute_read_only_query with database-enforced write protection. Enables optional read-only mode per connection. Errors must never fall back to ordinary execution." + }, "table_export": { "type": "boolean", "default": true, diff --git a/src-tauri/src/application/connections.rs b/src-tauri/src/application/connections.rs index e9d1cb666..27e5e6889 100644 --- a/src-tauri/src/application/connections.rs +++ b/src-tauri/src/application/connections.rs @@ -384,6 +384,7 @@ pub(crate) fn resolve_saved_connection_params( let driver_id = saved.params.driver.clone(); let plugin_secrets = load_plugin_secrets(runtime, &saved); let mut params = saved.params; + super::read_only::prepare_params(&mut params); restore_secrets_for_connect(runtime, connection_id, &mut params)?; params.extra.extend(plugin_secrets); params.connection_id = Some(connection_id.to_string()); @@ -1446,6 +1447,7 @@ fn prepare_connection_request( runtime: &RuntimeContext, request: &mut TestConnectionRequest, ) -> Result<(), String> { + super::read_only::prepare_params(&mut request.params); let iam_auth = request.params.use_iam_auth.unwrap_or(false); let mut stored_plugin_secrets = std::collections::HashMap::new(); let saved = match request.connection_id.as_deref() { @@ -1615,6 +1617,7 @@ async fn test_connection( Some(db_target), ); let capabilities = &driver.manifest().capabilities; + super::read_only::validate_support(&resolved, capabilities)?; if capabilities.file_based && crate::fs_path::requires_existing_database_file( capabilities.in_memory, diff --git a/src-tauri/src/application/database_objects.rs b/src-tauri/src/application/database_objects.rs index 390c8a1be..d57c1eb4e 100644 --- a/src-tauri/src/application/database_objects.rs +++ b/src-tauri/src/application/database_objects.rs @@ -827,6 +827,7 @@ pub async fn create_view( schema: Option, database: Option, ) -> Result<(), String> { + crate::application::read_only::ensure_writable(runtime, connection_id)?; let (driver, params) = connected_driver_and_params(runtime, session_id, connection_id, database).await?; @@ -844,6 +845,7 @@ pub async fn alter_view( schema: Option, database: Option, ) -> Result<(), String> { + crate::application::read_only::ensure_writable(runtime, connection_id)?; let (driver, params) = connected_driver_and_params(runtime, session_id, connection_id, database).await?; @@ -860,6 +862,7 @@ pub async fn drop_view( schema: Option, database: Option, ) -> Result<(), String> { + crate::application::read_only::ensure_writable(runtime, connection_id)?; let (driver, params) = connected_driver_and_params(runtime, session_id, connection_id, database).await?; @@ -876,6 +879,7 @@ pub async fn refresh_materialized_view( schema: Option, database: Option, ) -> Result<(), String> { + crate::application::read_only::ensure_writable(runtime, connection_id)?; let (driver, params) = connected_driver_and_params(runtime, session_id, connection_id, database).await?; @@ -999,6 +1003,7 @@ pub async fn drop_routine( database: Option, identity_args: Option, ) -> Result<(), String> { + crate::application::read_only::ensure_writable(runtime, connection_id)?; let (driver, params) = connected_driver_and_params(runtime, session_id, connection_id, database).await?; @@ -1038,6 +1043,7 @@ pub async fn create_trigger( schema: Option, database: Option, ) -> Result<(), String> { + crate::application::read_only::ensure_writable(runtime, connection_id)?; let (driver, params) = connected_driver_and_params(runtime, session_id, connection_id, database).await?; @@ -1055,6 +1061,7 @@ pub async fn drop_trigger( schema: Option, database: Option, ) -> Result<(), String> { + crate::application::read_only::ensure_writable(runtime, connection_id)?; let (driver, params) = connected_driver_and_params(runtime, session_id, connection_id, database).await?; @@ -1161,6 +1168,7 @@ pub async fn drop_index( schema: Option, database: Option, ) -> Result<(), String> { + crate::application::read_only::ensure_writable(runtime, connection_id)?; let (driver, params) = connected_driver_and_params(runtime, session_id, connection_id, database).await?; @@ -1178,6 +1186,7 @@ pub async fn drop_foreign_key( schema: Option, database: Option, ) -> Result<(), String> { + crate::application::read_only::ensure_writable(runtime, connection_id)?; let (driver, params) = connected_driver_and_params(runtime, session_id, connection_id, database).await?; @@ -1236,6 +1245,7 @@ pub async fn create_db_user( host: String, password: String, ) -> Result<(), String> { + crate::application::read_only::ensure_writable(runtime, connection_id)?; let (driver, params) = connected_driver_and_params(runtime, session_id, connection_id, None).await?; driver @@ -1250,6 +1260,7 @@ pub async fn drop_db_user( user: String, host: String, ) -> Result<(), String> { + crate::application::read_only::ensure_writable(runtime, connection_id)?; let (driver, params) = connected_driver_and_params(runtime, session_id, connection_id, None).await?; driver.drop_db_user(¶ms, &user, &host).await @@ -1263,6 +1274,7 @@ pub async fn set_db_user_password( host: String, password: String, ) -> Result<(), String> { + crate::application::read_only::ensure_writable(runtime, connection_id)?; let (driver, params) = connected_driver_and_params(runtime, session_id, connection_id, None).await?; driver @@ -1282,6 +1294,7 @@ pub async fn apply_db_user_privileges( privileges: Vec, grant: bool, ) -> Result<(), String> { + crate::application::read_only::ensure_writable(runtime, connection_id)?; let (driver, params) = connected_driver_and_params(runtime, session_id, connection_id, None).await?; driver diff --git a/src-tauri/src/application/database_transfers.rs b/src-tauri/src/application/database_transfers.rs index e8aa5266a..478fcd74e 100644 --- a/src-tauri/src/application/database_transfers.rs +++ b/src-tauri/src/application/database_transfers.rs @@ -390,6 +390,7 @@ pub async fn import_database( schema: Option, database: Option, ) -> Result<(), String> { + crate::application::read_only::ensure_writable(runtime, &connection_id)?; let (driver, mut params) = super::connections::resolve_saved_connection_params(runtime, session_id, &connection_id)?; if let Some(database) = database.filter(|database| !database.is_empty()) { @@ -653,8 +654,10 @@ pub(crate) async fn stream_sql_rows( where F: FnMut(&[String], Vec) -> Result<(), String>, { - match driver { - "mysql" => { + // Protected exports use the same bounded query path as the editor, + // including for built-ins, rather than bypassing it with a raw stream. + match (driver, params.read_only) { + ("mysql", false) => { use sqlx::{Column, TypeInfo}; let pool = get_mysql_pool(params).await?; let mut rows = sqlx::query(query).fetch(&pool); @@ -674,7 +677,7 @@ where yield_every_rows(&mut streamed).await; } } - "postgres" => { + ("postgres", false) => { let pool = get_postgres_pool(params).await?; let client = pool.get().await.map_err(|error| error.to_string())?; let query_params: Vec = Vec::new(); @@ -702,7 +705,7 @@ where yield_every_rows(&mut streamed).await; } } - "sqlite" => { + ("sqlite", false) => { use sqlx::Column; let pool = get_sqlite_pool(params).await?; let mut rows = sqlx::query(query).fetch(&pool); @@ -728,9 +731,20 @@ where let json_columns = json_column_names(&drv.get_columns(params, table, schema).await?); let mut page = 1u32; loop { - let result = drv - .execute_query(params, query, Some(1000), page, schema) - .await?; + let result = if params.read_only { + super::read_only::execute_query( + drv.as_ref(), + params, + query, + Some(1000), + page, + schema, + ) + .await? + } else { + drv.execute_query(params, query, Some(1000), page, schema) + .await? + }; let fetched = result.rows.len() as u32; for row in result.rows { let cells = plugin_row_cells(row, &result.columns, &json_columns); diff --git a/src-tauri/src/application/mod.rs b/src-tauri/src/application/mod.rs index ea06a13bc..13e485afd 100644 --- a/src-tauri/src/application/mod.rs +++ b/src-tauri/src/application/mod.rs @@ -19,6 +19,7 @@ pub mod productivity; mod productivity_tests; pub mod queries; pub mod query_sessions; +pub mod read_only; pub mod records; pub mod table_transfers; pub mod themes; diff --git a/src-tauri/src/application/queries.rs b/src-tauri/src/application/queries.rs index 77f956364..8ecaf8bdf 100644 --- a/src-tauri/src/application/queries.rs +++ b/src-tauri/src/application/queries.rs @@ -282,6 +282,8 @@ pub async fn execute_query( let (driver, params) = driver_and_params(runtime, scope.session_id, &connection_id, database).await?; + super::read_only::validate_query(¶ms, &driver.manifest().capabilities, &sanitized_query)?; + let effective_limit = response_limit(policy, limit); let driver_session = query_session_id.as_deref().map(|id| { super::query_sessions::register(scope.session_id, &connection_id, id, driver.clone()) @@ -289,6 +291,21 @@ pub async fn execute_query( let session_driver = driver.clone(); let task_session = driver_session.clone(); let task = tokio::spawn(async move { + if params.read_only { + if let Some(id) = task_session.as_deref() { + driver.release_session(id).await; + } + return super::read_only::execute_query( + driver.as_ref(), + ¶ms, + &sanitized_query, + effective_limit, + page.unwrap_or(1), + schema.as_deref(), + ) + .await + .map(|result| (result, false)); + } driver .execute_query_in_session( ¶ms, @@ -435,6 +452,11 @@ pub async fn execute_query_batch( let (driver, params) = driver_and_params(runtime, scope.session_id, &connection_id, database).await?; + // Validate the entire batch before sending its first statement. + for query in &sanitized_queries { + super::read_only::validate_query(¶ms, &driver.manifest().capabilities, query)?; + } + let progress: Option> = batch_id.map(|batch_id| { let events = runtime.events.clone(); let session_id = scope.session_id; @@ -457,6 +479,30 @@ pub async fn execute_query_batch( let session_driver = driver.clone(); let task_session = driver_session.clone(); let task = tokio::spawn(async move { + if params.read_only { + if let Some(id) = task_session.as_deref() { + driver.release_session(id).await; + } + let mut results = Vec::with_capacity(sanitized_queries.len()); + for (index, query) in sanitized_queries.iter().enumerate() { + let started = std::time::Instant::now(); + let outcome = super::read_only::execute_query( + driver.as_ref(), + ¶ms, + query, + effective_limit, + page.unwrap_or(1), + schema.as_deref(), + ) + .await; + let result = BatchStatementResult::from_outcome(started, outcome); + if let Some(callback) = progress.as_deref() { + callback(index, &result); + } + results.push(result); + } + return Ok((results, false)); + } driver .execute_batch_in_session( ¶ms, @@ -529,6 +575,9 @@ pub async fn explain_query_plan( schema: Option, database: Option, ) -> Result { + // Visual EXPLAIN uses a separate driver RPC which has no protected + // execution contract yet. Raw EXPLAIN can use the protected query path. + super::read_only::ensure_writable(runtime, &connection_id)?; let sanitized_query = sanitize_user_query(&query); let (driver, params) = driver_and_params(runtime, scope.session_id, &connection_id, database).await?; @@ -579,24 +628,36 @@ pub async fn count_query( let sanitized = query.trim().trim_end_matches(';'); let count_query = format!("SELECT COUNT(*) FROM ({sanitized}) as count_wrapper"); - let result = match query_session_id.as_deref() { - Some(id) => { - let id = super::query_sessions::key(session_id, &connection_id, id); - crate::commands::read_in_open_transaction( - driver.as_ref(), - ¶ms, - &count_query, - None, - 1, - schema.as_deref(), - &id, - ) - .await? - } - None => { - driver - .execute_query(¶ms, &count_query, None, 1, schema.as_deref()) + let result = if params.read_only { + super::read_only::execute_query( + driver.as_ref(), + ¶ms, + &count_query, + None, + 1, + schema.as_deref(), + ) + .await? + } else { + match query_session_id.as_deref() { + Some(id) => { + let id = super::query_sessions::key(session_id, &connection_id, id); + crate::commands::read_in_open_transaction( + driver.as_ref(), + ¶ms, + &count_query, + None, + 1, + schema.as_deref(), + &id, + ) .await? + } + None => { + driver + .execute_query(¶ms, &count_query, None, 1, schema.as_deref()) + .await? + } } }; Ok(result diff --git a/src-tauri/src/application/read_only.rs b/src-tauri/src/application/read_only.rs new file mode 100644 index 000000000..d6bbf04a2 --- /dev/null +++ b/src-tauri/src/application/read_only.rs @@ -0,0 +1,71 @@ +//! Connection-wide write policy shared by desktop, web and MCP. + +use crate::drivers::driver_trait::{DatabaseDriver, DriverCapabilities}; +use crate::models::{ConnectionParams, QueryResult}; +use crate::runtime::RuntimeContext; + +pub const WRITE_BLOCKED: &str = "This connection is read-only. Disable read-only mode in its connection settings to allow writes."; + +/// Startup SQL is user code and runs before query protection is installed. +/// Keep it saved, but never send it to a driver while this policy is active. +pub fn prepare_params(params: &mut ConnectionParams) { + if params.read_only { + params.startup_script = None; + } +} + +pub fn validate_support( + params: &ConnectionParams, + capabilities: &DriverCapabilities, +) -> Result<(), String> { + if params.read_only && !capabilities.readonly && !capabilities.read_only_queries { + return Err("This driver does not support protected read-only queries. Update the plugin or disable read-only mode in the connection settings.".into()); + } + Ok(()) +} + +/// Resolve the saved policy rather than trusting flags supplied by a caller. +pub fn ensure_writable(runtime: &RuntimeContext, connection_id: &str) -> Result<(), String> { + let saved = super::connections::load_connections(&runtime.paths.connections_file())? + .into_iter() + .find(|connection| connection.id == connection_id) + .ok_or_else(|| "Connection not found".to_string())?; + if saved.params.read_only { + return Err(WRITE_BLOCKED.into()); + } + Ok(()) +} + +pub fn validate_query( + params: &ConnectionParams, + capabilities: &DriverCapabilities, + query: &str, +) -> Result<(), String> { + validate_support(params, capabilities)?; + if params.read_only + && crate::ai_activity::classify_query_kind_for_capabilities(query, Some(capabilities)) + != "select" + { + return Err(WRITE_BLOCKED.into()); + } + Ok(()) +} + +/// Classification is only a preflight: the driver must also reject side +/// effects hidden in reads (for example a SELECT calling a write function). +pub async fn execute_query( + driver: &dyn DatabaseDriver, + params: &ConnectionParams, + query: &str, + limit: Option, + page: u32, + schema: Option<&str>, +) -> Result { + validate_query(params, &driver.manifest().capabilities, query)?; + driver + .execute_read_only_query(params, query, limit, page, schema) + .await +} + +#[cfg(test)] +mod tests; diff --git a/src-tauri/src/application/read_only/tests.rs b/src-tauri/src/application/read_only/tests.rs new file mode 100644 index 000000000..09158d175 --- /dev/null +++ b/src-tauri/src/application/read_only/tests.rs @@ -0,0 +1,338 @@ +use super::*; +use crate::drivers::driver_trait::SqlDialect; +use crate::runtime::{events::NoopRuntimeEvents, paths::FixedRuntimePaths}; +use serde_json::json; +use std::sync::Arc; + +fn capabilities() -> DriverCapabilities { + DriverCapabilities { + read_only_queries: true, + sql_dialect: Some(SqlDialect::Sqlite), + ..Default::default() + } +} + +#[test] +fn old_connections_remain_writable_and_the_policy_round_trips() { + let legacy: ConnectionParams = + serde_json::from_value(json!({"driver":"sqlite", "database":"test"})).unwrap(); + assert!(!legacy.read_only); + assert!(serde_json::to_value(&legacy) + .unwrap() + .get("read_only") + .is_none()); + let params = ConnectionParams { + read_only: true, + ..legacy + }; + let restored: ConnectionParams = + serde_json::from_value(serde_json::to_value(params).unwrap()).unwrap(); + assert!(restored.read_only); +} + +#[test] +fn plugin_opt_in_is_required_and_sql_preflight_fails_closed() { + let params = ConnectionParams { + read_only: true, + ..Default::default() + }; + assert!(validate_support(¶ms, &DriverCapabilities::default()).is_err()); + assert!(validate_support( + ¶ms, + &DriverCapabilities { + readonly: true, + ..Default::default() + } + ) + .is_ok()); + for sql in [ + "SELECT 1", + "WITH x AS (SELECT 1) SELECT * FROM x", + "PRAGMA table_info(items)", + ] { + assert!( + validate_query(¶ms, &capabilities(), sql).is_ok(), + "{sql}" + ); + } + for sql in [ + "DELETE FROM items", + "SELECT 1; INSERT INTO items VALUES (2)", + "PRAGMA query_only = OFF", + "PRAGMA user_version(42)", + "BEGIN", + "COMMIT", + "SET TRANSACTION READ WRITE", + "EXPLAIN ANALYZE DELETE FROM items", + "WITH x AS (DELETE FROM items RETURNING *) SELECT * FROM x", + ] { + assert!( + validate_query(¶ms, &capabilities(), sql).is_err(), + "{sql}" + ); + } +} + +#[test] +fn startup_scripts_are_skipped_only_for_protected_connections() { + let original = ConnectionParams { + startup_script: Some("DELETE FROM items".into()), + ..Default::default() + }; + let mut ordinary = original.clone(); + prepare_params(&mut ordinary); + assert_eq!(ordinary.startup_script, original.startup_script); + ordinary.read_only = true; + prepare_params(&mut ordinary); + assert!(ordinary.startup_script.is_none()); + assert!(original.startup_script.is_some()); +} + +struct NoopSecrets; +impl crate::runtime::secrets::RuntimeSecrets for NoopSecrets { + fn get(&self, _: &str) -> Result, String> { + Ok(None) + } + fn set(&self, _: &str, _: &str) -> Result<(), String> { + Ok(()) + } + fn delete(&self, _: &str) -> Result<(), String> { + Ok(()) + } +} + +async fn fixture() -> (tempfile::TempDir, RuntimeContext, ConnectionParams) { + let temp = tempfile::tempdir().unwrap(); + let runtime = RuntimeContext::new( + Arc::new(FixedRuntimePaths::new( + temp.path().into(), + temp.path().into(), + )), + Arc::new(NoopRuntimeEvents), + Arc::new(NoopSecrets), + ); + let params = ConnectionParams { + driver: "sqlite".into(), + database: crate::models::DatabaseSelection::Single( + temp.path() + .join("test.sqlite") + .to_string_lossy() + .into_owned(), + ), + ..Default::default() + }; + std::fs::File::create(params.database.primary()).unwrap(); + let driver = crate::drivers::sqlite::SqliteDriver::new(); + driver + .execute_query(¶ms, "CREATE TABLE items (id INTEGER)", None, 1, None) + .await + .unwrap(); + crate::drivers::registry::register_driver(driver).await; + let mut saved: crate::models::SavedConnection = serde_json::from_value(json!({ + "id":"protected", "name":"Protected", "params":params, + })) + .unwrap(); + saved.params.read_only = true; + saved.params.startup_script = Some("INSERT INTO items VALUES (99)".into()); + crate::persistence::save_connections_file( + &runtime.paths.connections_file(), + &crate::models::ConnectionsFile { + connections: vec![saved], + ..Default::default() + }, + ) + .unwrap(); + (temp, runtime, params) +} + +#[tokio::test] +async fn shared_execution_blocks_writes_and_keeps_normal_connections_writable() { + use crate::application::queries::{ + execute_query, execute_query_batch, QueryRequestScope, QueryResponsePolicy, + }; + let (_temp, runtime, params) = fixture().await; + let cancellation = crate::commands::QueryCancellationState::default(); + let read = execute_query( + &runtime, + &cancellation, + QueryRequestScope::DESKTOP, + QueryResponsePolicy::Unbounded, + "protected".into(), + "SELECT COUNT(*) FROM items".into(), + None, + None, + None, + None, + None, + ) + .await + .unwrap(); + assert_eq!(read.rows[0][0], json!(0)); + for sql in [ + "INSERT INTO items VALUES (1)", + "PRAGMA query_only = OFF; INSERT INTO items VALUES (2)", + ] { + assert!(execute_query( + &runtime, + &cancellation, + QueryRequestScope::DESKTOP, + QueryResponsePolicy::Unbounded, + "protected".into(), + sql.into(), + None, + None, + None, + None, + None + ) + .await + .unwrap_err() + .contains("read-only")); + } + let batch = execute_query_batch( + &runtime, + &cancellation, + QueryRequestScope::DESKTOP, + QueryResponsePolicy::Unbounded, + "protected".into(), + vec!["SELECT 1".into(), "SELECT 2".into()], + None, + None, + None, + None, + None, + None, + ) + .await + .unwrap(); + assert_eq!(batch.len(), 2); + assert!(batch.iter().all(|result| result.error.is_none())); + assert!(execute_query_batch( + &runtime, + &cancellation, + QueryRequestScope::DESKTOP, + QueryResponsePolicy::Unbounded, + "protected".into(), + vec!["SELECT 1".into(), "DELETE FROM items".into()], + None, + None, + None, + None, + None, + None + ) + .await + .is_err()); + assert!(crate::application::records::delete_record( + &runtime, + None, + "protected", + "items".into(), + Default::default(), + None, + None + ) + .await + .unwrap_err() + .contains("read-only")); + assert!(crate::application::database_objects::drop_view( + &runtime, + None, + "protected", + "v".into(), + None, + None + ) + .await + .unwrap_err() + .contains("read-only")); + // Imports must be rejected before even opening their source file. + let imports = crate::application::database_transfers::DumpCancellationState::default(); + assert!(crate::application::database_transfers::import_database( + &runtime, + &imports, + None, + "protected".into(), + crate::application::database_transfers::ImportSource::ServerPath("missing.sql".into()), + None, + None, + ) + .await + .unwrap_err() + .contains("read-only")); + assert!(crate::application::table_transfers::import_table( + &runtime, + &imports, + None, + "protected".into(), + crate::application::table_transfers::TableImportSource::ServerPath("missing.sql".into()), + crate::application::table_transfers::TableImportOptions { + if_exists: crate::application::table_transfers::IfTableExists::Fail, + schema: None, + database: None, + }, + ) + .await + .unwrap_err() + .contains("read-only")); + // The same pool must remain usable for a separately configured writable connection. + let driver = crate::drivers::sqlite::SqliteDriver::new(); + driver + .execute_query(¶ms, "INSERT INTO items VALUES (3)", None, 1, None) + .await + .unwrap(); + let result = driver + .execute_query(¶ms, "SELECT COUNT(*) FROM items", None, 1, None) + .await + .unwrap(); + assert_eq!(result.rows[0][0], json!(1)); +} + +#[tokio::test] +async fn read_only_table_export_keeps_rows_and_binary_values() { + use crate::application::table_transfers::{ + export_table, TableExportDestination, TableExportOptions, + }; + let (temp, runtime, params) = fixture().await; + let driver = crate::drivers::sqlite::SqliteDriver::new(); + driver + .execute_query( + ¶ms, + "CREATE TABLE binary_items (id INTEGER, payload BLOB)", + None, + 1, + None, + ) + .await + .unwrap(); + driver + .execute_query( + ¶ms, + "INSERT INTO binary_items VALUES (7, X'00FF')", + None, + 1, + None, + ) + .await + .unwrap(); + let path = temp.path().join("export.sql"); + let exported = export_table( + &runtime, + &crate::export::ExportCancellationState::default(), + None, + "protected".into(), + TableExportOptions { + table: "binary_items".into(), + content: crate::table_sql::TableSqlContent::StructureAndData, + schema: None, + database: None, + }, + TableExportDestination::ServerPath(path.clone()), + ) + .await + .unwrap(); + assert_eq!(exported.rows, 1); + let sql = std::fs::read_to_string(path).unwrap(); + assert!(sql.contains("INSERT INTO")); + assert!(sql.to_lowercase().contains("00ff")); +} diff --git a/src-tauri/src/application/records.rs b/src-tauri/src/application/records.rs index 1b19f39cf..ef36486cf 100644 --- a/src-tauri/src/application/records.rs +++ b/src-tauri/src/application/records.rs @@ -189,6 +189,7 @@ pub async fn delete_record( schema: Option, database: Option, ) -> Result { + crate::application::read_only::ensure_writable(runtime, connection_id)?; log::info!( "Deleting a record on connection {} from table {}", connection_id, @@ -220,6 +221,7 @@ pub async fn update_record( schema: Option, database: Option, ) -> Result { + crate::application::read_only::ensure_writable(runtime, connection_id)?; log::info!( "Updating column {} on connection {} in table {}", col_name, @@ -267,6 +269,7 @@ pub async fn insert_record( schema: Option, database: Option, ) -> Result { + crate::application::read_only::ensure_writable(runtime, connection_id)?; log::info!( "Inserting a record on connection {} into table {}", connection_id, diff --git a/src-tauri/src/application/table_transfers.rs b/src-tauri/src/application/table_transfers.rs index 9b7b01b72..ac2f194c4 100644 --- a/src-tauri/src/application/table_transfers.rs +++ b/src-tauri/src/application/table_transfers.rs @@ -938,9 +938,20 @@ async fn write_plugin_rows( let mut batch: Vec> = Vec::new(); let mut page = 1u32; loop { - let result = drv - .execute_query(params, query, Some(PLUGIN_PAGE_SIZE), page, schema) - .await?; + let result = if params.read_only { + super::read_only::execute_query( + drv.as_ref(), + params, + query, + Some(PLUGIN_PAGE_SIZE), + page, + schema, + ) + .await? + } else { + drv.execute_query(params, query, Some(PLUGIN_PAGE_SIZE), page, schema) + .await? + }; let fetched = result.rows.len() as u32; if writer.columns.is_empty() { writer.columns = result.columns.clone(); @@ -1076,6 +1087,7 @@ pub async fn import_table( source: TableImportSource, options: TableImportOptions, ) -> Result { + crate::application::read_only::ensure_writable(runtime, &connection_id)?; let (driver, mut params) = super::connections::resolve_saved_connection_params(runtime, session_id, &connection_id)?; scope_database(&mut params, options.database.as_deref()); diff --git a/src-tauri/src/clipboard_import.rs b/src-tauri/src/clipboard_import.rs index 5f1ce2109..666195c9c 100644 --- a/src-tauri/src/clipboard_import.rs +++ b/src-tauri/src/clipboard_import.rs @@ -176,6 +176,7 @@ pub async fn import_with_runtime( session_id: Option, req: ClipboardImportRequest, ) -> Result { + crate::application::read_only::ensure_writable(runtime, &req.connection_id)?; log::info!( "Clipboard import: table='{}', rows={}, create_table={}", req.table_name, diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index b441b6611..594c9758a 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1779,6 +1779,7 @@ pub(crate) fn sql_builder_params( database: Option, ) -> ConnectionParams { let mut params = saved.params.clone(); + crate::application::read_only::prepare_params(&mut params); params.connection_id = Some(saved.id.clone()); if crate::drivers::registry::is_local_path_driver(¶ms.driver) { params.database = crate::fs_path::sanitize_database_selection(¶ms.database); diff --git a/src-tauri/src/drivers/driver_trait.rs b/src-tauri/src/drivers/driver_trait.rs index dd9906f9f..95dab4ea0 100644 --- a/src-tauri/src/drivers/driver_trait.rs +++ b/src-tauri/src/drivers/driver_trait.rs @@ -188,6 +188,10 @@ pub struct DriverCapabilities { /// Defaults to `false`. #[serde(default)] pub readonly: bool, + /// Implements database-enforced `execute_read_only_query`. Unlike `readonly`, + /// this allows users to switch a writable connection into read-only mode. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub read_only_queries: bool, /// Offers "Export table to SQL" on table nodes (#709). `None` (the /// manifest omits it) means enabled; non-SQL plugins set `false`. #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/src-tauri/src/drivers/mysql/mod.rs b/src-tauri/src/drivers/mysql/mod.rs index 9ae83256a..8143d4308 100644 --- a/src-tauri/src/drivers/mysql/mod.rs +++ b/src-tauri/src/drivers/mysql/mod.rs @@ -2023,6 +2023,7 @@ impl MysqlDriver { manage_tables: true, explain: true, readonly: false, + read_only_queries: true, table_export: None, table_import: None, triggers: true, diff --git a/src-tauri/src/drivers/postgres/mod.rs b/src-tauri/src/drivers/postgres/mod.rs index 9d0257591..ae1827f29 100644 --- a/src-tauri/src/drivers/postgres/mod.rs +++ b/src-tauri/src/drivers/postgres/mod.rs @@ -2342,6 +2342,7 @@ impl PostgresDriver { manage_tables: true, explain: true, readonly: false, + read_only_queries: true, table_export: None, table_import: None, triggers: true, diff --git a/src-tauri/src/drivers/sqlite/mod.rs b/src-tauri/src/drivers/sqlite/mod.rs index 0cdcc3113..066e4e9cd 100644 --- a/src-tauri/src/drivers/sqlite/mod.rs +++ b/src-tauri/src/drivers/sqlite/mod.rs @@ -806,11 +806,12 @@ pub async fn execute_read_only_query( .await .map_err(|e| e.to_string())?; let outcome = exec_on_sqlite_conn(guard.conn(), query, limit, page).await; - match guard - .conn() - .execute(sqlx::raw_sql("ROLLBACK; PRAGMA query_only = OFF")) - .await - { + let cleanup = if params.read_only { + "ROLLBACK" + } else { + "ROLLBACK; PRAGMA query_only = OFF" + }; + match guard.conn().execute(sqlx::raw_sql(cleanup)).await { Ok(_) => guard.release(), Err(e) => log::warn!("SQLite: resetting after a read-only query failed: {e}"), } @@ -1089,6 +1090,7 @@ impl SqliteDriver { manage_tables: true, explain: true, readonly: false, + read_only_queries: true, table_export: None, table_import: None, triggers: true, diff --git a/src-tauri/src/mcp/mod.rs b/src-tauri/src/mcp/mod.rs index 42d58ffff..8b8c2cd08 100644 --- a/src-tauri/src/mcp/mod.rs +++ b/src-tauri/src/mcp/mod.rs @@ -268,6 +268,7 @@ async fn resolve_db_params( conn_id: &str, ) -> Result<(crate::models::SavedConnection, ConnectionParams), JsonRpcError> { let mut conn = find_connection(conn_id)?; + crate::application::read_only::prepare_params(&mut conn.params); // Load DB password from keychain unless the connection uses IAM auth, // whose 15-min tokens must come from the `password` field on every @@ -1190,6 +1191,17 @@ async fn tool_run_query( apply_database_override(&mut db_params, database, &capabilities)?; // Read-only enforcement (fail-closed: unknown counts as write). + crate::application::read_only::validate_query(&db_params, &capabilities, query).map_err( + |message| { + audit.status = "blocked_readonly".to_string(); + audit.error = Some(message.clone()); + JsonRpcError { + code: -32000, + message, + data: None, + } + }, + )?; if config::is_connection_readonly(config, &conn.id) && kind != "select" { audit.status = "blocked_readonly".to_string(); let msg = "Query blocked by Tabularis read-only mode. Enable writes for this connection in Settings → MCP → Read-only mode.".to_string(); @@ -1373,7 +1385,14 @@ async fn tool_run_query( // here because it was classified as a read, and so is anything sent to a // read-only connection. Classification cannot see what a function call // does, so have the database refuse writes too. - let run_read_only = config::is_connection_readonly(config, &conn.id) + crate::application::read_only::validate_query(&db_params, &capabilities, &effective_query) + .map_err(|message| JsonRpcError { + code: -32000, + message, + data: None, + })?; + let run_read_only = db_params.read_only + || config::is_connection_readonly(config, &conn.id) || (approval_mode != "off" && !needs_approval); let result = if run_read_only { driver diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index cbd9feb94..22edd933a 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -182,6 +182,13 @@ pub struct SshTestParams { #[derive(Debug, Deserialize, Serialize, Clone, Default, PartialEq, Eq)] pub struct ConnectionParams { pub driver: String, + /// User-selected connection policy. Missing in older saved connections. + #[serde( + default, + alias = "readOnly", + skip_serializing_if = "std::ops::Not::not" + )] + pub read_only: bool, pub host: Option, pub port: Option, pub username: Option, diff --git a/src-tauri/src/plugins/driver.rs b/src-tauri/src/plugins/driver.rs index 45a120cb0..542bd78eb 100644 --- a/src-tauri/src/plugins/driver.rs +++ b/src-tauri/src/plugins/driver.rs @@ -1267,6 +1267,39 @@ impl DatabaseDriver for RpcDriver { serde_json::from_value(res).map_err(|e| e.to_string()) } + async fn execute_read_only_query( + &self, + params: &ConnectionParams, + query: &str, + limit: Option, + page: u32, + schema: Option<&str>, + ) -> Result { + if self.manifest.capabilities.readonly { + return self.execute_query(params, query, limit, page, schema).await; + } + if !self.manifest.capabilities.read_only_queries { + // Preserve legacy MCP behaviour when no connection policy was set. + if !params.read_only { + return self.execute_query(params, query, limit, page, schema).await; + } + return Err("This plugin does not support protected read-only queries".into()); + } + let result = self + .process + .call( + "execute_read_only_query", + json!({ + "params": self.with_primary_database(params), "query": query, "limit": limit, + "page": page, "schema": schema, + }), + ) + .await + .map_err(|error| error.to_string())?; + // No execute_query fallback: a missing RPC or protection error fails closed. + serde_json::from_value(result).map_err(|error| error.to_string()) + } + async fn execute_batch( &self, params: &ConnectionParams, @@ -2567,6 +2600,7 @@ mod tests { fn test_connection_params() -> ConnectionParams { ConnectionParams { driver: "test-plugin".to_string(), + read_only: false, host: Some("localhost".to_string()), port: Some(1234), username: Some("user".to_string()), @@ -4122,6 +4156,10 @@ mod table_query_template_tests; #[path = "column_stats_tests.rs"] mod column_stats_tests; +#[cfg(test)] +#[path = "read_only_tests.rs"] +mod read_only_tests; + #[cfg(test)] #[path = "startup_tests.rs"] mod startup_tests; diff --git a/src-tauri/src/plugins/read_only_tests.rs b/src-tauri/src/plugins/read_only_tests.rs new file mode 100644 index 000000000..8f4d395ca --- /dev/null +++ b/src-tauri/src/plugins/read_only_tests.rs @@ -0,0 +1,101 @@ +use super::*; +use crate::drivers::driver_trait::DriverCapabilities; +use crate::plugins::rpc::JsonRpcError; + +#[test] +fn read_only_capability_is_opt_in_and_legacy_manifests_keep_their_wire_shape() { + let caps: DriverCapabilities = serde_json::from_value(json!({ + "schemas":false, "views":false, "routines":false, "file_based":false, + })) + .unwrap(); + assert!(!caps.read_only_queries); + assert!(serde_json::to_value(caps) + .unwrap() + .get("read_only_queries") + .is_none()); +} + +#[tokio::test] +async fn protected_query_uses_the_optional_rpc_with_connection_context() { + let mut driver = test_driver(|rpc| { + assert_eq!(rpc.method, "execute_read_only_query"); + assert_eq!(rpc.params["params"]["read_only"], true); + assert_eq!(rpc.params["params"]["connection_id"], "protected"); + assert_eq!(rpc.params["params"]["database"], "analytics"); + assert_eq!(rpc.params["schema"], "sales"); + assert_eq!(rpc.params["query"], "SELECT 1"); + json!({"columns":["value"],"rows":[[1]],"affected_rows":0}) + }); + driver.manifest.capabilities.read_only_queries = true; + Arc::get_mut(&mut driver.process) + .unwrap() + .route_selected_database = false; + let params = ConnectionParams { + read_only: true, + connection_id: Some("protected".into()), + database: DatabaseSelection::Multiple(vec!["analytics".into(), "other".into()]), + ..Default::default() + }; + let result = driver + .execute_read_only_query(¶ms, "SELECT 1", Some(10), 1, Some("sales")) + .await + .unwrap(); + assert_eq!(result.rows[0][0], json!(1)); +} + +#[tokio::test] +async fn unsupported_plugins_never_receive_an_unprotected_request() { + let driver = test_driver(|_| panic!("no unprotected request is allowed")); + let params = ConnectionParams { + read_only: true, + ..Default::default() + }; + assert!(driver + .execute_read_only_query(¶ms, "SELECT 1", None, 1, None) + .await + .is_err()); +} + +#[tokio::test] +async fn missing_or_failed_protection_never_falls_back_to_execute_query() { + for code in [-32601, -32000] { + let mut driver = test_driver_detailed(move |rpc| { + assert_eq!(rpc.method, "execute_read_only_query"); + Err(PluginCallError::Remote(JsonRpcError { + code, + message: "protection unavailable".into(), + })) + }); + driver.manifest.capabilities.read_only_queries = true; + let params = ConnectionParams { + read_only: true, + ..Default::default() + }; + assert!(driver + .execute_read_only_query(¶ms, "SELECT 1", None, 1, None) + .await + .is_err()); + } +} + +#[tokio::test] +async fn intrinsically_read_only_plugins_need_no_new_rpc() { + let mut driver = test_driver(|rpc| { + assert_eq!(rpc.method, "execute_query"); + json!({"columns":[],"rows":[],"affected_rows":0}) + }); + driver.manifest.capabilities.readonly = true; + driver + .execute_read_only_query( + &ConnectionParams { + read_only: true, + ..Default::default() + }, + "SELECT 1", + None, + 1, + None, + ) + .await + .unwrap(); +} diff --git a/src-tauri/src/pool_manager.rs b/src-tauri/src/pool_manager.rs index 3f7087f41..ef6b09b74 100644 --- a/src-tauri/src/pool_manager.rs +++ b/src-tauri/src/pool_manager.rs @@ -330,6 +330,14 @@ pub(crate) fn build_connection_key( base_key }; + // A protected connection must never borrow an ordinary physical session. + // Writable connections retain their existing keys. + let key = if params.read_only { + format!("read_only:{key}") + } else { + key + }; + // Fold the startup script into the key so editing it forces a fresh pool // (whose new connections run the new script) instead of silently reusing // the cached pool keyed only by connection_id. Hashed to keep the key @@ -519,6 +527,7 @@ async fn build_and_connect_mysql_pool( script: Option<&str>, ) -> Result, String> { let options = build_mysql_options(params, override_db)?; + let script = if params.read_only { None } else { script }; // Validate the startup script up front so a broken script fails fast with a // clearly attributed error (see `run_mysql_startup_script`). This uses the @@ -536,12 +545,18 @@ async fn build_and_connect_mysql_pool( } let mut pool_options = sqlx::mysql::MySqlPoolOptions::new().max_connections(10); - if let Some(script) = script { - let script = script.to_owned(); + if params.read_only || script.is_some() { + let read_only = params.read_only; + let script = script.map(str::to_owned); pool_options = pool_options.after_connect(move |conn, _meta| { let script = script.clone(); Box::pin(async move { - conn.execute(script.as_str()).await?; + if read_only { + conn.execute("SET SESSION TRANSACTION READ ONLY").await?; + } + if let Some(script) = script { + conn.execute(script.as_str()).await?; + } Ok(()) }) }); @@ -575,6 +590,9 @@ pub(crate) fn build_postgres_configurations(params: &ConnectionParams) -> Result if let Some(timeout) = connect_timeout(params) { cfg.connect_timeout(timeout); } + if params.read_only { + cfg.options("-c default_transaction_read_only=on"); + } // Unset leaves tokio_postgres's default (`Prefer`). A set but unknown // mode is a connection error rather than that default. @@ -896,13 +914,22 @@ impl ServerCertVerifier for VerifyCaCertVerifier { } fn build_sqlite_connectoptions(params: &ConnectionParams) -> SqliteConnectOptions { - SqliteConnectOptions::new().filename(expand_sqlite_filename(params.database.primary())) + let options = + SqliteConnectOptions::new().filename(expand_sqlite_filename(params.database.primary())); + if params.read_only { + options.read_only(true).pragma("query_only", "ON") + } else { + options + } } /// Return the connection's startup script if it is set and not blank. /// Whitespace-only scripts are treated as absent so the per-connection /// hook is skipped entirely rather than issuing an empty query. fn startup_script(params: &ConnectionParams) -> Option { + if params.read_only { + return None; + } params .startup_script .as_ref() @@ -911,6 +938,10 @@ fn startup_script(params: &ConnectionParams) -> Option { .map(str::to_owned) } +#[cfg(test)] +#[path = "pool_manager/read_only_tests.rs"] +mod read_only_tests; + /// Format a startup-script execution failure so the surfaced error clearly /// names the startup script as the cause, instead of reading like a bad host /// or wrong credentials. @@ -1367,7 +1398,7 @@ pub(crate) fn connection_pool_prefix(driver: &str, connection_id: &str) -> Strin format!("{driver}:conn:{connection_id}:") } -/// Remove and return every map entry whose key starts with `prefix`. The pools +/// Remove every matching entry, including the isolated `read_only:` namespace. The pools /// are collected under a short-lived write lock and closed by the caller after /// the lock is released, so closing N pools never blocks other pool access. pub(crate) fn remove_pools_with_prefix

( @@ -1376,7 +1407,11 @@ pub(crate) fn remove_pools_with_prefix

( ) -> Vec<(String, P)> { let keys: Vec = pools .keys() - .filter(|k| k.starts_with(prefix)) + .filter(|k| { + k.strip_prefix("read_only:") + .unwrap_or(k) + .starts_with(prefix) + }) .cloned() .collect(); keys.into_iter() diff --git a/src-tauri/src/pool_manager/read_only_tests.rs b/src-tauri/src/pool_manager/read_only_tests.rs new file mode 100644 index 000000000..dd8a31edb --- /dev/null +++ b/src-tauri/src/pool_manager/read_only_tests.rs @@ -0,0 +1,89 @@ +use super::*; + +#[test] +fn protected_pool_keys_are_isolated_and_disconnect_collects_both_policies() { + let params = ConnectionParams { + driver: "sqlite".into(), + database: crate::models::DatabaseSelection::Single("db".into()), + ..Default::default() + }; + let ordinary_key = build_connection_key(¶ms, Some("test")); + let mut protected = params.clone(); + protected.read_only = true; + protected.startup_script = Some("DELETE FROM items".into()); + let protected_key = build_connection_key(&protected, Some("test")); + assert_eq!(protected_key, format!("read_only:{ordinary_key}")); + assert!(startup_script(&protected).is_none()); + let mut pools = HashMap::from([ + (ordinary_key, 1), + (protected_key, 2), + ("sqlite:conn:another:db".into(), 3), + ]); + let removed = remove_pools_with_prefix(&mut pools, &connection_pool_prefix("sqlite", "test")); + assert_eq!(removed.len(), 2); + assert_eq!(pools.len(), 1); +} + +#[test] +fn postgres_read_only_is_installed_before_the_first_query() { + let params = ConnectionParams { + driver: "postgres".into(), + read_only: true, + ..Default::default() + }; + let config = build_postgres_configurations(¶ms).unwrap(); + assert_eq!( + config.get_options(), + Some("-c default_transaction_read_only=on") + ); + let config = build_postgres_configurations(&ConnectionParams { + read_only: false, + ..params + }) + .unwrap(); + assert_eq!(config.get_options(), None); +} + +#[tokio::test] +async fn sqlite_read_only_pool_protects_internal_reads_and_never_contaminates_writable_pools() { + let temp = tempfile::NamedTempFile::new().unwrap(); + let params = ConnectionParams { + driver: "sqlite".into(), + database: crate::models::DatabaseSelection::Single( + temp.path().to_string_lossy().into_owned(), + ), + ..Default::default() + }; + let writable = get_sqlite_pool(¶ms).await.unwrap(); + sqlx::query("CREATE TABLE items (id INTEGER)") + .execute(&writable) + .await + .unwrap(); + let mut protected = params.clone(); + protected.read_only = true; + protected.startup_script = Some("INSERT INTO items VALUES (99)".into()); + let readonly = get_sqlite_pool(&protected).await.unwrap(); + assert!(sqlx::query("INSERT INTO items VALUES (1)") + .execute(&readonly) + .await + .is_err()); + crate::drivers::sqlite::execute_read_only_query(&protected, "SELECT * FROM items", None, 1) + .await + .unwrap(); + let query_only: i64 = sqlx::query_scalar("PRAGMA query_only") + .fetch_one(&readonly) + .await + .unwrap(); + assert_eq!(query_only, 1); + sqlx::query("INSERT INTO items VALUES (2)") + .execute(&writable) + .await + .unwrap(); + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM items") + .fetch_one(&readonly) + .await + .unwrap(); + assert_eq!(count, 1); + readonly.close().await; + writable.close().await; +} diff --git a/src-tauri/tests/postgres_integration/parity.rs b/src-tauri/tests/postgres_integration/parity.rs index 93403747f..1f2f51efd 100644 --- a/src-tauri/tests/postgres_integration/parity.rs +++ b/src-tauri/tests/postgres_integration/parity.rs @@ -290,6 +290,7 @@ fn plugin_manifest() -> PluginManifest { column_comments: false, drop_table_sql: false, column_stats: false, + read_only_queries: false, query_timeout: false, alter_column: true, create_foreign_keys: true, From 712f1d5d422229556350a16c09632a8abfe77cb4 Mon Sep 17 00:00:00 2001 From: Guillaume Vaillant <7757451+pokertour@users.noreply.github.com> Date: Mon, 9 Nov 2026 22:39:24 +0100 Subject: [PATCH 2/2] fix(exports): enforce read-only plugin queries and preserve native streaming --- .../src/application/database_transfers.rs | 12 +- src-tauri/src/application/read_only/tests.rs | 47 ++++ src-tauri/src/export.rs | 59 +++-- src-tauri/src/export/read_only_tests.rs | 234 ++++++++++++++++++ 4 files changed, 330 insertions(+), 22 deletions(-) create mode 100644 src-tauri/src/export/read_only_tests.rs diff --git a/src-tauri/src/application/database_transfers.rs b/src-tauri/src/application/database_transfers.rs index 478fcd74e..87520f37f 100644 --- a/src-tauri/src/application/database_transfers.rs +++ b/src-tauri/src/application/database_transfers.rs @@ -654,10 +654,10 @@ pub(crate) async fn stream_sql_rows( where F: FnMut(&[String], Vec) -> Result<(), String>, { - // Protected exports use the same bounded query path as the editor, - // including for built-ins, rather than bypassing it with a raw stream. - match (driver, params.read_only) { - ("mysql", false) => { + // Built-in pools enforce the saved read-only policy at connection setup, + // so both modes retain native streaming and type-aware cell extraction. + match driver { + "mysql" => { use sqlx::{Column, TypeInfo}; let pool = get_mysql_pool(params).await?; let mut rows = sqlx::query(query).fetch(&pool); @@ -677,7 +677,7 @@ where yield_every_rows(&mut streamed).await; } } - ("postgres", false) => { + "postgres" => { let pool = get_postgres_pool(params).await?; let client = pool.get().await.map_err(|error| error.to_string())?; let query_params: Vec = Vec::new(); @@ -705,7 +705,7 @@ where yield_every_rows(&mut streamed).await; } } - ("sqlite", false) => { + "sqlite" => { use sqlx::Column; let pool = get_sqlite_pool(params).await?; let mut rows = sqlx::query(query).fetch(&pool); diff --git a/src-tauri/src/application/read_only/tests.rs b/src-tauri/src/application/read_only/tests.rs index 09158d175..6dab63db9 100644 --- a/src-tauri/src/application/read_only/tests.rs +++ b/src-tauri/src/application/read_only/tests.rs @@ -336,3 +336,50 @@ async fn read_only_table_export_keeps_rows_and_binary_values() { assert!(sql.contains("INSERT INTO")); assert!(sql.to_lowercase().contains("00ff")); } + +#[tokio::test] +async fn read_only_native_dumps_match_writable_dumps_across_page_boundaries() { + let (_temp, _runtime, params) = fixture().await; + let driver: Arc = Arc::new(crate::drivers::sqlite::SqliteDriver::new()); + driver.execute_query(¶ms, "CREATE TABLE dump_values (id INTEGER, label TEXT, document JSON, payload BLOB, optional TEXT)", None, 1, None).await.unwrap(); + driver.execute_query(¶ms, "WITH RECURSIVE n(id) AS (SELECT 1 UNION ALL SELECT id+1 FROM n WHERE id < 2105) INSERT INTO dump_values SELECT id, 'quote '' and unicode café', '{\"flag\":true}', X'00FF', NULL FROM n", None, 1, None).await.unwrap(); + let mut outputs = Vec::new(); + for read_only in [false, true] { + let params = ConnectionParams { + read_only, + ..params.clone() + }; + let mut output = Vec::new(); + crate::application::database_transfers::stream_sql_rows( + &driver, + ¶ms, + "sqlite", + "SELECT * FROM dump_values ORDER BY id", + "dump_values", + None, + |names, cells| { + let cells = cells + .into_iter() + .map(|cell| { + json!({ + "value":cell.value, "is_json":cell.is_json, + }) + }) + .collect::>(); + output.push(json!({"columns":names,"cells":cells})); + Ok(()) + }, + ) + .await + .unwrap(); + assert_eq!(output.len(), 2105); + // Retain the exact SQL cell semantics, including the JSON/text distinction. + outputs.push(output); + } + for (index, (writable, protected)) in outputs[0].iter().zip(&outputs[1]).enumerate() { + assert_eq!( + writable, protected, + "export row {index} differs between policies" + ); + } +} diff --git a/src-tauri/src/export.rs b/src-tauri/src/export.rs index 1225e5b05..0c7dc0817 100644 --- a/src-tauri/src/export.rs +++ b/src-tauri/src/export.rs @@ -5,6 +5,10 @@ mod sink; #[cfg(test)] mod tests; +#[cfg(test)] +#[path = "export/read_only_tests.rs"] +mod read_only_tests; + pub use format::{parse_csv_delimiter, value_to_csv_string, ExportFormat, DEFAULT_CSV_DELIMITER}; pub use progress::{ProgressEmitter, DEFAULT_INTERVAL as DEFAULT_PROGRESS_INTERVAL}; pub use sink::{CsvSink, JsonSink, MarkdownSink, RowSink}; @@ -82,6 +86,17 @@ where S: RowSink + Send, F: FnMut(u64) + Send, { + if params.read_only { + let driver = crate::drivers::registry::get_connection_driver(params).await?; + crate::application::read_only::validate_query( + params, + &driver.manifest().capabilities, + query, + )?; + } + // An editor session may still hold a writable transaction opened before + // the policy changed. Protected exports must use the isolated pool/RPC. + let session_id = if params.read_only { None } else { session_id }; let mut on_row = |headers: &[String], values: &[Value]| -> Result<(), String> { sink.write_row(headers, values)?; progress.tick(); @@ -118,23 +133,35 @@ where let mut page: u32 = 1; loop { - let result = match session_id { - Some(id) => { - crate::commands::read_in_open_transaction( - driver.as_ref(), - params, - query, - Some(PAGE_SIZE), - page, - None, - id, - ) - .await? - } - None => { - driver - .execute_query(params, query, Some(PAGE_SIZE), page, None) + let result = if params.read_only { + crate::application::read_only::execute_query( + driver.as_ref(), + params, + query, + Some(PAGE_SIZE), + page, + None, + ) + .await? + } else { + match session_id { + Some(id) => { + crate::commands::read_in_open_transaction( + driver.as_ref(), + params, + query, + Some(PAGE_SIZE), + page, + None, + id, + ) .await? + } + None => { + driver + .execute_query(params, query, Some(PAGE_SIZE), page, None) + .await? + } } }; diff --git a/src-tauri/src/export/read_only_tests.rs b/src-tauri/src/export/read_only_tests.rs new file mode 100644 index 000000000..9698639d1 --- /dev/null +++ b/src-tauri/src/export/read_only_tests.rs @@ -0,0 +1,234 @@ +use super::*; +use crate::drivers::driver_trait::SqlDialect; +use crate::plugins::driver::{test_driver_with_manifest, test_manifest}; +use crate::plugins::rpc::JsonRpcRequest; +use serde_json::json; + +async fn plugin_params(support: bool, handle: F) -> ConnectionParams +where + F: FnMut(JsonRpcRequest) -> Result + Send + 'static, +{ + let mut manifest = test_manifest(); + manifest.id = format!("readonly-export-{}", uuid::Uuid::new_v4()); + manifest.capabilities.read_only_queries = support; + manifest.capabilities.sql_dialect = Some(SqlDialect::Sqlite); + let params = ConnectionParams { + driver: manifest.id.clone(), + read_only: true, + ..Default::default() + }; + crate::drivers::registry::register_driver(test_driver_with_manifest(manifest, handle)).await; + params +} + +#[tokio::test] +async fn read_only_plugin_exports_use_protected_rpc_for_every_page_and_format() { + let calls = Arc::new(Mutex::new(Vec::new())); + let recorded = calls.clone(); + let params = plugin_params(true, move |rpc| { + let page = rpc.params["page"].as_u64().unwrap(); + recorded.lock().unwrap().push((rpc.method.clone(), page)); + assert_eq!(rpc.method, "execute_read_only_query"); + assert_eq!(rpc.params["params"]["read_only"], true); + assert!(rpc.params.get("query_session_id").is_none()); + assert!(rpc.params.get("session_id").is_none()); + assert_eq!(rpc.params["limit"], 1000); + Ok(json!({"columns":["id"], "rows":if page == 1 {json!([[1],[2]])} else {json!([[3]])}, + "affected_rows":0, "pagination":{"page":page,"page_size":1000,"total_rows":3,"has_more":page == 1}})) + }).await; + for format in [ + ExportFormat::Csv, + ExportFormat::Json, + ExportFormat::Markdown, + ] { + for session in [None, Some("previously-writable-session")] { + calls.lock().unwrap().clear(); + let mut output = Vec::new(); + run_export( + ¶ms.driver, + ¶ms, + "SELECT id FROM items", + &mut output, + format, + b',', + session, + |_| {}, + ) + .await + .unwrap(); + assert_eq!( + *calls.lock().unwrap(), + vec![ + ("execute_read_only_query".to_string(), 1), + ("execute_read_only_query".to_string(), 2) + ] + ); + let text = String::from_utf8(output).unwrap(); + assert!(text.contains('3')); + if format == ExportFormat::Json { + assert_eq!( + serde_json::from_str::(&text).unwrap(), + json!([{"id":1},{"id":2},{"id":3}]) + ); + } + } + } +} + +#[tokio::test] +async fn read_only_plugin_exports_block_writes_and_unsupported_drivers_before_rpc() { + for support in [true, false] { + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let recorded = calls.clone(); + let params = plugin_params(support, move |_| { + recorded.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Err("unexpected RPC".into()) + }) + .await; + let queries = if support { + vec![ + "DELETE FROM items", + "SELECT 1; INSERT INTO items VALUES (2)", + ] + } else { + vec!["SELECT 1"] + }; + for query in queries { + assert!(run_export( + ¶ms.driver, + ¶ms, + query, + Vec::new(), + ExportFormat::Json, + b',', + Some("previously-writable-session"), + |_| {} + ) + .await + .is_err()); + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0); + } + } +} + +#[tokio::test] +async fn read_only_plugin_exports_never_retry_without_protection() { + for error in [ + "method not found", + "database refused a write in SELECT mutate_data()", + ] { + let calls = Arc::new(Mutex::new(Vec::new())); + let recorded = calls.clone(); + let params = plugin_params(true, move |rpc| { + recorded.lock().unwrap().push(rpc.method); + Err(error.into()) + }) + .await; + assert!(run_export( + ¶ms.driver, + ¶ms, + "SELECT mutate_data()", + Vec::new(), + ExportFormat::Json, + b',', + None, + |_| {} + ) + .await + .is_err()); + assert_eq!(*calls.lock().unwrap(), vec!["execute_read_only_query"]); + } +} + +#[tokio::test] +async fn writable_plugin_exports_preserve_normal_and_session_execution() { + let calls = Arc::new(Mutex::new(Vec::new())); + let recorded = calls.clone(); + let mut params = plugin_params(false, move |rpc| { + recorded.lock().unwrap().push(rpc.method.clone()); + let result = json!({"columns":["id"],"rows":[[1]],"affected_rows":0}); + if rpc.method == "execute_query_batch" { + assert_eq!(rpc.params["session_id"], "writable-session"); + return Ok(json!((0..4) + .map(|_| json!({"result":result,"error":null})) + .collect::>())); + } + assert_eq!(rpc.method, "execute_query"); + Ok(result) + }) + .await; + params.read_only = false; + for session in [None, Some("writable-session")] { + run_export( + ¶ms.driver, + ¶ms, + "SELECT 1", + Vec::new(), + ExportFormat::Json, + b',', + session, + |_| {}, + ) + .await + .unwrap(); + } + assert_eq!( + *calls.lock().unwrap(), + vec!["execute_query", "execute_query_batch"] + ); +} + +#[tokio::test] +async fn read_only_native_exports_reject_writes_and_policy_reset_statements() { + let file = tempfile::NamedTempFile::new().unwrap(); + let params = ConnectionParams { + driver: "sqlite".into(), + database: crate::models::DatabaseSelection::Single( + file.path().to_string_lossy().into_owned(), + ), + ..Default::default() + }; + crate::drivers::registry::register_driver(sqlite::SqliteDriver::new()).await; + sqlite::execute_query(¶ms, "CREATE TABLE items (id INTEGER)", None, 1) + .await + .unwrap(); + let protected = ConnectionParams { + read_only: true, + ..params.clone() + }; + for query in [ + "PRAGMA query_only = OFF", + "DELETE FROM items RETURNING id", + "PRAGMA query_only = OFF; INSERT INTO items VALUES (9)", + ] { + assert!(run_export( + "sqlite", + &protected, + query, + Vec::new(), + ExportFormat::Json, + b',', + Some("previously-writable-session"), + |_| {} + ) + .await + .is_err()); + } + let mut output = Vec::new(); + run_export( + "sqlite", + &protected, + "SELECT COUNT(*) AS count FROM items", + &mut output, + ExportFormat::Json, + b',', + Some("previously-writable-session"), + |_| {}, + ) + .await + .unwrap(); + assert_eq!( + serde_json::from_slice::(&output).unwrap(), + json!([{"count":0}]) + ); +}