diff --git a/README.md b/README.md index e4397e3..2f42dd4 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Features: * Save queries on single location for later use * Define custom table helpers * Bind parameters (see `:help vim-dadbod-ui-bind-parameters`) +* Read-only mode for production database safety * Autocompletion with [vim-dadbod-completion](https://github.com/kristijanhusak/vim-dadbod-completion) * Jump to foreign keys from the dadbod output (see `:help (DBUI_JumpToForeignKey)`) * Support for nerd fonts (see `:help g:db_ui_use_nerd_fonts`) @@ -151,12 +152,31 @@ Just make sure to **NOT COMMIT** these. I suggest using project local vim config Using `:DBUIAddConnection` command or pressing `A` in dbui drawer opens up a prompt to enter database url and name, that will be saved in `g:db_ui_save_location` connections file. These connections are available from everywhere. +When adding a connection, you'll be prompted whether it should be read-only to prevent mutation queries. + #### Connection related notes It is possible to have two connections with same name, but from different source. for example, you can have `my-db` in env variable, in `g:dbs` and in saved connections. To view from which source the database is, press `H` in drawer. If there are duplicate connection names from same source, warning will be shown and first one added will be preserved. +### Read-only mode +Connections can be marked as read-only to prevent accidental data modifications in production databases. +Read-only mode blocks INSERT, UPDATE, DELETE, DROP, ALTER, and other mutation queries while allowing SELECT queries. + +To mark a connection as read-only, add `"read_only": 1` to your `connections.json`: +```json +[ + { + "name": "production", + "url": "postgresql://user:pass@host:5432/db", + "read_only": 1 + } +] +``` + +Read-only connections show `[READ-ONLY]` in the drawer and statusline. + ## Settings An overview of all settings and their default values can be found at `:help vim-dadbod-ui`. diff --git a/autoload/db_ui.vim b/autoload/db_ui.vim index e549a93..3ccf8e9 100644 --- a/autoload/db_ui.vim +++ b/autoload/db_ui.vim @@ -157,7 +157,11 @@ function! db_ui#statusline(...) call add(content, entry) endif endfor - return prefix.join(content, separator) + let result = prefix.join(content, separator) + if get(b:, 'dbui_read_only', 0) + let result = '[READ-ONLY] ' . result + endif + return result endfunction function! s:dbui.new() abort @@ -259,7 +263,8 @@ function! s:dbui.generate_new_db_entry(db) abort \ 'schema_support': 0, \ 'quote': 0, \ 'default_scheme': '', - \ 'filetype': '' + \ 'filetype': '', + \ 'read_only': get(a:db, 'read_only', 0) \ } call self.populate_schema_info(db) @@ -288,7 +293,7 @@ function! s:dbui.populate_from_global_variable() abort if exists('g:db') && !empty(g:db) let url = self.resolve_url_global_variable(g:db) let gdb_name = split(url, '/')[-1] - call self.add_if_not_exists(gdb_name, url, 'g:dbs') + call self.add_if_not_exists(gdb_name, url, 'g:dbs', 0) endif if !exists('g:dbs') || empty(g:dbs) @@ -297,13 +302,14 @@ function! s:dbui.populate_from_global_variable() abort if type(g:dbs) ==? type({}) for [db_name, Db_url] in items(g:dbs) - call self.add_if_not_exists(db_name, self.resolve_url_global_variable(Db_url), 'g:dbs') + call self.add_if_not_exists(db_name, self.resolve_url_global_variable(Db_url), 'g:dbs', 0) endfor return self endif for db in g:dbs - call self.add_if_not_exists(db.name, self.resolve_url_global_variable(db.url), 'g:dbs') + let read_only = get(db, 'read_only', 0) + call self.add_if_not_exists(db.name, self.resolve_url_global_variable(db.url), 'g:dbs', read_only) endfor return self @@ -326,7 +332,7 @@ function! s:dbui.populate_from_dotenv() abort for [name, url] in items(all_envs) if stridx(name, prefix) != -1 let db_name = tolower(join(split(name, prefix))) - call self.add_if_not_exists(db_name, url, 'dotenv') + call self.add_if_not_exists(db_name, url, 'dotenv', 0) endif endfor endfunction @@ -350,7 +356,7 @@ function! s:dbui.populate_from_env() abort \ printf('Found %s variable for db url, but unable to parse the name. Please provide name via %s', g:db_ui_env_variable_url, g:db_ui_env_variable_name)) endif - call self.add_if_not_exists(env_name, env_url, 'env') + call self.add_if_not_exists(env_name, env_url, 'env', 0) return self endfunction @@ -371,20 +377,25 @@ function! s:dbui.populate_from_connections_file() abort let file = db_ui#utils#readfile(self.connections_path) for conn in file - call self.add_if_not_exists(conn.name, conn.url, 'file') + call self.add_if_not_exists(conn.name, conn.url, 'file', get(conn, 'read_only', 0)) endfor return self endfunction -function! s:dbui.add_if_not_exists(name, url, source) abort +function! s:dbui.add_if_not_exists(name, url, source, ...) abort let existing = get(filter(copy(self.dbs_list), 'v:val.name ==? a:name && v:val.source ==? a:source'), 0, {}) if !empty(existing) return db_ui#notifications#warning(printf('Warning: Duplicate connection name "%s" in "%s" source. First one added has precedence.', a:name, a:source)) endif - return add(self.dbs_list, { + let read_only = get(a:, 1, 0) + let entry = { \ 'name': a:name, 'url': db_ui#resolve(a:url), 'source': a:source, 'key_name': printf('%s_%s', a:name, a:source) - \ }) + \ } + if read_only + let entry.read_only = read_only + endif + return add(self.dbs_list, entry) endfunction function! s:dbui.is_tmp_location_buffer(db, buf) abort diff --git a/autoload/db_ui/connections.vim b/autoload/db_ui/connections.vim index ab06e79..3f7f06c 100644 --- a/autoload/db_ui/connections.vim +++ b/autoload/db_ui/connections.vim @@ -59,7 +59,13 @@ function! s:connections.add_full_url() abort return db_ui#notifications#error(v:exception) endtry - return self.save(name, url) + let read_only = 0 + let read_only_choice = confirm('Should this connection be read-only (prevents mutation queries)?', "&No\n&Yes") + if read_only_choice ==? 2 + let read_only = 1 + endif + + return self.save(name, url, read_only) endfunction function! s:connections.rename(db) abort @@ -99,8 +105,17 @@ function! s:connections.rename(db) abort return db_ui#notifications#error(v:exception) endtry + let read_only = get(entry, 'read_only', 0) + let read_only_default = read_only ? 2 : 1 + let read_only_choice = confirm('Should this connection be read-only (prevents mutation queries)?', "&No\n&Yes", read_only_default) + let read_only = read_only_choice ==? 2 ? 1 : 0 + call remove(connections, idx) - let connections = insert(connections, {'name': name, 'url': url }, idx) + let new_conn = {'name': name, 'url': url} + if read_only + let new_conn.read_only = 1 + endif + let connections = insert(connections, new_conn, idx) return self.write(connections) endfunction @@ -119,7 +134,7 @@ function! s:connections.get_file() abort return printf('%s/%s', save_folder, 'connections.json') endfunction -function s:connections.save(name, url) abort +function s:connections.save(name, url, ...) abort let file = self.get_file() let dir = fnamemodify(file, ':p:h') @@ -137,7 +152,12 @@ function s:connections.save(name, url) abort call db_ui#notifications#error('Connection with that name already exists. Please enter different name.') return 0 endif - call add(file, {'name': a:name, 'url': a:url}) + let read_only = get(a:, 1, 0) + let conn = {'name': a:name, 'url': a:url} + if read_only + let conn.read_only = 1 + endif + call add(file, conn) return self.write(file) endfunction diff --git a/autoload/db_ui/drawer.vim b/autoload/db_ui/drawer.vim index 6d42f7c..b97bc0c 100644 --- a/autoload/db_ui/drawer.vim +++ b/autoload/db_ui/drawer.vim @@ -409,6 +409,10 @@ endfunction function! s:drawer.add_db(db) abort let db_name = a:db.name + if get(a:db, 'read_only', 0) + let db_name = '[READ-ONLY] ' . db_name + endif + if !empty(a:db.conn_error) let db_name .= ' '.g:db_ui_icons.connection_error elseif !empty(a:db.conn) diff --git a/autoload/db_ui/query.vim b/autoload/db_ui/query.vim index 2cbdfb2..6c30fb3 100644 --- a/autoload/db_ui/query.vim +++ b/autoload/db_ui/query.vim @@ -154,6 +154,7 @@ function! s:query.setup_buffer(db, opts, buffer_name, was_single_win) abort let b:dbui_table_name = get(a:opts, 'table', '') let b:dbui_schema_name = get(a:opts, 'schema', '') let b:db = a:db.conn + let b:dbui_read_only = get(a:db, 'read_only', 0) let is_existing_buffer = get(a:opts, 'existing_buffer', 0) let is_tmp = self.drawer.dbui.is_tmp_location_buffer(a:db, a:buffer_name) let db_buffers = self.drawer.dbui.dbs[a:db.key_name].buffers @@ -213,12 +214,22 @@ endfunction function! s:query.execute_query(...) abort let is_visual_mode = get(a:, 1, 0) let lines = self.get_lines(is_visual_mode) + + " Check for read-only mode + let db = self.drawer.dbui.dbs[b:dbui_db_key_name] + if get(db, 'read_only', 0) + let query_text = join(lines, "\n") + let error = db_ui#utils#validate_query_for_read_only(query_text) + if !empty(error) + return db_ui#notifications#error(error) + endif + endif + call s:start_query() if !is_visual_mode && search(s:bind_param_rgx, 'n') <= 0 call db_ui#utils#print_debug({ 'message': 'Executing whole buffer', 'command': '%DB' }) silent! exe '%DB' else - let db = self.drawer.dbui.dbs[b:dbui_db_key_name] call self.execute_lines(db, lines, is_visual_mode) endif let has_async = exists('*db#cancel') diff --git a/autoload/db_ui/utils.vim b/autoload/db_ui/utils.vim index 6e023cc..2bc85a3 100644 --- a/autoload/db_ui/utils.vim +++ b/autoload/db_ui/utils.vim @@ -60,3 +60,56 @@ function! db_ui#utils#print_debug(msg) abort echom '[DBUI Debug] '.string(a:msg) endfunction + +function! db_ui#utils#is_query_mutation(query) abort + let blocked_keywords = [ + \ 'INSERT', 'UPDATE', 'DELETE', 'DROP', 'ALTER', + \ 'TRUNCATE', 'REPLACE', 'MERGE', + \ 'GRANT', 'REVOKE', 'RENAME', + \ 'CREATE TABLE', 'CREATE INDEX', 'CREATE DATABASE', + \ 'CREATE SCHEMA', 'CREATE VIEW', 'CREATE FUNCTION', + \ 'CREATE PROCEDURE', 'CREATE TRIGGER' + \ ] + + let upper_query = toupper(trim(a:query)) + + " Remove single line comments + let upper_query = substitute(upper_query, '--[^\n]*', '', 'g') + " Remove multi-line comments + let upper_query = substitute(upper_query, '/\*\_.\{-}\*/', '', 'g') + " Remove string literals to avoid false positives + let upper_query = substitute(upper_query, '''[^'']*''', '''''', 'g') + let upper_query = substitute(upper_query, '"[^"]*"', '""', 'g') + let upper_query = trim(upper_query) + + " Split by semicolons to check each statement + let statements = split(upper_query, ';') + + for statement in statements + let statement = trim(statement) + if empty(statement) + continue + endif + + " Check for blocked mutation keywords at the start of each statement + for blocked_keyword in blocked_keywords + if statement =~# '^\s*' . blocked_keyword . '\>' + return 1 + endif + + " Check for WITH clause followed by mutation + if statement =~# '^\s*WITH\s\+.\{-}\s\+' . blocked_keyword . '\>' + return 1 + endif + endfor + endfor + + return 0 +endfunction + +function! db_ui#utils#validate_query_for_read_only(query) abort + if db_ui#utils#is_query_mutation(a:query) + return 'Mutation queries are not allowed in read-only mode' + endif + return '' +endfunction diff --git a/doc/dadbod-ui.txt b/doc/dadbod-ui.txt index fce35fb..f2f67c6 100644 --- a/doc/dadbod-ui.txt +++ b/doc/dadbod-ui.txt @@ -21,6 +21,7 @@ vim-dadbod-ui *vim-dadbod-ui* 9. Functions |vim-dadbod-ui-functions| 10. Autocommands |vim-dadbod-ui-autocommands| 11. Highlights |vim-dadbod-ui-highlights| +12. Read-only mode |vim-dadbod-ui-read-only| ============================================================================== 1. Introduction *vim-dadbod-ui-introduction* @@ -34,6 +35,7 @@ Main features: 3. Save queries on single location for later use 4. Define custom table helpers 5. Bind parameters +6. Read-only mode for production database safety ============================================================================== 2. Install *vim-dadbod-ui-install* @@ -899,6 +901,32 @@ g:db_ui_drawer_sections < Default value: `['new_query', 'buffers', 'saved_queries', 'schemas']` +============================================================================== +12. Read-only mode *vim-dadbod-ui-read-only* + +Connections can be marked as read-only to prevent accidental data +modifications in production databases. Read-only mode blocks mutation queries +(INSERT, UPDATE, DELETE, DROP, ALTER, etc.) while allowing SELECT queries. + +When adding a connection via |DBUIAddConnection|, you'll be prompted to mark +it as read-only. To manually mark connections as read-only, edit the +`connections.json` file in your |g:db_ui_save_location| directory: +> + [ + { + "name": "production", + "url": "postgresql://user:pass@host:5432/db", + "read_only": 1 + } + ] +< + +Read-only connections display `[READ-ONLY]` in the drawer and statusline. + +Note: The validator scans all statements in your buffer. If any statement +is a mutation, the entire execution is blocked. + + *g:db_ui_default_query* g:db_ui_default_query (DEPRECATED) This value was intially used as a default value for the table diff --git a/test/test-read-only-mode.vim b/test/test-read-only-mode.vim new file mode 100644 index 0000000..3c0e133 --- /dev/null +++ b/test/test-read-only-mode.vim @@ -0,0 +1,37 @@ +let s:suite = themis#suite('Read-only mode') +let s:expect = themis#helper('expect') + +function! s:suite.before() abort + call SetupTestDbs() +endfunction + +function! s:suite.after() abort + call Cleanup() +endfunction + +function! s:suite.should_detect_mutation_queries() abort + call s:expect(db_ui#utils#is_query_mutation('INSERT INTO users VALUES (1, "test")')).to_be_true() + call s:expect(db_ui#utils#is_query_mutation('UPDATE users SET name = "test"')).to_be_true() + call s:expect(db_ui#utils#is_query_mutation('DELETE FROM users WHERE id = 1')).to_be_true() + call s:expect(db_ui#utils#is_query_mutation('DROP TABLE users')).to_be_true() + call s:expect(db_ui#utils#is_query_mutation('CREATE TABLE users (id INT)')).to_be_true() +endfunction + +function! s:suite.should_allow_select_queries() abort + call s:expect(db_ui#utils#is_query_mutation('SELECT * FROM users')).to_be_false() + call s:expect(db_ui#utils#is_query_mutation('SHOW TABLES')).to_be_false() + call s:expect(db_ui#utils#is_query_mutation('DESCRIBE users')).to_be_false() +endfunction + +function! s:suite.should_handle_multi_statement_queries() abort + " The original bug report case - SELECT followed by DELETE + call s:expect(db_ui#utils#is_query_mutation("SELECT\n *\nFROM\n user_entity\nLIMIT 10;\n\nDELETE FROM user_entity WHERE user_id = 'x'")).to_be_true() + + call s:expect(db_ui#utils#is_query_mutation("SELECT * FROM users;\nDELETE FROM users WHERE id = 1")).to_be_true() + call s:expect(db_ui#utils#is_query_mutation("SELECT * FROM users;\nSELECT * FROM posts")).to_be_false() +endfunction + +function! s:suite.should_ignore_keywords_in_comments_and_strings() abort + call s:expect(db_ui#utils#is_query_mutation("-- DELETE FROM users\nSELECT * FROM users")).to_be_false() + call s:expect(db_ui#utils#is_query_mutation("SELECT * FROM users WHERE name = 'DELETE'")).to_be_false() +endfunction