pg_sql_triggers 1.2.0 → 1.4.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.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +397 -1
  3. data/COVERAGE.md +26 -19
  4. data/GEM_ANALYSIS.md +368 -0
  5. data/Goal.md +276 -155
  6. data/README.md +45 -22
  7. data/app/assets/javascripts/pg_sql_triggers/trigger_actions.js +50 -0
  8. data/app/controllers/concerns/pg_sql_triggers/error_handling.rb +56 -0
  9. data/app/controllers/concerns/pg_sql_triggers/kill_switch_protection.rb +66 -0
  10. data/app/controllers/concerns/pg_sql_triggers/permission_checking.rb +117 -0
  11. data/app/controllers/pg_sql_triggers/application_controller.rb +10 -62
  12. data/app/controllers/pg_sql_triggers/audit_logs_controller.rb +102 -0
  13. data/app/controllers/pg_sql_triggers/dashboard_controller.rb +4 -9
  14. data/app/controllers/pg_sql_triggers/tables_controller.rb +30 -4
  15. data/app/controllers/pg_sql_triggers/triggers_controller.rb +3 -21
  16. data/app/helpers/pg_sql_triggers/permissions_helper.rb +43 -0
  17. data/app/models/pg_sql_triggers/audit_log.rb +106 -0
  18. data/app/models/pg_sql_triggers/trigger_registry.rb +218 -13
  19. data/app/views/layouts/pg_sql_triggers/application.html.erb +25 -6
  20. data/app/views/pg_sql_triggers/audit_logs/index.html.erb +177 -0
  21. data/app/views/pg_sql_triggers/dashboard/index.html.erb +34 -12
  22. data/app/views/pg_sql_triggers/tables/index.html.erb +75 -5
  23. data/app/views/pg_sql_triggers/tables/show.html.erb +17 -6
  24. data/app/views/pg_sql_triggers/triggers/_drop_modal.html.erb +16 -7
  25. data/app/views/pg_sql_triggers/triggers/_re_execute_modal.html.erb +16 -7
  26. data/app/views/pg_sql_triggers/triggers/show.html.erb +26 -6
  27. data/config/routes.rb +2 -14
  28. data/db/migrate/20260103000001_create_pg_sql_triggers_audit_log.rb +28 -0
  29. data/db/migrate/20260228000001_add_for_each_to_pg_sql_triggers_registry.rb +8 -0
  30. data/docs/README.md +15 -5
  31. data/docs/api-reference.md +233 -151
  32. data/docs/audit-trail.md +413 -0
  33. data/docs/configuration.md +28 -7
  34. data/docs/getting-started.md +17 -16
  35. data/docs/permissions.md +369 -0
  36. data/docs/troubleshooting.md +486 -0
  37. data/docs/ui-guide.md +211 -0
  38. data/docs/usage-guide.md +38 -67
  39. data/docs/web-ui.md +251 -128
  40. data/lib/generators/pg_sql_triggers/templates/trigger_dsl.rb.tt +11 -0
  41. data/lib/generators/pg_sql_triggers/templates/trigger_migration_full.rb.tt +29 -0
  42. data/lib/generators/pg_sql_triggers/trigger_generator.rb +83 -0
  43. data/lib/pg_sql_triggers/drift/db_queries.rb +12 -8
  44. data/lib/pg_sql_triggers/drift/detector.rb +51 -38
  45. data/lib/pg_sql_triggers/dsl/trigger_definition.rb +17 -23
  46. data/lib/pg_sql_triggers/engine.rb +14 -0
  47. data/lib/pg_sql_triggers/errors.rb +245 -0
  48. data/lib/pg_sql_triggers/migrator/pre_apply_comparator.rb +8 -9
  49. data/lib/pg_sql_triggers/migrator/safety_validator.rb +32 -12
  50. data/lib/pg_sql_triggers/migrator.rb +53 -6
  51. data/lib/pg_sql_triggers/permissions/checker.rb +9 -2
  52. data/lib/pg_sql_triggers/registry/manager.rb +36 -11
  53. data/lib/pg_sql_triggers/registry/validator.rb +62 -5
  54. data/lib/pg_sql_triggers/registry.rb +141 -8
  55. data/lib/pg_sql_triggers/sql/kill_switch.rb +153 -247
  56. data/lib/pg_sql_triggers/sql.rb +0 -6
  57. data/lib/pg_sql_triggers/testing/function_tester.rb +2 -0
  58. data/lib/pg_sql_triggers/version.rb +1 -1
  59. data/lib/pg_sql_triggers.rb +7 -7
  60. data/pg_sql_triggers.gemspec +53 -0
  61. metadata +35 -18
  62. data/app/controllers/pg_sql_triggers/generator_controller.rb +0 -213
  63. data/app/controllers/pg_sql_triggers/sql_capsules_controller.rb +0 -161
  64. data/app/views/pg_sql_triggers/generator/new.html.erb +0 -388
  65. data/app/views/pg_sql_triggers/generator/preview.html.erb +0 -305
  66. data/app/views/pg_sql_triggers/sql_capsules/new.html.erb +0 -81
  67. data/app/views/pg_sql_triggers/sql_capsules/show.html.erb +0 -85
  68. data/docs/screenshots/.gitkeep +0 -1
  69. data/docs/screenshots/Generate Trigger.png +0 -0
  70. data/docs/screenshots/Triggers Page.png +0 -0
  71. data/docs/screenshots/kill error.png +0 -0
  72. data/docs/screenshots/kill modal for migration down.png +0 -0
  73. data/lib/generators/trigger/migration_generator.rb +0 -60
  74. data/lib/pg_sql_triggers/generator/form.rb +0 -80
  75. data/lib/pg_sql_triggers/generator/service.rb +0 -307
  76. data/lib/pg_sql_triggers/generator.rb +0 -8
  77. data/lib/pg_sql_triggers/sql/capsule.rb +0 -79
  78. data/lib/pg_sql_triggers/sql/executor.rb +0 -200
