query_console 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5f57530c0dcf15456265758600392d0eeff17ba08cfff9f879d0afa31a1ab7e3
4
+ data.tar.gz: 12952c2bac1d22594a51e06d7c44e892b8e9955381091c22eb696ce08f7a8668
5
+ SHA512:
6
+ metadata.gz: c2c2c54e0c044763180321438121c11b66a5cabd8ebc6a1afe53eea8f58b22675424647b3c5b2bde568aeac62e4de5bfa2dcc2f1f32606f5a1a50eeb3960519f
7
+ data.tar.gz: 28d136f7b2c95ae7aeb551c07bf38bc8a861ff434885a75cbf805773935d4b3df8df57f2a165f9aca7e5ee23169822e635cd23b884d998626a1a7d654c012b57
data/MIT-LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Johnson Gnanasekar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,382 @@
1
+ # QueryConsole
2
+
3
+ A Rails engine that provides a secure, mountable web interface for running read-only SQL queries against your application's database.
4
+
5
+ ## Features
6
+
7
+ - 🔒 **Security First**: Read-only queries enforced at multiple levels
8
+ - 🚦 **Environment Gating**: Disabled by default in production
9
+ - 🔑 **Flexible Authorization**: Integrate with your existing auth system
10
+ - 📊 **Modern UI**: Clean, responsive interface with query history
11
+ - 📝 **Audit Logging**: All queries logged with actor information
12
+ - ⚡ **Resource Protection**: Configurable row limits and query timeouts
13
+ - 💾 **Client-Side History**: Query history stored in browser localStorage
14
+ - ⚡ **Hotwire-Powered**: Uses Turbo Frames and Stimulus for smooth, SPA-like experience
15
+ - 🎨 **Zero Build Step**: CDN-hosted Hotwire, no asset compilation needed
16
+
17
+ ## Security Features
18
+
19
+ QueryConsole implements multiple layers of security:
20
+
21
+ 1. **Environment Gating**: Only enabled in configured environments (development by default)
22
+ 2. **Authorization Hook**: Requires explicit authorization configuration
23
+ 3. **SQL Validation**: Only SELECT and WITH (CTE) queries allowed
24
+ 4. **Keyword Blocking**: Blocks all write operations (UPDATE, DELETE, INSERT, DROP, etc.)
25
+ 5. **Statement Isolation**: Prevents multiple statement execution
26
+ 6. **Row Limiting**: Automatic result limiting to prevent resource exhaustion
27
+ 7. **Query Timeout**: Configurable timeout to prevent long-running queries
28
+ 8. **Audit Trail**: All queries logged with structured data
29
+
30
+ ## Installation
31
+
32
+ Add this line to your application's Gemfile:
33
+
34
+ ```ruby
35
+ gem 'query_console'
36
+ ```
37
+
38
+ And then execute:
39
+
40
+ ```bash
41
+ bundle install
42
+ rails generate query_console:install
43
+ ```
44
+
45
+ **Requirements:**
46
+ - Ruby 3.1+
47
+ - Rails 7.0+
48
+ - Works with Rails 8+
49
+ - Hotwire (Turbo Rails + Stimulus) - automatically included
50
+
51
+ ## Configuration
52
+
53
+ The generator creates `config/initializers/query_console.rb`. You **MUST** configure the authorization hook:
54
+
55
+ ```ruby
56
+ QueryConsole.configure do |config|
57
+ # Required: Set up authorization
58
+ config.authorize = ->(controller) {
59
+ controller.current_user&.admin?
60
+ }
61
+
62
+ # Track who runs queries
63
+ config.current_actor = ->(controller) {
64
+ controller.current_user&.email || "anonymous"
65
+ }
66
+
67
+ # Optional: Enable in additional environments (use with caution!)
68
+ # config.enabled_environments = %w[development staging]
69
+
70
+ # Optional: Adjust limits
71
+ # config.max_rows = 1000
72
+ # config.timeout_ms = 5000
73
+ end
74
+ ```
75
+
76
+ ### Authorization Examples
77
+
78
+ #### With Devise
79
+
80
+ ```ruby
81
+ config.authorize = ->(controller) {
82
+ controller.current_user&.admin?
83
+ }
84
+ ```
85
+
86
+ #### With HTTP Basic Auth
87
+
88
+ ```ruby
89
+ config.authorize = ->(controller) {
90
+ controller.authenticate_or_request_with_http_basic do |username, password|
91
+ username == "admin" && password == Rails.application.credentials.query_console_password
92
+ end
93
+ }
94
+ ```
95
+
96
+ #### For Development (NOT for production!)
97
+
98
+ ```ruby
99
+ config.authorize = ->(_controller) { true }
100
+ ```
101
+
102
+ ### Configuration Options
103
+
104
+ | Option | Default | Description |
105
+ |--------|---------|-------------|
106
+ | `enabled_environments` | `["development"]` | Environments where console is accessible |
107
+ | `authorize` | `nil` | Lambda/proc that receives controller and returns true/false |
108
+ | `current_actor` | `->(_) { "unknown" }` | Lambda/proc to identify who's running queries |
109
+ | `max_rows` | `500` | Maximum rows returned per query |
110
+ | `timeout_ms` | `3000` | Query timeout in milliseconds |
111
+ | `forbidden_keywords` | See code | SQL keywords that are blocked |
112
+ | `allowed_starts_with` | `["select", "with"]` | Allowed query starting keywords |
113
+
114
+ ## Mounting
115
+
116
+ Add to your `config/routes.rb`:
117
+
118
+ ```ruby
119
+ Rails.application.routes.draw do
120
+ mount QueryConsole::Engine, at: "/query_console"
121
+ end
122
+ ```
123
+
124
+ Then visit: `http://localhost:3000/query_console`
125
+
126
+ ## Usage
127
+
128
+ ### Running Queries
129
+
130
+ 1. Enter your SELECT query in the editor
131
+ 2. Click "Run Query" or press Ctrl/Cmd+Enter
132
+ 3. View results in the table below
133
+ 4. Query is automatically saved to history
134
+
135
+ ### Query History
136
+
137
+ - Stored locally in your browser (not on server)
138
+ - Click any history item to load it into the editor
139
+ - Stores up to 20 recent queries
140
+ - Clear history with the "Clear" button
141
+
142
+ ### Allowed Queries
143
+
144
+ ✅ **Allowed**:
145
+ - `SELECT * FROM users`
146
+ - `SELECT id, name FROM users WHERE active = true`
147
+ - `WITH active_users AS (SELECT * FROM users WHERE active = true) SELECT * FROM active_users`
148
+ - Queries with JOINs, ORDER BY, GROUP BY, etc.
149
+
150
+ ❌ **Blocked**:
151
+ - `UPDATE users SET name = 'test'`
152
+ - `DELETE FROM users`
153
+ - `INSERT INTO users VALUES (...)`
154
+ - `DROP TABLE users`
155
+ - `SELECT * FROM users; DELETE FROM users` (multiple statements)
156
+ - Any query containing forbidden keywords
157
+
158
+ ## Example Queries
159
+
160
+ ```sql
161
+ -- List recent users
162
+ SELECT id, email, created_at
163
+ FROM users
164
+ ORDER BY created_at DESC
165
+ LIMIT 10;
166
+
167
+ -- Count by status
168
+ SELECT status, COUNT(*) as count
169
+ FROM orders
170
+ GROUP BY status;
171
+
172
+ -- Join with aggregation
173
+ SELECT u.email, COUNT(o.id) as order_count
174
+ FROM users u
175
+ LEFT JOIN orders o ON u.id = o.user_id
176
+ GROUP BY u.id, u.email
177
+ ORDER BY order_count DESC
178
+ LIMIT 20;
179
+
180
+ -- Common Table Expression (CTE)
181
+ WITH active_users AS (
182
+ SELECT * FROM users WHERE active = true
183
+ )
184
+ SELECT * FROM active_users WHERE created_at > DATE('now', '-30 days');
185
+ ```
186
+
187
+ ## Security Considerations
188
+
189
+ ### Environment Configuration
190
+
191
+ ⚠️ **Important**: QueryConsole is **disabled by default in production**. To enable in non-development environments:
192
+
193
+ ```ruby
194
+ config.enabled_environments = %w[development staging]
195
+ ```
196
+
197
+ **Never enable in production without**:
198
+ 1. Strong authentication
199
+ 2. Network restrictions (VPN, IP whitelist)
200
+ 3. Audit monitoring
201
+ 4. Database read-only user (recommended)
202
+
203
+ ### Authorization
204
+
205
+ The authorization hook is called on **every request**. Ensure it:
206
+ - Returns `false` or `nil` to deny access
207
+ - Is performant (avoid N+1 queries)
208
+ - Handles edge cases (logged out users, etc.)
209
+
210
+ ### Audit Logs
211
+
212
+ All queries are logged to `Rails.logger.info` with:
213
+
214
+ ```ruby
215
+ {
216
+ component: "query_console",
217
+ actor: "user@example.com",
218
+ sql: "SELECT * FROM users LIMIT 10",
219
+ duration_ms: 45.2,
220
+ rows: 10,
221
+ status: "ok", # or "error"
222
+ truncated: false
223
+ }
224
+ ```
225
+
226
+ Monitor these logs for:
227
+ - Unusual query patterns
228
+ - Unauthorized access attempts
229
+ - Performance issues
230
+ - Data access patterns
231
+
232
+ ### Database Permissions
233
+
234
+ For production environments, consider using a dedicated read-only database user:
235
+
236
+ ```ruby
237
+ # config/database.yml
238
+ production_readonly:
239
+ <<: *production
240
+ username: readonly_user
241
+ password: <%= ENV['READONLY_DB_PASSWORD'] %>
242
+
243
+ # In your initializer
244
+ QueryConsole.configure do |config|
245
+ config.database_config = :production_readonly
246
+ end
247
+ ```
248
+
249
+ ## Development
250
+
251
+ ### Testing Without a Rails App
252
+
253
+ You can test the gem in isolation without needing a full Rails application:
254
+
255
+ **Option 1: Run automated tests**
256
+ ```bash
257
+ cd query_console
258
+ bundle install
259
+ bundle exec rspec
260
+ ```
261
+
262
+ **Option 2: Start the test server**
263
+ ```bash
264
+ cd query_console
265
+ bundle install
266
+ ./bin/test_server
267
+ ```
268
+
269
+ Then visit: http://localhost:9292/query_console
270
+
271
+ The test server includes sample data and is pre-configured for easy testing.
272
+
273
+ **See [TESTING.md](TESTING.md) for detailed testing instructions.**
274
+
275
+ ### Test Coverage
276
+
277
+ The test suite includes:
278
+ - SQL validator specs (security rules)
279
+ - SQL limiter specs (result limiting)
280
+ - Runner specs (integration tests)
281
+ - Controller specs (authorization & routing)
282
+
283
+ ## Frontend Technology Stack
284
+
285
+ QueryConsole uses **Hotwire (Turbo + Stimulus)**, the modern Rails-native frontend framework:
286
+
287
+ ### What's Included
288
+
289
+ - **Turbo Frames**: Query results update without page reloads (SPA-like experience)
290
+ - **Stimulus Controllers**: Organized JavaScript for collapsible sections, history, and editor
291
+ - **CDN Delivery**: Hotwire loaded from CDN (no asset compilation needed)
292
+ - **Zero Build Step**: No webpack, esbuild, or other bundlers required
293
+
294
+ ### Architecture
295
+
296
+ ```
297
+ Frontend Stack
298
+ ├── HTML: ERB Templates
299
+ ├── CSS: Vanilla CSS (inline)
300
+ ├── JavaScript:
301
+ │ ├── Turbo Frames (results updates)
302
+ │ └── Stimulus Controllers
303
+ │ ├── collapsible_controller (section toggling)
304
+ │ ├── history_controller (localStorage management)
305
+ │ └── editor_controller (query execution)
306
+ └── Storage: localStorage API
307
+ ```
308
+
309
+ ### Benefits
310
+
311
+ ✅ **No Build Step**: Works out of the box, no compilation needed
312
+ ✅ **Rails-Native**: Standard Rails 7+ approach
313
+ ✅ **Lightweight**: ~50KB total (vs React's 200KB+)
314
+ ✅ **Fast**: No page reloads, instant interactions
315
+ ✅ **Progressive**: Degrades gracefully without JavaScript
316
+
317
+ ### Why Hotwire?
318
+
319
+ 1. **Rails Standard**: Default frontend stack for Rails 7+
320
+ 2. **Simple**: Fewer moving parts than SPA frameworks
321
+ 3. **Productive**: Write less JavaScript, more HTML
322
+ 4. **Modern**: All the benefits of SPAs without the complexity
323
+ 5. **Maintainable**: Standard Rails patterns throughout
324
+
325
+ ## Troubleshooting
326
+
327
+ ### Console returns 404
328
+
329
+ **Possible causes**:
330
+ 1. Environment not in `enabled_environments`
331
+ 2. Authorization hook returns `false`
332
+ 3. Authorization hook not configured (defaults to deny)
333
+
334
+ **Solution**: Check your initializer configuration.
335
+
336
+ ### Query times out
337
+
338
+ **Causes**:
339
+ - Query is too complex
340
+ - Database is slow
341
+ - Timeout setting too aggressive
342
+
343
+ **Solutions**:
344
+ - Increase `timeout_ms`
345
+ - Optimize query
346
+ - Add indexes to database
347
+
348
+ ### "Multiple statements" error
349
+
350
+ **Cause**: Query contains semicolon (`;`) in the middle
351
+
352
+ **Solution**: Remove extra semicolons. Only one trailing semicolon is allowed.
353
+
354
+ ## Contributing
355
+
356
+ 1. Fork the repository
357
+ 2. Create your feature branch (`git checkout -b feature/my-feature`)
358
+ 3. Write tests for your changes
359
+ 4. Ensure tests pass (`bundle exec rspec`)
360
+ 5. Commit your changes (`git commit -am 'Add feature'`)
361
+ 6. Push to the branch (`git push origin feature/my-feature`)
362
+ 7. Create a Pull Request
363
+
364
+ ## License
365
+
366
+ The gem is available as open source under the terms of the [MIT License](MIT-LICENSE).
367
+
368
+ ## Credits
369
+
370
+ Created by [Johnson Gnanasekar](https://github.com/JohnsonGnanasekar)
371
+
372
+ ## Changelog
373
+
374
+ ### 0.1.0 (Initial Release)
375
+
376
+ - Basic query console with read-only enforcement
377
+ - Environment gating and authorization hooks
378
+ - SQL validation and row limiting
379
+ - Query timeout protection
380
+ - Client-side history with localStorage
381
+ - Comprehensive test suite
382
+ - Audit logging
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ require "bundler/setup"
2
+ require "bundler/gem_tasks"
3
+
4
+ APP_RAKEFILE = File.expand_path("spec/dummy/Rakefile", __dir__)
5
+ load "rails/tasks/engine.rake"
6
+
7
+ require "rspec/core/rake_task"
8
+
9
+ RSpec::Core::RakeTask.new(:spec)
10
+
11
+ task default: :spec
@@ -0,0 +1,38 @@
1
+ module QueryConsole
2
+ class ApplicationController < ActionController::Base
3
+ # Rails 8 CSRF protection - use null_session for Turbo Frame requests
4
+ protect_from_forgery with: :exception, prepend: true
5
+
6
+ # Skip CSRF for Turbo Frame requests (Turbo handles it via meta tags)
7
+ skip_forgery_protection if: -> { request.headers['Turbo-Frame'].present? }
8
+
9
+ before_action :ensure_enabled!
10
+ before_action :authorize_access!
11
+
12
+ private
13
+
14
+ def ensure_enabled!
15
+ config = QueryConsole.configuration
16
+
17
+ unless config.enabled_environments.map(&:to_s).include?(Rails.env.to_s)
18
+ raise ActionController::RoutingError, "Not Found"
19
+ end
20
+ end
21
+
22
+ def authorize_access!
23
+ config = QueryConsole.configuration
24
+
25
+ # Default deny if no authorize hook is configured
26
+ if config.authorize.nil?
27
+ Rails.logger.warn("[QueryConsole] Access denied: No authorization hook configured")
28
+ raise ActionController::RoutingError, "Not Found"
29
+ end
30
+
31
+ # Call the authorization hook
32
+ unless config.authorize.call(self)
33
+ Rails.logger.warn("[QueryConsole] Access denied by authorization hook")
34
+ raise ActionController::RoutingError, "Not Found"
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,43 @@
1
+ module QueryConsole
2
+ class QueriesController < ApplicationController
3
+ def new
4
+ # Render the main query editor page
5
+ end
6
+
7
+ def run
8
+ sql = params[:sql]
9
+
10
+ if sql.blank?
11
+ @result = Runner::QueryResult.new(error: "Query cannot be empty")
12
+ respond_to do |format|
13
+ format.turbo_stream { render turbo_stream: turbo_stream.replace("query-results", partial: "results", locals: { result: @result }) }
14
+ format.html { render :_results, layout: false }
15
+ end
16
+ return
17
+ end
18
+
19
+ # Execute the query
20
+ runner = Runner.new(sql)
21
+ @result = runner.execute
22
+
23
+ # Log the query execution
24
+ AuditLogger.log_query(
25
+ sql: sql,
26
+ result: @result,
27
+ controller: self
28
+ )
29
+
30
+ # Respond with Turbo Stream or HTML
31
+ respond_to do |format|
32
+ format.turbo_stream do
33
+ render turbo_stream: turbo_stream.replace(
34
+ "query-results",
35
+ partial: "results",
36
+ locals: { result: @result }
37
+ )
38
+ end
39
+ format.html { render :_results, layout: false }
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,20 @@
1
+ // Entry point for the QueryConsole Stimulus application
2
+ import { Application } from "@hotwired/stimulus"
3
+ import { registerControllers } from "@hotwired/stimulus-loading"
4
+
5
+ const application = Application.start()
6
+
7
+ // Configure Stimulus development experience
8
+ application.debug = false
9
+ window.Stimulus = application
10
+
11
+ // Register all controllers in the controllers/query_console directory
12
+ import CollapsibleController from "./controllers/collapsible_controller"
13
+ import HistoryController from "./controllers/history_controller"
14
+ import EditorController from "./controllers/editor_controller"
15
+
16
+ application.register("collapsible", CollapsibleController)
17
+ application.register("history", HistoryController)
18
+ application.register("editor", EditorController)
19
+
20
+ export { application }
@@ -0,0 +1,42 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Handles collapsible sections (banner, editor, history)
4
+ // Usage: <div data-controller="collapsible" data-collapsible-key-value="banner">
5
+ export default class extends Controller {
6
+ static values = {
7
+ key: String // Storage key suffix (e.g., "banner", "editor", "history")
8
+ }
9
+
10
+ connect() {
11
+ this.storageKey = `query_console.${this.keyValue}_collapsed`
12
+ this.loadState()
13
+ }
14
+
15
+ toggle(event) {
16
+ event.preventDefault()
17
+ this.element.classList.toggle('collapsed')
18
+
19
+ const isCollapsed = this.element.classList.contains('collapsed')
20
+ this.saveState(isCollapsed)
21
+ this.updateToggleButton(event.target, isCollapsed)
22
+ }
23
+
24
+ loadState() {
25
+ const isCollapsed = localStorage.getItem(this.storageKey) === 'true'
26
+ if (isCollapsed) {
27
+ this.element.classList.add('collapsed')
28
+ const button = this.element.querySelector('.section-toggle, .banner-toggle')
29
+ if (button) {
30
+ this.updateToggleButton(button, true)
31
+ }
32
+ }
33
+ }
34
+
35
+ saveState(isCollapsed) {
36
+ localStorage.setItem(this.storageKey, isCollapsed ? 'true' : 'false')
37
+ }
38
+
39
+ updateToggleButton(button, isCollapsed) {
40
+ button.textContent = isCollapsed ? '▲' : '▼'
41
+ }
42
+ }
@@ -0,0 +1,77 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Manages the SQL editor textarea and query execution
4
+ // Usage: <div data-controller="editor">
5
+ export default class extends Controller {
6
+ static targets = ["textarea", "runButton", "clearButton", "results"]
7
+
8
+ connect() {
9
+ // Listen for history load events
10
+ this.element.addEventListener('history:load', (event) => {
11
+ this.loadQuery(event.detail.sql)
12
+ })
13
+ }
14
+
15
+ // Load query into textarea (from history)
16
+ loadQuery(sql) {
17
+ this.textareaTarget.value = sql
18
+ this.textareaTarget.focus()
19
+
20
+ // Scroll to editor
21
+ this.element.scrollIntoView({ behavior: 'smooth', block: 'start' })
22
+ }
23
+
24
+ // Clear textarea
25
+ clear(event) {
26
+ event.preventDefault()
27
+ this.textareaTarget.value = ''
28
+ this.textareaTarget.focus()
29
+ }
30
+
31
+ // Handle form submission
32
+ submit(event) {
33
+ const sql = this.textareaTarget.value.trim()
34
+
35
+ if (!sql) {
36
+ event.preventDefault()
37
+ alert('Please enter a SQL query')
38
+ return
39
+ }
40
+
41
+ // Show loading state
42
+ this.runButtonTarget.disabled = true
43
+ this.runButtonTarget.textContent = 'Running...'
44
+
45
+ // After Turbo completes the request, we'll handle success/error
46
+ }
47
+
48
+ // Called after successful query execution (via Turbo)
49
+ querySuccess(event) {
50
+ // Re-enable button
51
+ this.runButtonTarget.disabled = false
52
+ this.runButtonTarget.textContent = 'Run Query'
53
+
54
+ // Dispatch event to add to history
55
+ const sql = this.textareaTarget.value.trim()
56
+ this.dispatch('executed', {
57
+ detail: {
58
+ sql: sql,
59
+ timestamp: new Date().toISOString()
60
+ },
61
+ target: document.querySelector('[data-controller="history"]')
62
+ })
63
+ }
64
+
65
+ // Called after failed query execution
66
+ queryError(event) {
67
+ this.runButtonTarget.disabled = false
68
+ this.runButtonTarget.textContent = 'Run Query'
69
+ }
70
+
71
+ // Handle Turbo Frame errors
72
+ turboFrameError(event) {
73
+ console.error('Turbo Frame error:', event.detail)
74
+ this.runButtonTarget.disabled = false
75
+ this.runButtonTarget.textContent = 'Run Query'
76
+ }
77
+ }