dbviewer 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 12d0c138efd718011eda36a79ea33a9e96f6609032b1160249e19876df86d00e
4
- data.tar.gz: 11150deaa07278a6073f9977000fc4075ac0d24f671601a3525d19afd27b2e25
3
+ metadata.gz: da5b1e52cd18bd48043589666bfca92019afbf065b2b327d415aa2bacf85658b
4
+ data.tar.gz: d5061713ee1b8726289202bd77972c95cc1383ab9b30b859020ca76a8a8e186b
5
5
  SHA512:
6
- metadata.gz: 0e0bfde143b99b1412a405d46eee0e13efb3711d7b33a297c0327f712dfb946acb808052b377df970dfae65e97656a826af3cab7896a0e544831ad9942a048a4
7
- data.tar.gz: 342ee2c29f04c64d68272f843c45860bf60a51e61e338d1a9ff5ae579d85ec6a5684d4bbd12f95b46a1f452100eb01796d57a0caa2a96b0b5732aecb050a4896
6
+ metadata.gz: 8e75e627915d175150dc88030b11ea6ea961f832f24c7ab2144d90939b6ed76676db7994e2741acaec5e06a6541bbcd0eca8dd5752c0e21bac1f7288f0479234
7
+ data.tar.gz: d04158eaedb765a5b22af6e9ceec3b6817f5e11a6980affd47a1ace976c8f20d4a868dd93031cf463ae8f73462e6b79dd562356087090cbcc17c3b46092ca34d
data/README.md CHANGED
@@ -1,5 +1,3 @@
1
- ![dbviewer](https://github.com/user-attachments/assets/665c1a65-aab3-4a7e-aa54-b42e871cb3d0)
2
-
3
1
  # 👁️ DBViewer
4
2
 
5
3
  > **The fastest way to visualize and explore your database**
@@ -7,7 +5,7 @@
7
5
  DBViewer is a powerful Rails engine that provides a comprehensive interface to view and explore database tables, records, and schema.
8
6
  It's designed for development, debugging, and database analysis, offering a clean and intuitive way to interact with your application's database.
9
7
 
10
- <img width="1470" alt="image" src="https://github.com/user-attachments/assets/0d2719ad-f5b4-4818-891d-5bff7be6c5c3" />
8
+ <img width="1470" alt="image" src="https://github.com/user-attachments/assets/6bc68cfa-903e-49fb-a450-425317d6cbc0" />
11
9
 
12
10
  ## ✨ Features
13
11
 
@@ -25,16 +23,6 @@ It's designed for development, debugging, and database analysis, offering a clea
25
23
 
26
24
  You can explore a live demo of DBViewer at [https://dbviewer-demo.wailantirajoh.tech/](https://dbviewer-demo.wailantirajoh.tech/). This demo showcases all the features of DBViewer on a sample database, allowing you to try out the tool before installing it in your own application.
27
25
 
28
- ## 📸 Screenshots
29
-
30
- <details>
31
- <summary>Click to see more screenshots</summary>
32
-
33
- <img width="1470" alt="image" src="https://github.com/user-attachments/assets/7d708c14-5f78-42c4-b769-2167546b3aad" />
34
- <img width="1470" alt="image" src="https://github.com/user-attachments/assets/f6d9a39a-a571-4328-908a-d96b3148f707" />
35
-
36
- </details>
37
-
38
26
  ## 📥 Installation
39
27
 
40
28
  Add this line to your application's Gemfile:
@@ -120,6 +108,15 @@ Dbviewer.configure do |config|
120
108
  # Authentication options
121
109
  # config.admin_credentials = { username: "admin", password: "your_secure_password" } # Basic HTTP auth credentials
122
110
 
111
+ # Table and Column Access Control
112
+ # config.access_control_mode = :whitelist # :whitelist, :blacklist, or :none (default)
113
+ # config.allowed_tables = ['users', 'orders', 'products'] # Only these tables accessible (whitelist mode)
114
+ # config.blocked_tables = ['admin_users', 'sensitive_data'] # These tables blocked (blacklist mode)
115
+ # config.blocked_columns = { # Hide sensitive columns from specific tables
116
+ # 'users' => ['password_digest', 'api_key', 'secret_token'],
117
+ # 'orders' => ['internal_notes']
118
+ # }
119
+
123
120
  # Disable DBViewer completely
124
121
  # config.disabled = Rails.env.production? # Disable in production
125
122
  end
@@ -260,11 +257,11 @@ end
260
257
 
261
258
  When disabled, all DBViewer routes return 404 responses, making it appear as if the tool was never installed. This is the recommended approach for production systems where database admin tools should not be accessible.
262
259
 
263
- ## 🔐 PII Data Masking
260
+ ### 🔐 PII Data Masking
264
261
 
265
262
  DBViewer includes built-in support for masking Personally Identifiable Information (PII) to protect sensitive data while allowing developers to browse database contents.
266
263
 
267
- ### Quick Setup
264
+ #### Quick Setup
268
265
 
269
266
  Configure PII masking in your Rails initializer (e.g., `config/initializers/dbviewer.rb`):
270
267
 
@@ -296,7 +293,7 @@ Dbviewer.configure_pii do |pii|
296
293
  end
297
294
  ```
298
295
 
299
- ### Built-in Masking Types
296
+ #### Built-in Masking Types
300
297
 
301
298
  - **`:email`** - Masks email addresses while preserving domain
302
299
  - **`:phone`** - Masks phone numbers keeping first and last digits
@@ -305,6 +302,22 @@ end
305
302
  - **`:full_redact`** - Completely redacts the value
306
303
  - **`:partial`** - Partial masking (default behavior)
307
304
 
305
+ ### Table and Column Access Control
306
+
307
+ DBViewer includes granular access control features to restrict access to specific tables and columns, providing an additional layer of security beyond basic authentication.
308
+
309
+ #### Access Control Modes
310
+
311
+ DBViewer supports three access control modes:
312
+
313
+ - **`:none`** (default) - All tables are accessible (current behavior)
314
+ - **`:whitelist`** - Only explicitly allowed tables are accessible (most secure)
315
+ - **`:blacklist`** - All tables except explicitly blocked ones are accessible
316
+
317
+ #### Whitelist Mode (Recommended for Production)
318
+
319
+ Whitelist mode is the most secure approach, where only explicitly allowed tables can be accessed:
320
+
308
321
  ### Generate Example Configuration
309
322
 
310
323
  Use the generator to create an example PII configuration:
@@ -0,0 +1,25 @@
1
+ module Dbviewer
2
+ module AccessControlValidation
3
+ extend ActiveSupport::Concern
4
+
5
+ private
6
+
7
+ def validate_table_access(table_name)
8
+ return unless table_name.present?
9
+
10
+ unless access_control.table_accessible?(table_name)
11
+ Rails.logger.warn "DBViewer: Access denied for table '#{table_name}'"
12
+ flash[:alert] = access_control.access_violation_message(table_name)
13
+ redirect_to tables_path and return
14
+ else
15
+ Rails.logger.info "DBViewer: Access granted for table '#{table_name}'"
16
+ end
17
+ end
18
+
19
+ def validate_query_access(sql)
20
+ unless access_control.validate_query_table_access(sql)
21
+ raise SecurityError, "Query contains references to inaccessible tables"
22
+ end
23
+ end
24
+ end
25
+ end
@@ -6,7 +6,9 @@ module Dbviewer
6
6
  # Fetch all tables with their stats
7
7
  # By default, don't include record counts for better performance on sidebar
8
8
  def fetch_tables(include_record_counts = false)
9
- database_manager.tables.map do |table_name|
9
+ all_table_names = database_manager.tables
10
+ filtered_table_names = filter_accessible_tables(all_table_names)
11
+ filtered_table_names.map do |table_name|
10
12
  table_stats = { name: table_name }
11
13
  table_stats[:record_count] = fetch_table_record_count(table_name) if include_record_counts
12
14
  table_stats
@@ -17,5 +17,17 @@ module Dbviewer
17
17
  def table_query_operations
18
18
  @table_query_operations ||= database_manager.table_query_operations
19
19
  end
20
+
21
+ def access_control
22
+ @access_control ||= Dbviewer::Security::AccessControl.new
23
+ end
24
+
25
+ def filter_accessible_tables(tables)
26
+ access_control.filter_accessible_tables(tables)
27
+ end
28
+
29
+ def filter_accessible_columns(table_name, columns)
30
+ access_control.filter_accessible_columns(table_name, columns)
31
+ end
20
32
  end
21
33
  end
@@ -37,20 +37,37 @@ module Dbviewer
37
37
  def extract_table_relationships(table_name)
38
38
  metadata = fetch_table_metadata(table_name)
39
39
  return [] unless metadata&.dig(:foreign_keys)&.present?
40
-
41
- metadata[:foreign_keys].map do |fk|
42
- {
43
- from_table: table_name,
44
- to_table: fk[:to_table],
45
- from_column: fk[:column],
46
- to_column: fk[:primary_key],
47
- name: fk[:name]
48
- }
40
+ table_names = @tables.map { |t| t[:name] }
41
+ metadata[:foreign_keys].filter_map do |fk|
42
+ # Only include relationship if target table is also accessible
43
+ if table_names.include?(fk[:to_table])
44
+ {
45
+ from_table: table_name,
46
+ to_table: fk[:to_table],
47
+ from_column: fk[:column],
48
+ to_column: fk[:primary_key],
49
+ name: fk[:name]
50
+ }
51
+ end
49
52
  end
50
53
  rescue => e
51
54
  Rails.logger.error("[DBViewer] Error fetching relationships for #{table_name}: #{e.message}")
52
55
  [] # Return empty array to continue processing other tables
53
56
  end
57
+
58
+ # Override to filter relationships to only accessible tables
59
+ def fetch_table_relationships(tables)
60
+ table_names = tables.map { |t| t[:name] }
61
+
62
+ # Get all relationships from accessible tables
63
+ all_relationships = super(tables)
64
+
65
+ # Filter to only include relationships where both source and target tables are accessible
66
+ all_relationships.select do |relationship|
67
+ table_names.include?(relationship[:from_table]) &&
68
+ table_names.include?(relationship[:to_table])
69
+ end
70
+ end
54
71
  end
55
72
  end
56
73
  end
@@ -2,18 +2,25 @@ module Dbviewer
2
2
  module Api
3
3
  class TablesController < BaseController
4
4
  def index
5
- tables_count = fetch_tables_count
6
- render_success(total_tables: tables_count)
5
+ render_success(total_tables: fetch_tables.length)
7
6
  end
8
7
 
9
8
  def show
10
- table_stats = fetch_table_stats(params[:id])
9
+ table_name = params[:id]
10
+
11
+ # Check if table is accessible
12
+ unless access_control.table_accessible?(table_name)
13
+ render_error(access_control.access_violation_message(table_name), :forbidden)
14
+ return
15
+ end
16
+
17
+ table_stats = fetch_table_stats(table_name)
11
18
  render_success(**table_stats)
12
19
  end
13
20
 
14
21
  def records
15
- tables_stats = fetch_tables_stats
16
- render_success(tables_stats)
22
+ all_tables_stats = fetch_tables_stats
23
+ render_success(all_tables_stats)
17
24
  end
18
25
 
19
26
  def relationships_count
@@ -25,14 +32,23 @@ module Dbviewer
25
32
  table_name = params[:id]
26
33
  record_id = params[:record_id]
27
34
 
35
+ # Check if table is accessible
36
+ unless access_control.table_accessible?(table_name)
37
+ render_error("Access denied: Table '#{table_name}' is not accessible", :forbidden)
38
+ return
39
+ end
40
+
28
41
  reverse_foreign_keys = fetch_table_metadata(table_name).dig(:reverse_foreign_keys) || []
29
42
  relationship_counts = reverse_foreign_keys.map do |rel|
43
+ # Only include relationships to accessible tables
44
+ next unless access_control.table_accessible?(rel[:from_table])
45
+
30
46
  {
31
47
  table: rel[:from_table],
32
48
  foreign_key: rel[:column],
33
49
  count: database_manager.get_model_for(rel[:from_table]).where(rel[:column] => record_id).count
34
50
  }
35
- end
51
+ end.compact
36
52
 
37
53
  render_success({
38
54
  table_name: table_name,
@@ -1,11 +1,16 @@
1
1
  module Dbviewer
2
2
  class TablesController < ApplicationController
3
+ include Dbviewer::AccessControlValidation
4
+
3
5
  before_action :set_table_name, except: [ :index ]
6
+ before_action :validate_table, only: [ :show, :query, :export_csv ]
4
7
  before_action :set_query_filters, only: [ :show, :export_csv ]
5
8
  before_action :set_global_filters, only: [ :show, :export_csv ]
6
9
 
7
10
  def index
8
- @tables = fetch_tables(:include_record_counts)
11
+ @tables = @tables.map do |table|
12
+ table.merge(record_count: fetch_table_record_count(table[:name]))
13
+ end
9
14
  end
10
15
 
11
16
  def show
@@ -21,13 +26,24 @@ module Dbviewer
21
26
  @total_count = datatable_data[:total_count]
22
27
  @records = datatable_data[:records]
23
28
  @total_pages = datatable_data[:total_pages]
24
- @columns = datatable_data[:columns]
29
+ @columns = filter_accessible_columns(@table_name, datatable_data[:columns])
25
30
  @metadata = datatable_data[:metadata]
26
31
  end
27
32
 
28
33
  def query
29
- @columns = fetch_table_columns(@table_name)
34
+ all_columns = fetch_table_columns(@table_name)
35
+ @columns = filter_accessible_columns(@table_name, all_columns)
30
36
  @query = prepare_query(@table_name, params[:query])
37
+
38
+ if @query.present?
39
+ begin
40
+ validate_query_access(@query)
41
+ rescue SecurityError => e
42
+ flash[:alert] = e.message
43
+ render :query and return
44
+ end
45
+ end
46
+
31
47
  @records = execute_query(@query)
32
48
 
33
49
  render :query
@@ -64,6 +80,10 @@ module Dbviewer
64
80
  @table_name = params[:id]
65
81
  end
66
82
 
83
+ def validate_table
84
+ validate_table_access(@table_name)
85
+ end
86
+
67
87
  def set_query_filters
68
88
  @current_page = [ 1, params[:page].to_i ].max
69
89
  @per_page = params[:per_page] ? params[:per_page].to_i : Dbviewer.configuration.default_per_page
@@ -77,6 +77,29 @@ module Dbviewer
77
77
  # }
78
78
  attr_accessor :custom_pii_masks
79
79
 
80
+ # Table access control - whitelist approach (more secure)
81
+ # Only tables listed here will be accessible
82
+ # @example ['users', 'orders', 'products']
83
+ attr_accessor :allowed_tables
84
+
85
+ # Table access control - blacklist approach
86
+ # Tables listed here will be blocked from access
87
+ # @example ['admin_users', 'sensitive_data', 'audit_logs']
88
+ attr_accessor :blocked_tables
89
+
90
+ # Column access control - hide sensitive columns
91
+ # @example {
92
+ # 'users' => ['password_digest', 'api_key', 'secret_token'],
93
+ # 'orders' => ['internal_notes']
94
+ # }
95
+ attr_accessor :blocked_columns
96
+
97
+ # Access control mode: :whitelist, :blacklist, or :none
98
+ # :whitelist - only allowed_tables are accessible (most secure)
99
+ # :blacklist - all tables except blocked_tables are accessible
100
+ # :none - all tables accessible (current behavior)
101
+ attr_accessor :access_control_mode
102
+
80
103
  def initialize
81
104
  @per_page_options = [ 10, 20, 50, 100 ]
82
105
  @default_per_page = 20
@@ -103,6 +126,12 @@ module Dbviewer
103
126
  @pii_rules = {}
104
127
  @enable_pii_masking = true
105
128
  @custom_pii_masks = {}
129
+
130
+ # Initialize access control settings
131
+ @allowed_tables = []
132
+ @blocked_tables = []
133
+ @blocked_columns = {}
134
+ @access_control_mode = :none # Default to current behavior
106
135
  end
107
136
  end
108
137
  end
@@ -0,0 +1,88 @@
1
+ module Dbviewer
2
+ module Security
3
+ # Access control service to validate table and column access
4
+ class AccessControl
5
+ def initialize(config = nil)
6
+ @config = config || Dbviewer.configuration
7
+ @sql_parser = SqlParser.new
8
+ end
9
+
10
+ # Check if a table is accessible based on current access control mode
11
+ # @param table_name [String] Name of the table to check
12
+ # @return [Boolean] true if table is accessible, false otherwise
13
+ def table_accessible?(table_name)
14
+ return true if @config.access_control_mode == :none
15
+
16
+ case @config.access_control_mode
17
+ when :whitelist
18
+ @config.allowed_tables.include?(table_name.to_s)
19
+ when :blacklist
20
+ !@config.blocked_tables.include?(table_name.to_s)
21
+ else
22
+ true
23
+ end
24
+ end
25
+
26
+ # Get list of accessible tables based on access control settings
27
+ # @param all_tables [Array<String>] List of all available tables
28
+ # @return [Array<String>] Filtered list of accessible tables
29
+ def filter_accessible_tables(all_tables)
30
+ return all_tables if @config.access_control_mode == :none
31
+
32
+ all_tables.select { |table| table_accessible?(table) }
33
+ end
34
+
35
+ # Get list of accessible columns for a table
36
+ # @param table_name [String] Name of the table
37
+ # @param all_columns [Array<String>] List of all columns in the table
38
+ # @return [Array<String>] Filtered list of accessible columns
39
+ def filter_accessible_columns(table_name, all_columns)
40
+ blocked_columns = @config.blocked_columns[table_name.to_s] || []
41
+ all_columns.reject { |column| blocked_columns.include?(column.to_s) }
42
+ end
43
+
44
+ # Validate if a SQL query only accesses allowed tables
45
+ # @param sql [String] The SQL query to validate
46
+ # @return [Boolean] true if query only accesses allowed tables
47
+ def validate_query_table_access(sql)
48
+ return true if @config.access_control_mode == :none
49
+
50
+ # Extract table names from the SQL query using the SQL parser
51
+ extracted_tables = @sql_parser.extract_table_names(sql)
52
+
53
+ # Check if all extracted tables are accessible
54
+ extracted_tables.all? { |table| table_accessible?(table) }
55
+ end
56
+
57
+ # Get access control violation message
58
+ # @param table_name [String] Name of the table that was blocked
59
+ # @return [String] Error message explaining the access violation
60
+ def access_violation_message(table_name = nil)
61
+ case @config.access_control_mode
62
+ when :whitelist
63
+ if table_name
64
+ "Access denied: Table '#{table_name}' is not in the allowed tables list"
65
+ else
66
+ "Access denied: Only the following tables are accessible: #{@config.allowed_tables.join(', ')}"
67
+ end
68
+ when :blacklist
69
+ if table_name
70
+ "Access denied: Table '#{table_name}' is blocked from access"
71
+ else
72
+ "Access denied: The following tables are blocked: #{@config.blocked_tables.join(', ')}"
73
+ end
74
+ else
75
+ "Access denied: Table access is restricted"
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ # For backwards compatibility, we keep this method but delegate to SqlParser
82
+ # @deprecated Use SqlParser.extract_table_names directly
83
+ def extract_table_names_from_sql(sql)
84
+ @sql_parser.extract_table_names(sql)
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,465 @@
1
+ module Dbviewer
2
+ module Security
3
+ # SQL parser for extracting table names from SQL queries
4
+ # Handles complex SQL including CTEs, subqueries, joins, and DML operations
5
+ class SqlParser
6
+ require "set"
7
+
8
+ # Parse SQL query and extract all table names
9
+ # @param sql [String] The SQL query to parse
10
+ # @return [Array<String>] List of table names found in the query
11
+ def self.extract_table_names(sql)
12
+ new.extract_table_names(sql)
13
+ end
14
+
15
+ # Parse SQL query and extract all table names
16
+ # @param sql [String] The SQL query to parse
17
+ # @return [Array<String>] List of table names found in the query
18
+ def extract_table_names(sql)
19
+ return [] if sql.nil? || sql.strip.empty?
20
+
21
+ # Remove comments and normalize whitespace
22
+ cleaned_sql = clean_sql(sql)
23
+
24
+ # Use a more sophisticated approach to handle complex queries
25
+ table_names = Set.new
26
+
27
+ # Split by semicolons to handle multiple statements
28
+ statements = cleaned_sql.split(";").map(&:strip).reject(&:empty?)
29
+
30
+ statements.each do |statement|
31
+ table_names.merge(extract_tables_from_statement(statement))
32
+ end
33
+
34
+ table_names.to_a.compact
35
+ end
36
+
37
+ private
38
+
39
+ # Clean SQL by removing comments and normalizing whitespace
40
+ # Properly handles string literals to avoid removing content inside strings
41
+ # @param sql [String] The SQL query to clean
42
+ # @return [String] Cleaned SQL
43
+ def clean_sql(sql)
44
+ result = []
45
+ i = 0
46
+ in_single_quote = false
47
+ in_double_quote = false
48
+ in_single_line_comment = false
49
+ in_multi_line_comment = false
50
+
51
+ while i < sql.length
52
+ char = sql[i]
53
+ next_char = i + 1 < sql.length ? sql[i + 1] : nil
54
+
55
+ # Handle string literals first (highest priority)
56
+ if !in_single_line_comment && !in_multi_line_comment
57
+ case char
58
+ when "'"
59
+ if !in_double_quote
60
+ # Handle escaped single quotes
61
+ if in_single_quote && next_char == "'"
62
+ result << char << next_char
63
+ i += 2
64
+ next
65
+ else
66
+ in_single_quote = !in_single_quote
67
+ result << char
68
+ end
69
+ else
70
+ result << char
71
+ end
72
+ when '"'
73
+ if !in_single_quote
74
+ # Handle escaped double quotes
75
+ if in_double_quote && next_char == '"'
76
+ result << char << next_char
77
+ i += 2
78
+ next
79
+ else
80
+ in_double_quote = !in_double_quote
81
+ result << char
82
+ end
83
+ else
84
+ result << char
85
+ end
86
+ when "-"
87
+ # Check for single-line comment start
88
+ if !in_single_quote && !in_double_quote && next_char == "-"
89
+ in_single_line_comment = true
90
+ i += 2
91
+ next
92
+ else
93
+ result << char
94
+ end
95
+ when "/"
96
+ # Check for multi-line comment start
97
+ if !in_single_quote && !in_double_quote && next_char == "*"
98
+ in_multi_line_comment = true
99
+ i += 2
100
+ next
101
+ else
102
+ result << char
103
+ end
104
+ when "*"
105
+ # Check for multi-line comment end
106
+ if in_multi_line_comment && next_char == "/"
107
+ in_multi_line_comment = false
108
+ i += 2
109
+ next
110
+ elsif !in_single_line_comment && !in_multi_line_comment
111
+ result << char
112
+ end
113
+ when "\n", "\r"
114
+ # End single-line comment
115
+ if in_single_line_comment
116
+ in_single_line_comment = false
117
+ result << char
118
+ elsif !in_multi_line_comment
119
+ result << char
120
+ end
121
+ else
122
+ # Regular character
123
+ if !in_single_line_comment && !in_multi_line_comment
124
+ result << char
125
+ end
126
+ end
127
+ else
128
+ # Inside comment - handle comment end conditions
129
+ case char
130
+ when "*"
131
+ if in_multi_line_comment && next_char == "/"
132
+ in_multi_line_comment = false
133
+ i += 2
134
+ next
135
+ end
136
+ when "\n", "\r"
137
+ if in_single_line_comment
138
+ in_single_line_comment = false
139
+ result << char
140
+ end
141
+ end
142
+ end
143
+
144
+ i += 1
145
+ end
146
+
147
+ # Normalize whitespace and strip
148
+ result.join.squeeze(" ").strip
149
+ end
150
+
151
+ # Extract table names from a single SQL statement
152
+ # @param sql [String] A single SQL statement
153
+ # @param cte_names [Set] Set of CTE names to exclude (for recursive calls)
154
+ # @return [Array<String>] List of table names
155
+ def extract_tables_from_statement(sql, cte_names = Set.new)
156
+ table_names = Set.new
157
+
158
+ # Handle CTEs (WITH clauses) first and collect CTE names
159
+ cte_data = extract_cte_tables_and_names(sql)
160
+ table_names.merge(cte_data[:table_names])
161
+ current_cte_names = cte_names + cte_data[:cte_names]
162
+
163
+ # Handle main query tables
164
+ table_names.merge(extract_main_query_tables(sql, current_cte_names))
165
+
166
+ # Handle subqueries recursively
167
+ table_names.merge(extract_subquery_tables(sql, current_cte_names))
168
+
169
+ # Remove CTE names from the final result since they're not actual database tables
170
+ (table_names - current_cte_names).to_a
171
+ end
172
+
173
+ # Extract table names from CTEs (WITH clauses) and return both actual tables and CTE names
174
+ # @param sql [String] SQL statement
175
+ # @return [Hash] Hash with :table_names and :cte_names arrays
176
+ def extract_cte_tables_and_names(sql)
177
+ table_names = Set.new
178
+ cte_names = Set.new
179
+
180
+ # Find all CTE names first using an improved approach
181
+ # Look for pattern: [schema.]cte_name AS (
182
+ # Handles quoted identifiers, schema-qualified names, and backticks
183
+ # Pattern breakdown:
184
+ # (?:(?:\w+|"[^"]+"|`[^`]+`)\.)? - Optional schema prefix (word, quoted, or backticked) followed by dot
185
+ # (\w+|"[^"]+"|`[^`]+`) - CTE name (word, double-quoted, or backticked)
186
+ # \s+AS\s*\( - " AS ("
187
+ cte_name_pattern = /(?:(?:\w+|"[^"]+"|`[^`]+`)\.)?(\w+|"[^"]+"|`[^`]+`)\s+AS\s*\(/i
188
+
189
+ sql.scan(cte_name_pattern) do |match|
190
+ cte_name = match[0]
191
+ # Clean up the CTE name by removing quotes if present
192
+ clean_cte_name = cte_name.gsub(/^["'`](.+)["'`]$/, '\1')
193
+ cte_names.add(clean_cte_name)
194
+ end
195
+
196
+ # Then extract table names from the CTE definitions
197
+ # Match WITH clauses (including RECURSIVE)
198
+ cte_pattern = /\bWITH\s+(?:RECURSIVE\s+)?(.+?)(?=\s+SELECT\s+[^(]|\s+INSERT\s+|\s+UPDATE\s+|\s+DELETE\s+)/mi
199
+
200
+ cte_match = sql.match(cte_pattern)
201
+ if cte_match
202
+ cte_definitions = cte_match[1]
203
+
204
+ # Find all subqueries within the CTE definitions and extract tables from them
205
+ depth = 0
206
+ start_pos = nil
207
+ i = 0
208
+
209
+ while i < cte_definitions.length
210
+ char = cte_definitions[i]
211
+
212
+ if char == "(" && cte_definitions[i..-1] =~ /^\(\s*SELECT/i
213
+ if depth == 0
214
+ start_pos = i + 1
215
+ end
216
+ depth += 1
217
+ elsif char == ")" && depth > 0
218
+ depth -= 1
219
+ if depth == 0 && start_pos
220
+ subquery = cte_definitions[start_pos...i]
221
+ # Recursively extract tables from the CTE subquery
222
+ table_names.merge(extract_tables_from_statement(subquery, cte_names))
223
+ start_pos = nil
224
+ end
225
+ end
226
+
227
+ i += 1
228
+ end
229
+ end
230
+
231
+ {
232
+ table_names: table_names.to_a,
233
+ cte_names: cte_names.to_a
234
+ }
235
+ end
236
+
237
+ # Extract table names from main query operations
238
+ # @param sql [String] SQL statement
239
+ # @param cte_names [Set] Set of CTE names to exclude
240
+ # @return [Array<String>] List of table names
241
+ def extract_main_query_tables(sql, cte_names = Set.new)
242
+ table_names = Set.new
243
+
244
+ # FROM clauses (including table-valued functions and subqueries)
245
+ table_names.merge(extract_from_tables(sql, cte_names))
246
+
247
+ # JOIN clauses
248
+ table_names.merge(extract_join_tables(sql, cte_names))
249
+
250
+ # INSERT INTO
251
+ table_names.merge(extract_insert_tables(sql, cte_names))
252
+
253
+ # UPDATE (including UPDATE ... FROM)
254
+ table_names.merge(extract_update_tables(sql, cte_names))
255
+
256
+ # DELETE FROM (including DELETE ... FROM ... JOIN)
257
+ table_names.merge(extract_delete_tables(sql, cte_names))
258
+
259
+ # UNION, INTERSECT, EXCEPT
260
+ table_names.merge(extract_set_operation_tables(sql, cte_names))
261
+
262
+ table_names.to_a
263
+ end
264
+
265
+ # Extract table names from FROM clauses
266
+ # @param sql [String] SQL statement
267
+ # @param cte_names [Set] Set of CTE names to exclude
268
+ # @return [Array<String>] List of table names
269
+ def extract_from_tables(sql, cte_names = Set.new)
270
+ table_names = Set.new
271
+
272
+ # Match FROM clause with various patterns
273
+ # Handle: FROM table, FROM schema.table, FROM "table", FROM (subquery), FROM function()
274
+ from_pattern = /\bFROM\s+(?![\(\s]*SELECT)((?:\w+\.)?(?:\\?"[^"\\]+\\?"|`[^`]+`|\w+)(?:\s*\([^)]*\))?)/i
275
+
276
+ sql.scan(from_pattern) do |match|
277
+ table_ref = match[0]
278
+ # Skip if it looks like a function call or subquery
279
+ unless table_ref.include?("(") && table_ref.include?(")")
280
+ table_name = extract_table_name_from_reference(table_ref)
281
+ # Only add if it's not a CTE name
282
+ table_names.add(table_name) if table_name && !cte_names.include?(table_name)
283
+ end
284
+ end
285
+
286
+ table_names.to_a
287
+ end
288
+
289
+ # Extract table names from JOIN clauses
290
+ # @param sql [String] SQL statement
291
+ # @param cte_names [Set] Set of CTE names to exclude
292
+ # @return [Array<String>] List of table names
293
+ def extract_join_tables(sql, cte_names = Set.new)
294
+ table_names = Set.new
295
+
296
+ # Match all types of JOINs
297
+ join_pattern = /\b(?:INNER\s+|LEFT\s+(?:OUTER\s+)?|RIGHT\s+(?:OUTER\s+)?|FULL\s+(?:OUTER\s+)?|CROSS\s+)?JOIN\s+(?![\(\s]*SELECT)((?:\w+\.)?(?:\\?"[^"\\]+\\?"|`[^`]+`|\w+))/i
298
+
299
+ sql.scan(join_pattern) do |match|
300
+ table_ref = match[0]
301
+ table_name = extract_table_name_from_reference(table_ref)
302
+ # Only add if it's not a CTE name
303
+ table_names.add(table_name) if table_name && !cte_names.include?(table_name)
304
+ end
305
+
306
+ table_names.to_a
307
+ end
308
+
309
+ # Extract table names from INSERT statements
310
+ # @param sql [String] SQL statement
311
+ # @param cte_names [Set] Set of CTE names to exclude
312
+ # @return [Array<String>] List of table names
313
+ def extract_insert_tables(sql, cte_names = Set.new)
314
+ table_names = Set.new
315
+
316
+ # INSERT INTO table
317
+ insert_pattern = /\bINSERT\s+INTO\s+((?:\w+\.)?(?:\\?"[^"\\]+\\?"|`[^`]+`|\w+))/i
318
+
319
+ sql.scan(insert_pattern) do |match|
320
+ table_ref = match[0]
321
+ table_name = extract_table_name_from_reference(table_ref)
322
+ # Only add if it's not a CTE name
323
+ table_names.add(table_name) if table_name && !cte_names.include?(table_name)
324
+ end
325
+
326
+ table_names.to_a
327
+ end
328
+
329
+ # Extract table names from UPDATE statements
330
+ # @param sql [String] SQL statement
331
+ # @param cte_names [Set] Set of CTE names to exclude
332
+ # @return [Array<String>] List of table names
333
+ def extract_update_tables(sql, cte_names = Set.new)
334
+ table_names = Set.new
335
+
336
+ # UPDATE table SET ... or UPDATE table ... FROM other_table
337
+ update_pattern = /\bUPDATE\s+((?:\w+\.)?(?:\\?"[^"\\]+\\?"|`[^`]+`|\w+))/i
338
+
339
+ sql.scan(update_pattern) do |match|
340
+ table_ref = match[0]
341
+ table_name = extract_table_name_from_reference(table_ref)
342
+ # Only add if it's not a CTE name
343
+ table_names.add(table_name) if table_name && !cte_names.include?(table_name)
344
+ end
345
+
346
+ # UPDATE ... FROM (PostgreSQL style)
347
+ update_from_pattern = /\bUPDATE\s+\w+.*?\bFROM\s+((?:\w+\.)?(?:\\?"[^"\\]+\\?"|`[^`]+`|\w+))/i
348
+
349
+ sql.scan(update_from_pattern) do |match|
350
+ table_ref = match[0]
351
+ table_name = extract_table_name_from_reference(table_ref)
352
+ # Only add if it's not a CTE name
353
+ table_names.add(table_name) if table_name && !cte_names.include?(table_name)
354
+ end
355
+
356
+ table_names.to_a
357
+ end
358
+
359
+ # Extract table names from DELETE statements
360
+ # @param sql [String] SQL statement
361
+ # @param cte_names [Set] Set of CTE names to exclude
362
+ # @return [Array<String>] List of table names
363
+ def extract_delete_tables(sql, cte_names = Set.new)
364
+ table_names = Set.new
365
+
366
+ # DELETE FROM table
367
+ delete_pattern = /\bDELETE\s+FROM\s+((?:\w+\.)?(?:\\?"[^"\\]+\\?"|`[^`]+`|\w+))/i
368
+
369
+ sql.scan(delete_pattern) do |match|
370
+ table_ref = match[0]
371
+ table_name = extract_table_name_from_reference(table_ref)
372
+ # Only add if it's not a CTE name
373
+ table_names.add(table_name) if table_name && !cte_names.include?(table_name)
374
+ end
375
+
376
+ table_names.to_a
377
+ end
378
+
379
+ # Extract table names from set operations (UNION, INTERSECT, EXCEPT)
380
+ # @param sql [String] SQL statement
381
+ # @param cte_names [Set] Set of CTE names to exclude
382
+ # @return [Array<String>] List of table names
383
+ def extract_set_operation_tables(sql, cte_names = Set.new)
384
+ table_names = Set.new
385
+
386
+ # Split by set operations and process each part
387
+ parts = sql.split(/\b(?:UNION(?:\s+ALL)?|INTERSECT|EXCEPT)\b/i)
388
+
389
+ parts.each do |part|
390
+ table_names.merge(extract_from_tables(part, cte_names))
391
+ table_names.merge(extract_join_tables(part, cte_names))
392
+ end
393
+
394
+ table_names.to_a
395
+ end
396
+
397
+ # Extract table names from subqueries
398
+ # @param sql [String] SQL statement
399
+ # @param cte_names [Set] Set of CTE names to exclude
400
+ # @return [Array<String>] List of table names
401
+ def extract_subquery_tables(sql, cte_names = Set.new)
402
+ table_names = Set.new
403
+
404
+ # Find subqueries in parentheses
405
+ # This is a simplified approach - nested parentheses are complex to handle with regex
406
+
407
+ # Track parentheses depth to handle nested subqueries
408
+ depth = 0
409
+ start_pos = nil
410
+ i = 0
411
+
412
+ while i < sql.length
413
+ char = sql[i]
414
+
415
+ if char == "(" && sql[i..-1] =~ /^\(\s*SELECT/i
416
+ if depth == 0
417
+ start_pos = i + 1
418
+ end
419
+ depth += 1
420
+ elsif char == ")" && depth > 0
421
+ depth -= 1
422
+ if depth == 0 && start_pos
423
+ subquery = sql[start_pos...i]
424
+ # Recursively process subquery
425
+ table_names.merge(extract_tables_from_statement(subquery, cte_names))
426
+ start_pos = nil
427
+ end
428
+ end
429
+
430
+ i += 1
431
+ end
432
+
433
+ table_names.to_a
434
+ end
435
+
436
+ # Extract clean table name from a table reference
437
+ # @param table_ref [String] Table reference (e.g., "schema.table", "table", "\"table\"")
438
+ # @return [String, nil] Clean table name or nil if invalid
439
+ def extract_table_name_from_reference(table_ref)
440
+ return nil if table_ref.nil? || table_ref.strip.empty?
441
+
442
+ # Remove schema prefix (schema.table -> table)
443
+ if table_ref.include?(".")
444
+ parts = table_ref.split(".")
445
+ table_part = parts.last
446
+ else
447
+ table_part = table_ref
448
+ end
449
+
450
+ # Remove quotes and clean up
451
+ table_name = table_part.gsub(/^\\?"([^"\\]+)\\?"$/, '\1') # Remove escaped quotes
452
+ .gsub(/^"([^"]+)"$/, '\1') # Remove double quotes
453
+ .gsub(/^`([^`]+)`$/, '\1') # Remove backticks
454
+ .strip
455
+
456
+ # Validate table name (basic identifier rules)
457
+ if table_name =~ /^[a-zA-Z_][a-zA-Z0-9_]*$/
458
+ table_name
459
+ else
460
+ nil
461
+ end
462
+ end
463
+ end
464
+ end
465
+ end
@@ -1,3 +1,3 @@
1
1
  module Dbviewer
2
- VERSION = "0.8.1"
2
+ VERSION = "0.9.0"
3
3
  end
data/lib/dbviewer.rb CHANGED
@@ -2,6 +2,8 @@ require "dbviewer/version"
2
2
  require "dbviewer/configuration"
3
3
  require "dbviewer/engine"
4
4
  require "dbviewer/validator/sql"
5
+ require "dbviewer/security/sql_parser"
6
+ require "dbviewer/security/access_control"
5
7
 
6
8
  require "dbviewer/storage/base"
7
9
  require "dbviewer/storage/in_memory_storage"
@@ -19,6 +19,31 @@ Dbviewer.configure do |config|
19
19
  # password: "your_secure_password"
20
20
  # }
21
21
 
22
+ # Table and Column Access Control (Security Feature)
23
+ # Access control mode: :whitelist (most secure), :blacklist, or :none (default)
24
+ # config.access_control_mode = :whitelist
25
+
26
+ # Whitelist mode: Only these tables will be accessible
27
+ # config.allowed_tables = [
28
+ # 'users',
29
+ # 'orders',
30
+ # 'products',
31
+ # 'categories'
32
+ # ]
33
+
34
+ # Blacklist mode: All tables except these will be accessible
35
+ # config.blocked_tables = [
36
+ # 'admin_users',
37
+ # 'sensitive_data',
38
+ # 'audit_logs'
39
+ # ]
40
+
41
+ # Hide sensitive columns from specific tables
42
+ # config.blocked_columns = {
43
+ # 'users' => ['password_digest', 'api_key', 'secret_token'],
44
+ # 'orders' => ['internal_notes', 'admin_comments']
45
+ # }
46
+
22
47
  # Multiple database connections configuration
23
48
  # config.database_connections = {
24
49
  # primary: {
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dbviewer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.1
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Wailan Tirajoh
@@ -95,6 +95,7 @@ files:
95
95
  - app/assets/stylesheets/dbviewer/logs.css
96
96
  - app/assets/stylesheets/dbviewer/query.css
97
97
  - app/assets/stylesheets/dbviewer/table.css
98
+ - app/controllers/concerns/dbviewer/access_control_validation.rb
98
99
  - app/controllers/concerns/dbviewer/database_operations.rb
99
100
  - app/controllers/concerns/dbviewer/database_operations/connection_management.rb
100
101
  - app/controllers/concerns/dbviewer/database_operations/data_export.rb
@@ -156,6 +157,8 @@ files:
156
157
  - lib/dbviewer/query/logger.rb
157
158
  - lib/dbviewer/query/notification_subscriber.rb
158
159
  - lib/dbviewer/query/parser.rb
160
+ - lib/dbviewer/security/access_control.rb
161
+ - lib/dbviewer/security/sql_parser.rb
159
162
  - lib/dbviewer/storage/base.rb
160
163
  - lib/dbviewer/storage/file_storage.rb
161
164
  - lib/dbviewer/storage/in_memory_storage.rb