data/README.md CHANGED
@@ -50,16 +50,18 @@ PgSqlTriggers::DSL.pg_sql_trigger "users_email_validation" do
50
50
  table :users
51
51
  on :insert, :update
52
52
  function :validate_user_email
53
- version 1
54
- enabled false
55
- when_env :production
53
+ self.version = 1
54
+ self.enabled = true
55
+ timing :before
56
56
  end
57
57
  ```
58
58
 
59
- ### Create and Run Migration
59
+ ### Generate and Run Migration
60
60
 
61
61
  ```bash
62
- rails generate trigger:migration add_email_validation
62
+ # Generate a DSL stub + migration in one command
63
+ rails generate pg_sql_triggers:trigger users_email_validation users insert update --timing before --function validate_user_email
64
+
63
65
  rake trigger:migrate
64
66
  ```
65
67
 
@@ -83,34 +85,62 @@ Comprehensive documentation is available in the [docs](docs/) directory:
83
85
  ## Key Features
84
86
 
85
87
  ### Trigger DSL
86
- Define triggers using a Rails-native Ruby DSL with versioning and environment control.
88
+ Define triggers using a Rails-native Ruby DSL with versioning, row/statement-level granularity, and timing control.
89
+
90
+ ### CLI Generator
91
+ Scaffold a DSL stub and migration in one command:
92
+ ```bash
93
+ rails generate pg_sql_triggers:trigger TRIGGER_NAME TABLE_NAME [EVENTS...] [--timing before|after] [--function fn_name]
94
+ ```
95
+ Files land in `app/triggers/` and `db/triggers/` for code review like any other source change.
87
96
 
88
97
  ### Migration System
89
98
  Manage trigger functions and definitions with a migration system similar to Rails schema migrations.
90
99
 
91
100
  ### Drift Detection
92
- Automatically detect when database triggers drift from your DSL definitions.
101
+ Automatically detect when database triggers drift from your DSL definitions. N+1-free bulk detection across all triggers.
93
102
 
94
103
  ### Production Kill Switch
95
104
  Multi-layered safety mechanism preventing accidental destructive operations in production environments.
96
105
 
97
106
  ### Web Dashboard
98
- Visual interface for managing triggers, running migrations, and executing SQL capsules.
99
-
100
- ### SQL Capsules
101
- Emergency SQL execution feature for critical operations with Admin permission requirements, kill switch protection, and comprehensive logging.
107
+ Visual interface for managing triggers and running migrations. Includes:
108
+ - **Quick Actions**: Enable/disable, drop, and re-execute triggers from dashboard
109
+ - **Last Applied Tracking**: See when triggers were last applied with human-readable timestamps
110
+ - **Breadcrumb Navigation**: Easy navigation between dashboard, tables, and triggers
111
+ - **Permission-Aware UI**: Buttons show/hide based on user role
112
+
113
+ ### Audit Logging
114
+ Comprehensive audit trail for all trigger operations:
115
+ - Track who performed each operation (actor tracking)
116
+ - Before and after state capture (including function body)
117
+ - Success/failure logging with error messages
118
+ - Reason tracking for drop and re-execute operations
102
119
 
103
120
  ### Drop & Re-Execute Flow
104
121
  Operational controls for trigger lifecycle management with drop and re-execute capabilities, drift comparison, and required reason logging.
105
122
 
106
123
  ### Permissions
107
- Three-tier permission system (Viewer, Operator, Admin) with customizable authorization.
124
+ Three-tier permission system (Viewer, Operator, Admin) with customizable authorization. A startup warning is emitted in production when no `permission_checker` is configured.
108
125
 
109
126
  ## Console API
110
127
 
111
128
  PgSqlTriggers provides a comprehensive console API for managing triggers programmatically:
112
129
 
113
130
  ```ruby
131
+ # Query triggers
132
+ triggers = PgSqlTriggers::Registry.list
133
+ enabled = PgSqlTriggers::Registry.enabled
134
+ disabled = PgSqlTriggers::Registry.disabled
135
+ user_triggers = PgSqlTriggers::Registry.for_table(:users)
136
+
137
+ # Check drift status
138
+ drift_info = PgSqlTriggers::Registry.diff
139
+ drifted = PgSqlTriggers::Registry.drifted
140
+ in_sync = PgSqlTriggers::Registry.in_sync
141
+ unknown = PgSqlTriggers::Registry.unknown_triggers
142
+ dropped = PgSqlTriggers::Registry.dropped
143
+
114
144
  # Enable/disable triggers
115
145
  PgSqlTriggers::Registry.enable("users_email_validation", actor: current_user, confirmation: "EXECUTE TRIGGER_ENABLE")
116
146
  PgSqlTriggers::Registry.disable("users_email_validation", actor: current_user, confirmation: "EXECUTE TRIGGER_DISABLE")
@@ -118,17 +148,10 @@ PgSqlTriggers::Registry.disable("users_email_validation", actor: current_user, c
118
148
  # Drop and re-execute triggers
119
149
  PgSqlTriggers::Registry.drop("old_trigger", actor: current_user, reason: "No longer needed", confirmation: "EXECUTE TRIGGER_DROP")
120
150
  PgSqlTriggers::Registry.re_execute("drifted_trigger", actor: current_user, reason: "Fix drift", confirmation: "EXECUTE TRIGGER_RE_EXECUTE")
121
-
122
- # Execute SQL capsules
123
- capsule = PgSqlTriggers::SQL::Capsule.new(
124
- name: "emergency_fix",
125
- environment: "production",
126
- purpose: "Fix critical data issue",
127
- sql: "UPDATE users SET status = 'active' WHERE id = 123"
128
- )
129
- PgSqlTriggers::SQL::Executor.execute(capsule, actor: current_user, confirmation: "EXECUTE SQL")
130
151
  ```
131
152
 
153
+ See the [API Reference](docs/api-reference.md) for complete documentation of all console APIs.
154
+
132
155
  ## Examples
133
156
 
134
157
  For working examples and complete demonstrations, check out the [example repository](https://github.com/samaswin/pg_triggers_example).
@@ -138,7 +161,7 @@ For working examples and complete demonstrations, check out the [example reposit
138
161
  - **Rails-native**: Works seamlessly with Rails conventions
139
162
  - **Explicit over magic**: No automatic execution
140
163
  - **Safe by default**: Requires explicit confirmation for destructive actions
141
- - **Power with guardrails**: Emergency SQL escape hatches with safety checks
164
+ - **Code review first**: Generator produces files into working tree; no server-side file writes
142
165
 
143
166
  ## Development
144
167
 
@@ -0,0 +1,50 @@
1
+ // AJAX handlers for trigger actions
2
+ (function() {
3
+ 'use strict';
4
+
5
+ // Handle AJAX form submissions
6
+ document.addEventListener('DOMContentLoaded', function() {
7
+ // Set up AJAX error handling
8
+ document.addEventListener('ajax:error', function(event) {
9
+ const detail = event.detail || [];
10
+ const error = detail[0] || {};
11
+ const status = error.status || 500;
12
+ const message = error.message || 'An error occurred';
13
+
14
+ alert('Error: ' + message);
15
+ console.error('AJAX Error:', error);
16
+ });
17
+
18
+ // Handle AJAX success for trigger actions
19
+ document.addEventListener('ajax:success', function(event) {
20
+ const [data, status, xhr] = event.detail;
21
+
22
+ // If the response contains a redirect, follow it
23
+ if (xhr.getResponseHeader('Location')) {
24
+ window.location.href = xhr.getResponseHeader('Location');
25
+ } else {
26
+ // Otherwise, reload the page to show updated state
27
+ window.location.reload();
28
+ }
29
+ });
30
+
31
+ // Handle AJAX complete to show loading states
32
+ document.addEventListener('ajax:before', function(event) {
33
+ const form = event.target;
34
+ const submitButton = form.querySelector('button[type="submit"], button[type="button"]');
35
+ if (submitButton) {
36
+ submitButton.disabled = true;
37
+ submitButton.textContent = 'Processing...';
38
+ }
39
+ });
40
+
41
+ document.addEventListener('ajax:complete', function(event) {
42
+ const form = event.target;
43
+ const submitButton = form.querySelector('button[type="submit"], button[type="button"]');
44
+ if (submitButton) {
45
+ submitButton.disabled = false;
46
+ }
47
+ });
48
+ });
49
+ })();
50
+
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgSqlTriggers
4
+ module ErrorHandling
5
+ extend ActiveSupport::Concern
6
+
7
+ # Handles errors and formats them for display.
8
+ # Returns a formatted error message for flash display.
9
+ #
10
+ # @param error [Exception] The error to format
11
+ # @return [String] Formatted error message
12
+ def format_error_for_flash(error)
13
+ return error.to_s unless error.is_a?(PgSqlTriggers::Error)
14
+
15
+ # Use user_message which includes recovery suggestions
16
+ error.user_message
17
+ end
18
+
19
+ # Rescues from PgSqlTriggers errors and sets appropriate flash messages.
20
+ #
21
+ # @param error [Exception] The error to handle
22
+ # @return [void]
23
+ def rescue_pg_sql_triggers_error(error)
24
+ Rails.logger.error("#{error.class.name}: #{error.message}")
25
+ Rails.logger.error(error.backtrace.join("\n")) if Rails.env.development? && error.respond_to?(:backtrace)
26
+
27
+ flash[:error] = if error.is_a?(PgSqlTriggers::Error)
28
+ format_error_for_flash(error)
29
+ else
30
+ "An unexpected error occurred: #{error.message}"
31
+ end
32
+ end
33
+
34
+ # Handles kill switch errors with appropriate flash message and redirect.
35
+ #
36
+ # @param error [PgSqlTriggers::KillSwitchError] The kill switch error
37
+ # @param redirect_path [String, nil] Optional redirect path (defaults to root_path)
38
+ # @return [void]
39
+ def handle_kill_switch_error(error, redirect_path: nil)
40
+ flash[:error] = error.message
41
+ redirect_to redirect_path || root_path
42
+ end
43
+
44
+ # Handles standard errors with logging and flash message.
45
+ #
46
+ # @param error [Exception] The error to handle
47
+ # @param operation [String] Description of the operation that failed
48
+ # @param redirect_path [String, nil] Optional redirect path (defaults to root_path)
49
+ # @return [void]
50
+ def handle_standard_error(error, operation:, redirect_path: nil)
51
+ Rails.logger.error("#{operation} failed: #{error.message}\n#{error.backtrace.join("\n")}")
52
+ flash[:error] = "#{operation}: #{error.message}"
53
+ redirect_to redirect_path || root_path
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgSqlTriggers
4
+ module KillSwitchProtection
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ # Helper method available in views
9
+ helper_method :kill_switch_active?, :expected_confirmation_text, :current_environment
10
+ end
11
+
12
+ # Checks if kill switch is active for the current environment.
13
+ #
14
+ # @return [Boolean] true if kill switch is active, false otherwise
15
+ def kill_switch_active?
16
+ PgSqlTriggers::SQL::KillSwitch.active?(environment: current_environment)
17
+ end
18
+
19
+ # Checks kill switch before executing a dangerous operation.
20
+ # Raises KillSwitchError if the operation is blocked.
21
+ #
22
+ # @param operation [Symbol] The operation being performed
23
+ # @param confirmation [String, nil] Optional confirmation text from params
24
+ # @raise [PgSqlTriggers::KillSwitchError] If the operation is blocked
25
+ # @return [true] If the operation is allowed
26
+ def check_kill_switch(operation:, confirmation: nil)
27
+ PgSqlTriggers::SQL::KillSwitch.check!(
28
+ operation: operation,
29
+ environment: current_environment,
30
+ confirmation: confirmation,
31
+ actor: current_actor
32
+ )
33
+ end
34
+
35
+ # Before action to require kill switch override for an action.
36
+ # Add to specific controller actions that need protection:
37
+ # before_action -> { require_kill_switch_override(:operation_name) }, only: [:dangerous_action]
38
+ #
39
+ # @param operation [Symbol] The operation name
40
+ # @param confirmation [String, nil] Optional confirmation text
41
+ # @raise [PgSqlTriggers::KillSwitchError] If the operation is blocked
42
+ def require_kill_switch_override(operation, confirmation: nil)
43
+ check_kill_switch(operation: operation, confirmation: confirmation)
44
+ end
45
+
46
+ # Returns the expected confirmation text for an operation (for use in views).
47
+ #
48
+ # @param operation [Symbol] The operation name
49
+ # @return [String] The expected confirmation text
50
+ def expected_confirmation_text(operation)
51
+ if PgSqlTriggers.respond_to?(:kill_switch_confirmation_pattern) &&
52
+ PgSqlTriggers.kill_switch_confirmation_pattern.respond_to?(:call)
53
+ PgSqlTriggers.kill_switch_confirmation_pattern.call(operation)
54
+ else
55
+ "EXECUTE #{operation.to_s.upcase}"
56
+ end
57
+ end
58
+
59
+ # Returns the current environment.
60
+ #
61
+ # @return [String] The current Rails environment
62
+ def current_environment
63
+ Rails.env
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgSqlTriggers
4
+ module PermissionChecking
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ # Helper methods available in views
9
+ helper_method :current_actor, :can_view_triggers?, :can_enable_disable_triggers?,
10
+ :can_drop_triggers?, :can_execute_sql?, :can_generate_triggers?, :can_apply_triggers?
11
+ end
12
+
13
+ # Returns the current actor (user) performing the action.
14
+ # Override this method in host application to provide actual user.
15
+ #
16
+ # @return [Hash] Actor information with :type and :id keys
17
+ def current_actor
18
+ {
19
+ type: current_user_type,
20
+ id: current_user_id
21
+ }
22
+ end
23
+
24
+ # Returns the current user type.
25
+ # Override this method in host application.
26
+ #
27
+ # @return [String] User type (default: "User")
28
+ def current_user_type
29
+ "User"
30
+ end
31
+
32
+ # Returns the current user ID.
33
+ # Override this method in host application.
34
+ #
35
+ # @return [String] User ID (default: "unknown")
36
+ def current_user_id
37
+ "unknown"
38
+ end
39
+
40
+ # Checks if current actor has viewer permissions.
41
+ #
42
+ # @raise [ActionController::RedirectError] Redirects if permission denied
43
+ def check_viewer_permission
44
+ can_access = begin
45
+ PgSqlTriggers::Permissions.can?(current_actor, :view_triggers, environment: current_environment)
46
+ rescue StandardError => e
47
+ Rails.logger.error("Permission check failed: #{e.message}\n#{e.backtrace.join("\n")}")
48
+ false
49
+ end
50
+ return if can_access
51
+
52
+ redirect_to root_path, alert: "Insufficient permissions. Viewer role required."
53
+ end
54
+
55
+ # Checks if current actor has operator permissions (enable/disable/apply).
56
+ #
57
+ # @raise [ActionController::RedirectError] Redirects if permission denied
58
+ def check_operator_permission
59
+ can_access = begin
60
+ PgSqlTriggers::Permissions.can?(current_actor, :enable_trigger, environment: current_environment)
61
+ rescue StandardError => e
62
+ Rails.logger.error("Permission check failed: #{e.message}\n#{e.backtrace.join("\n")}")
63
+ false
64
+ end
65
+ return if can_access
66
+
67
+ redirect_to root_path, alert: "Insufficient permissions. Operator role required."
68
+ end
69
+
70
+ # Checks if current actor has admin permissions (drop/re-execute/execute SQL).
71
+ #
72
+ # @raise [ActionController::RedirectError] Redirects if permission denied
73
+ def check_admin_permission
74
+ can_access = begin
75
+ PgSqlTriggers::Permissions.can?(current_actor, :drop_trigger, environment: current_environment)
76
+ rescue StandardError => e
77
+ Rails.logger.error("Permission check failed: #{e.message}\n#{e.backtrace.join("\n")}")
78
+ false
79
+ end
80
+ return if can_access
81
+
82
+ redirect_to root_path, alert: "Insufficient permissions. Admin role required."
83
+ end
84
+
85
+ # Permission helper methods for views
86
+
87
+ # @return [Boolean] true if current actor can view triggers
88
+ def can_view_triggers?
89
+ PgSqlTriggers::Permissions.can?(current_actor, :view_triggers, environment: current_environment)
90
+ end
91
+
92
+ # @return [Boolean] true if current actor can enable/disable triggers
93
+ def can_enable_disable_triggers?
94
+ PgSqlTriggers::Permissions.can?(current_actor, :enable_trigger, environment: current_environment)
95
+ end
96
+
97
+ # @return [Boolean] true if current actor can drop triggers
98
+ def can_drop_triggers?
99
+ PgSqlTriggers::Permissions.can?(current_actor, :drop_trigger, environment: current_environment)
100
+ end
101
+
102
+ # @return [Boolean] true if current actor can execute SQL capsules
103
+ def can_execute_sql?
104
+ PgSqlTriggers::Permissions.can?(current_actor, :execute_sql, environment: current_environment)
105
+ end
106
+
107
+ # @return [Boolean] true if current actor can generate triggers
108
+ def can_generate_triggers?
109
+ PgSqlTriggers::Permissions.can?(current_actor, :apply_trigger, environment: current_environment)
110
+ end
111
+
112
+ # @return [Boolean] true if current actor can apply triggers
113
+ def can_apply_triggers?
114
+ PgSqlTriggers::Permissions.can?(current_actor, :apply_trigger, environment: current_environment)
115
+ end
116
+ end
117
+ end
@@ -1,81 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PgSqlTriggers
4
+ # Base controller for all pg_sql_triggers controllers.
5
+ # Includes common concerns for kill switch protection, permission checking, and error handling.
4
6
  class ApplicationController < ActionController::Base
5
7
  include PgSqlTriggers::Engine.routes.url_helpers
8
+ include PgSqlTriggers::KillSwitchProtection
9
+ include PgSqlTriggers::PermissionChecking
10
+ include PgSqlTriggers::ErrorHandling
6
11
 
7
12
  protect_from_forgery with: :exception
8
13
  layout "pg_sql_triggers/application"
9
14
 
10
15
  before_action :check_permissions?
11
16
 
12
- # Helper methods available in views
13
- helper_method :current_environment, :kill_switch_active?, :expected_confirmation_text, :current_actor
17
+ # Include permissions helper for view helpers
18
+ include PgSqlTriggers::PermissionsHelper
14
19
 
15
20
  private
16
21
 
22
+ # Override this method in host application to implement custom permission checks.
23
+ #
24
+ # @return [Boolean] true if permissions check passes
17
25
  def check_permissions?
18
- # Override this method in host application to implement custom permission checks
19
26
  true
20
27
  end
21
-
22
- def current_actor
23
- # Override this in host application to provide actual user
24
- {
25
- type: current_user_type,
26
- id: current_user_id
27
- }
28
- end
29
-
30
- def current_user_type
31
- "User"
32
- end
33
-
34
- def current_user_id
35
- "unknown"
36
- end
37
-
38
- # ========== Kill Switch Helpers ==========
39
-
40
- # Returns the current environment
41
- def current_environment
42
- Rails.env
43
- end
44
-
45
- # Checks if kill switch is active for the current environment
46
- def kill_switch_active?
47
- PgSqlTriggers::SQL::KillSwitch.active?(environment: current_environment)
48
- end
49
-
50
- # Checks kill switch before executing a dangerous operation
51
- # Raises KillSwitchError if the operation is blocked
52
- #
53
- # @param operation [Symbol] The operation being performed
54
- # @param confirmation [String, nil] Optional confirmation text from params
55
- def check_kill_switch(operation:, confirmation: nil)
56
- PgSqlTriggers::SQL::KillSwitch.check!(
57
- operation: operation,
58
- environment: current_environment,
59
- confirmation: confirmation,
60
- actor: current_actor
61
- )
62
- end
63
-
64
- # Before action to require kill switch override for an action
65
- # Add to specific controller actions that need protection:
66
- # before_action -> { require_kill_switch_override(:operation_name) }, only: [:dangerous_action]
67
- def require_kill_switch_override(operation, confirmation: nil)
68
- check_kill_switch(operation: operation, confirmation: confirmation)
69
- end
70
-
71
- # Returns the expected confirmation text for an operation (for use in views)
72
- def expected_confirmation_text(operation)
73
- if PgSqlTriggers.respond_to?(:kill_switch_confirmation_pattern) &&
74
- PgSqlTriggers.kill_switch_confirmation_pattern.respond_to?(:call)
75
- PgSqlTriggers.kill_switch_confirmation_pattern.call(operation)
76
- else
77
- "EXECUTE #{operation.to_s.upcase}"
78
- end
79
- end
80
28
  end
81
29
  end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgSqlTriggers
4
+ class AuditLogsController < ApplicationController
5
+ before_action :check_viewer_permission
6
+
7
+ # GET /audit_logs
8
+ # Display audit log entries with filtering and sorting
9
+ def index
10
+ @audit_logs = PgSqlTriggers::AuditLog.all
11
+
12
+ # Filter by trigger name
13
+ @audit_logs = @audit_logs.for_trigger(params[:trigger_name]) if params[:trigger_name].present?
14
+
15
+ # Filter by operation
16
+ @audit_logs = @audit_logs.for_operation(params[:operation]) if params[:operation].present?
17
+
18
+ # Filter by status
19
+ if params[:status].present? && %w[success failure].include?(params[:status])
20
+ @audit_logs = @audit_logs.where(status: params[:status])
21
+ end
22
+
23
+ # Filter by environment
24
+ @audit_logs = @audit_logs.for_environment(params[:environment]) if params[:environment].present?
25
+
26
+ # Filter by actor (search in JSONB field)
27
+ @audit_logs = @audit_logs.where("actor->>'id' = ?", params[:actor_id]) if params[:actor_id].present?
28
+
29
+ # Sort by date (default: most recent first)
30
+ sort_direction = params[:sort] == "asc" ? :asc : :desc
31
+ @audit_logs = @audit_logs.order(created_at: sort_direction)
32
+
33
+ # Pagination
34
+ @per_page = (params[:per_page] || 50).to_i
35
+ @per_page = [@per_page, 200].min # Cap at 200
36
+ @page = (params[:page] || 1).to_i
37
+ @total_count = @audit_logs.count
38
+ @total_pages = @total_count.positive? ? (@total_count.to_f / @per_page).ceil : 1
39
+ @page = @page.clamp(1, @total_pages)
40
+
41
+ offset = (@page - 1) * @per_page
42
+ @audit_logs = @audit_logs.offset(offset).limit(@per_page)
43
+
44
+ # Get distinct values for filter dropdowns
45
+ @available_trigger_names = PgSqlTriggers::AuditLog.distinct.pluck(:trigger_name).compact.sort
46
+ @available_operations = PgSqlTriggers::AuditLog.distinct.pluck(:operation).compact.sort
47
+ @available_environments = PgSqlTriggers::AuditLog.distinct.pluck(:environment).compact.sort
48
+
49
+ respond_to do |format|
50
+ format.html
51
+ format.csv do
52
+ send_data generate_csv, filename: "audit_logs_#{Time.current.strftime('%Y%m%d_%H%M%S')}.csv",
53
+ type: "text/csv", disposition: "attachment"
54
+ end
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ def generate_csv
61
+ require "csv"
62
+
63
+ # Get all audit logs (no pagination for CSV)
64
+ audit_logs = PgSqlTriggers::AuditLog.all
65
+
66
+ # Apply filters
67
+ audit_logs = audit_logs.for_trigger(params[:trigger_name]) if params[:trigger_name].present?
68
+ audit_logs = audit_logs.for_operation(params[:operation]) if params[:operation].present?
69
+ if params[:status].present? && %w[success failure].include?(params[:status])
70
+ audit_logs = audit_logs.where(status: params[:status])
71
+ end
72
+ audit_logs = audit_logs.for_environment(params[:environment]) if params[:environment].present?
73
+ audit_logs = audit_logs.where("actor->>'id' = ?", params[:actor_id]) if params[:actor_id].present?
74
+
75
+ CSV.generate(headers: true) do |csv|
76
+ csv << [
77
+ "ID", "Trigger Name", "Operation", "Status", "Environment",
78
+ "Actor Type", "Actor ID", "Reason", "Error Message",
79
+ "Created At"
80
+ ]
81
+
82
+ audit_logs.order(created_at: :desc).find_each do |log|
83
+ actor_type = log.actor.is_a?(Hash) ? log.actor["type"] || log.actor[:type] : nil
84
+ actor_id = log.actor.is_a?(Hash) ? log.actor["id"] || log.actor[:id] : nil
85
+
86
+ csv << [
87
+ log.id,
88
+ log.trigger_name || "",
89
+ log.operation,
90
+ log.status,
91
+ log.environment || "",
92
+ actor_type || "",
93
+ actor_id || "",
94
+ log.reason || "",
95
+ log.error_message || "",
96
+ log.created_at&.iso8601 || ""
97
+ ]
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -5,7 +5,10 @@ module PgSqlTriggers
5
5
  before_action :check_viewer_permission
6
6
 
7
7
  def index
8
- @triggers = PgSqlTriggers::TriggerRegistry.order(created_at: :desc)
8
+ # Sort by installed_at descending (most recent first), fallback to created_at
9
+ @triggers = PgSqlTriggers::TriggerRegistry.order(
10
+ Arel.sql("COALESCE(installed_at, created_at) DESC")
11
+ )
9
12
 
10
13
  # Get drift summary
11
14
  drift_summary = PgSqlTriggers::Drift::Reporter.summary
@@ -43,13 +46,5 @@ module PgSqlTriggers
43
46
  @per_page = 20
44
47
  end
45
48
  end
46
-
47
- private
48
-
49
- def check_viewer_permission
50
- return if PgSqlTriggers::Permissions.can?(current_actor, :view_triggers)
51
-
52
- redirect_to root_path, alert: "Insufficient permissions. Viewer role required."
53
- end
54
49
  end
55
50
  end
@@ -2,12 +2,38 @@
2
2
 
3
3
  module PgSqlTriggers
4
4
  class TablesController < ApplicationController
5
+ before_action :check_viewer_permission
6
+
5
7
  def index
6
8
  all_tables = PgSqlTriggers::DatabaseIntrospection.new.tables_with_triggers
7
- # Only show tables that have at least one trigger
8
- @tables_with_triggers = all_tables.select { |t| t[:trigger_count].positive? }
9
- @total_tables = @tables_with_triggers.count
10
- @tables_with_trigger_count = @tables_with_triggers.count
9
+
10
+ # Calculate statistics
11
+ @tables_with_trigger_count = all_tables.count { |t| t[:trigger_count].positive? }
12
+ @tables_without_trigger_count = all_tables.count { |t| t[:trigger_count].zero? }
13
+ @total_tables_count = all_tables.count
14
+
15
+ # Filter based on parameter
16
+ @filter = params[:filter] || "with_triggers"
17
+ filtered_tables = case @filter
18
+ when "with_triggers"
19
+ all_tables.select { |t| t[:trigger_count].positive? }
20
+ when "without_triggers"
21
+ all_tables.select { |t| t[:trigger_count].zero? }
22
+ else # 'all'
23
+ all_tables
24
+ end
25
+
26
+ @total_tables = filtered_tables.count
27
+
28
+ # Pagination
29
+ @per_page = (params[:per_page] || 20).to_i
30
+ @per_page = [@per_page, 100].min # Cap at 100
31
+ @page = (params[:page] || 1).to_i
32
+ @total_pages = @total_tables.positive? ? (@total_tables.to_f / @per_page).ceil : 1
33
+ @page = @page.clamp(1, @total_pages) # Ensure page is within valid range
34
+
35
+ offset = (@page - 1) * @per_page
36
+ @tables_with_triggers = filtered_tables.slice(offset, @per_page) || []
11
37
  end
12
38
 
13
39
  def show