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
@@ -4,10 +4,10 @@ module PgSqlTriggers
4
4
  # Controller for managing individual triggers via web UI
5
5
  # Provides actions to enable and disable triggers
6
6
  class TriggersController < ApplicationController
7
- before_action :set_trigger, only: %i[show enable disable drop re_execute]
8
7
  before_action :check_viewer_permission, only: [:show]
9
8
  before_action :check_operator_permission, only: %i[enable disable]
10
9
  before_action :check_admin_permission, only: %i[drop re_execute]
10
+ before_action :set_trigger, only: %i[show enable disable drop re_execute]
11
11
 
12
12
  def show
13
13
  # Load trigger details and drift information
@@ -18,7 +18,7 @@ module PgSqlTriggers
18
18
  # Check kill switch before enabling trigger
19
19
  check_kill_switch(operation: :ui_trigger_enable, confirmation: params[:confirmation_text])
20
20
 
21
- @trigger.enable!(confirmation: params[:confirmation_text])
21
+ @trigger.enable!(confirmation: params[:confirmation_text], actor: current_actor)
22
22
  flash[:success] = "Trigger '#{@trigger.trigger_name}' enabled successfully."
23
23
  redirect_to redirect_path
24
24
  rescue PgSqlTriggers::KillSwitchError => e
@@ -34,7 +34,7 @@ module PgSqlTriggers
34
34
  # Check kill switch before disabling trigger
35
35
  check_kill_switch(operation: :ui_trigger_disable, confirmation: params[:confirmation_text])
36
36
 
37
- @trigger.disable!(confirmation: params[:confirmation_text])
37
+ @trigger.disable!(confirmation: params[:confirmation_text], actor: current_actor)
38
38
  flash[:success] = "Trigger '#{@trigger.trigger_name}' disabled successfully."
39
39
  redirect_to redirect_path
40
40
  rescue PgSqlTriggers::KillSwitchError => e
@@ -119,24 +119,6 @@ module PgSqlTriggers
119
119
  redirect_to root_path
120
120
  end
121
121
 
122
- def check_viewer_permission
123
- return if PgSqlTriggers::Permissions.can?(current_actor, :view_triggers)
124
-
125
- redirect_to root_path, alert: "Insufficient permissions. Viewer role required."
126
- end
127
-
128
- def check_operator_permission
129
- return if PgSqlTriggers::Permissions.can?(current_actor, :enable_trigger)
130
-
131
- redirect_to root_path, alert: "Insufficient permissions. Operator role required."
132
- end
133
-
134
- def check_admin_permission
135
- return if PgSqlTriggers::Permissions.can?(current_actor, :drop_trigger)
136
-
137
- redirect_to root_path, alert: "Insufficient permissions. Admin role required."
138
- end
139
-
140
122
  def redirect_path
141
123
  # Redirect back to the referring page if possible, otherwise to dashboard
142
124
  params[:redirect_to].presence || request.referer || root_path
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgSqlTriggers
4
+ module PermissionsHelper
5
+ # Check if the current actor can perform an action
6
+ #
7
+ # @param action [Symbol, String] The action to check
8
+ # @return [Boolean] True if the actor can perform the action
9
+ def can?(action)
10
+ PgSqlTriggers::Permissions.can?(current_actor, action, environment: current_environment)
11
+ end
12
+
13
+ # Check if the current actor can view triggers
14
+ def can_view_triggers?
15
+ can?(:view_triggers)
16
+ end
17
+
18
+ # Check if the current actor can enable/disable triggers
19
+ def can_enable_disable_triggers?
20
+ can?(:enable_trigger)
21
+ end
22
+
23
+ # Check if the current actor can drop triggers
24
+ def can_drop_triggers?
25
+ can?(:drop_trigger)
26
+ end
27
+
28
+ # Check if the current actor can execute SQL capsules
29
+ def can_execute_sql?
30
+ can?(:execute_sql)
31
+ end
32
+
33
+ # Check if the current actor can generate triggers
34
+ def can_generate_triggers?
35
+ can?(:generate_trigger)
36
+ end
37
+
38
+ # Check if the current actor can apply triggers (run migrations)
39
+ def can_apply_triggers?
40
+ can?(:apply_trigger)
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgSqlTriggers
4
+ # Audit log model for tracking all trigger operations
5
+ class AuditLog < PgSqlTriggers::ApplicationRecord
6
+ self.table_name = "pg_sql_triggers_audit_log"
7
+
8
+ # Scopes
9
+ scope :for_trigger, ->(trigger_name) { where(trigger_name: trigger_name) }
10
+ scope :for_operation, ->(operation) { where(operation: operation) }
11
+ scope :for_environment, ->(env) { where(environment: env) }
12
+ scope :successful, -> { where(status: "success") }
13
+ scope :failed, -> { where(status: "failure") }
14
+ scope :recent, -> { order(created_at: :desc) }
15
+
16
+ # Validations
17
+ validates :operation, presence: true
18
+ validates :status, presence: true, inclusion: { in: %w[success failure] }
19
+
20
+ # Class methods for logging operations
21
+ class << self
22
+ # Log a successful operation
23
+ #
24
+ # @param operation [Symbol, String] The operation being performed
25
+ # @param trigger_name [String, nil] The trigger name (if applicable)
26
+ # @param actor [Hash] Information about who performed the action
27
+ # @param environment [String, nil] The environment
28
+ # @param reason [String, nil] Reason for the operation
29
+ # @param confirmation_text [String, nil] Confirmation text used
30
+ # @param before_state [Hash, nil] State before operation
31
+ # @param after_state [Hash, nil] State after operation
32
+ # @param diff [String, nil] Diff information
33
+ # rubocop:disable Metrics/ParameterLists
34
+ def log_success(operation:, trigger_name: nil, actor: nil, environment: nil,
35
+ reason: nil, confirmation_text: nil, before_state: nil,
36
+ after_state: nil, diff: nil)
37
+ create!(
38
+ trigger_name: trigger_name,
39
+ operation: operation.to_s,
40
+ actor: serialize_actor(actor),
41
+ environment: environment,
42
+ status: "success",
43
+ reason: reason,
44
+ confirmation_text: confirmation_text,
45
+ before_state: before_state,
46
+ after_state: after_state,
47
+ diff: diff
48
+ )
49
+ rescue StandardError => e
50
+ Rails.logger.error("Failed to log audit entry: #{e.message}") if defined?(Rails.logger)
51
+ nil
52
+ end
53
+ # rubocop:enable Metrics/ParameterLists
54
+
55
+ # Log a failed operation
56
+ #
57
+ # @param operation [Symbol, String] The operation being performed
58
+ # @param trigger_name [String, nil] The trigger name (if applicable)
59
+ # @param actor [Hash] Information about who performed the action
60
+ # @param environment [String, nil] The environment
61
+ # @param error_message [String] Error message
62
+ # @param reason [String, nil] Reason for the operation (if provided before failure)
63
+ # @param confirmation_text [String, nil] Confirmation text used
64
+ # @param before_state [Hash, nil] State before operation
65
+ # rubocop:disable Metrics/ParameterLists
66
+ def log_failure(operation:, error_message:, trigger_name: nil, actor: nil, environment: nil, reason: nil,
67
+ confirmation_text: nil, before_state: nil)
68
+ create!(
69
+ trigger_name: trigger_name,
70
+ operation: operation.to_s,
71
+ actor: serialize_actor(actor),
72
+ environment: environment,
73
+ status: "failure",
74
+ error_message: error_message,
75
+ reason: reason,
76
+ confirmation_text: confirmation_text,
77
+ before_state: before_state
78
+ )
79
+ rescue StandardError => e
80
+ Rails.logger.error("Failed to log audit entry: #{e.message}") if defined?(Rails.logger)
81
+ nil
82
+ end
83
+ # rubocop:enable Metrics/ParameterLists
84
+
85
+ # Get audit log entries for a specific trigger
86
+ #
87
+ # @param trigger_name [String] The trigger name
88
+ # @return [ActiveRecord::Relation] Audit log entries for the trigger
89
+ def for_trigger_name(trigger_name)
90
+ for_trigger(trigger_name).recent
91
+ end
92
+
93
+ private
94
+
95
+ def serialize_actor(actor)
96
+ return nil if actor.nil?
97
+
98
+ if actor.is_a?(Hash)
99
+ actor
100
+ else
101
+ { type: actor.class.name, id: actor.id.to_s }
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -1,6 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PgSqlTriggers
4
+ # ActiveRecord model representing a trigger in the registry.
5
+ #
6
+ # This model tracks all triggers managed by pg_sql_triggers, including their
7
+ # state, version, checksum, and drift status.
8
+ #
9
+ # @example Query triggers
10
+ # # Find a trigger
11
+ # trigger = PgSqlTriggers::TriggerRegistry.find_by(trigger_name: "users_email_validation")
12
+ #
13
+ # # Check drift status
14
+ # trigger.drifted? # => true/false
15
+ # trigger.in_sync? # => true/false
16
+ #
17
+ # @example Enable/disable triggers
18
+ # trigger.enable!(actor: current_user, confirmation: "EXECUTE TRIGGER_ENABLE")
19
+ # trigger.disable!(actor: current_user, confirmation: "EXECUTE TRIGGER_DISABLE")
20
+ #
21
+ # @example Drop and re-execute triggers
22
+ # trigger.drop!(reason: "No longer needed", actor: current_user, confirmation: "EXECUTE TRIGGER_DROP")
23
+ # trigger.re_execute!(reason: "Fix drift", actor: current_user, confirmation: "EXECUTE TRIGGER_RE_EXECUTE")
4
24
  # rubocop:disable Metrics/ClassLength
5
25
  class TriggerRegistry < PgSqlTriggers::ApplicationRecord
6
26
  self.table_name = "pg_sql_triggers_registry"
@@ -19,36 +39,59 @@ module PgSqlTriggers
19
39
  scope :for_environment, ->(env) { where(environment: [env, nil]) }
20
40
  scope :by_source, ->(source) { where(source: source) }
21
41
 
22
- # Drift detection methods
42
+ # Returns the current drift state of this trigger.
43
+ #
44
+ # @return [String] One of: "in_sync", "drifted", "manual_override", "disabled", "dropped", "unknown"
23
45
  def drift_state
24
46
  result = PgSqlTriggers::Drift.detect(trigger_name)
25
47
  result[:state]
26
48
  end
27
49
 
50
+ # Returns detailed drift detection result for this trigger.
51
+ #
52
+ # @return [Hash] Drift result with keys: :state, :trigger_name, :expected_sql, :actual_sql, etc.
28
53
  def drift_result
29
54
  PgSqlTriggers::Drift::Detector.detect(trigger_name)
30
55
  end
31
56
 
57
+ # Checks if this trigger has drifted from its expected state.
58
+ #
59
+ # @return [Boolean] true if trigger has drifted, false otherwise
32
60
  def drifted?
33
61
  drift_state == PgSqlTriggers::DRIFT_STATE_DRIFTED
34
62
  end
35
63
 
64
+ # Checks if this trigger is in sync with its expected state.
65
+ #
66
+ # @return [Boolean] true if trigger is in sync, false otherwise
36
67
  def in_sync?
37
68
  drift_state == PgSqlTriggers::DRIFT_STATE_IN_SYNC
38
69
  end
39
70
 
71
+ # Checks if this trigger has been dropped from the database.
72
+ #
73
+ # @return [Boolean] true if trigger has been dropped, false otherwise
40
74
  def dropped?
41
75
  drift_state == PgSqlTriggers::DRIFT_STATE_DROPPED
42
76
  end
43
77
 
44
- def enable!(confirmation: nil)
78
+ # Enables this trigger in the database and updates the registry.
79
+ #
80
+ # @param confirmation [String, nil] Optional confirmation text for kill switch protection
81
+ # @param actor [Hash, nil] Information about who is performing the action (must have :type and :id keys)
82
+ # @raise [PgSqlTriggers::KillSwitchError] If kill switch blocks the operation
83
+ # @return [PgSqlTriggers::TriggerRegistry] self
84
+ def enable!(confirmation: nil, actor: nil)
85
+ actor ||= { type: "Console", id: "TriggerRegistry#enable!" }
86
+ before_state = capture_state
87
+
45
88
  # Check kill switch before enabling trigger
46
89
  # Use Rails.env for kill switch check, not the trigger's environment field
47
90
  PgSqlTriggers::SQL::KillSwitch.check!(
48
91
  operation: :trigger_enable,
49
92
  environment: Rails.env,
50
93
  confirmation: confirmation,
51
- actor: { type: "Console", id: "TriggerRegistry#enable!" }
94
+ actor: actor
52
95
  )
53
96
 
54
97
  # Check if trigger exists in database before trying to enable it
@@ -71,12 +114,16 @@ module PgSqlTriggers
71
114
  rescue ActiveRecord::StatementInvalid, StandardError => e
72
115
  # If trigger doesn't exist or can't be enabled, continue to update registry
73
116
  Rails.logger.warn("Could not enable trigger: #{e.message}") if defined?(Rails.logger)
117
+ log_audit_failure(:trigger_enable, actor, e.message, before_state: before_state)
118
+ raise
74
119
  end
75
120
  end
76
121
 
77
122
  # Update the registry record (always update, even if trigger doesn't exist)
78
123
  begin
79
124
  update!(enabled: true)
125
+ after_state = capture_state
126
+ log_audit_success(:trigger_enable, actor, before_state: before_state, after_state: after_state)
80
127
  rescue ActiveRecord::StatementInvalid, StandardError => e
81
128
  # If update! fails, try update_column which bypasses validations and callbacks
82
129
  # and might not use execute in the same way
@@ -85,6 +132,8 @@ module PgSqlTriggers
85
132
  # rubocop:disable Rails/SkipsModelValidations
86
133
  update_column(:enabled, true)
87
134
  # rubocop:enable Rails/SkipsModelValidations
135
+ after_state = capture_state
136
+ log_audit_success(:trigger_enable, actor, before_state: before_state, after_state: after_state)
88
137
  rescue StandardError => update_error
89
138
  # If update_column also fails, just set the in-memory attribute
90
139
  # The test might reload, but we've done our best
@@ -92,18 +141,29 @@ module PgSqlTriggers
92
141
  Rails.logger.warn("Could not update registry via update_column: #{update_error.message}") if defined?(Rails.logger)
93
142
  # rubocop:enable Layout/LineLength
94
143
  self.enabled = true
144
+ after_state = capture_state
145
+ log_audit_success(:trigger_enable, actor, before_state: before_state, after_state: after_state)
95
146
  end
96
147
  end
97
148
  end
98
149
 
99
- def disable!(confirmation: nil)
150
+ # Disables this trigger in the database and updates the registry.
151
+ #
152
+ # @param confirmation [String, nil] Optional confirmation text for kill switch protection
153
+ # @param actor [Hash, nil] Information about who is performing the action (must have :type and :id keys)
154
+ # @raise [PgSqlTriggers::KillSwitchError] If kill switch blocks the operation
155
+ # @return [PgSqlTriggers::TriggerRegistry] self
156
+ def disable!(confirmation: nil, actor: nil)
157
+ actor ||= { type: "Console", id: "TriggerRegistry#disable!" }
158
+ before_state = capture_state
159
+
100
160
  # Check kill switch before disabling trigger
101
161
  # Use Rails.env for kill switch check, not the trigger's environment field
102
162
  PgSqlTriggers::SQL::KillSwitch.check!(
103
163
  operation: :trigger_disable,
104
164
  environment: Rails.env,
105
165
  confirmation: confirmation,
106
- actor: { type: "Console", id: "TriggerRegistry#disable!" }
166
+ actor: actor
107
167
  )
108
168
 
109
169
  # Check if trigger exists in database before trying to disable it
@@ -126,12 +186,16 @@ module PgSqlTriggers
126
186
  rescue ActiveRecord::StatementInvalid, StandardError => e
127
187
  # If trigger doesn't exist or can't be disabled, continue to update registry
128
188
  Rails.logger.warn("Could not disable trigger: #{e.message}") if defined?(Rails.logger)
189
+ log_audit_failure(:trigger_disable, actor, e.message, before_state: before_state)
190
+ raise
129
191
  end
130
192
  end
131
193
 
132
194
  # Update the registry record (always update, even if trigger doesn't exist)
133
195
  begin
134
196
  update!(enabled: false)
197
+ after_state = capture_state
198
+ log_audit_success(:trigger_disable, actor, before_state: before_state, after_state: after_state)
135
199
  rescue ActiveRecord::StatementInvalid, StandardError => e
136
200
  # If update! fails, try update_column which bypasses validations and callbacks
137
201
  # and might not use execute in the same way
@@ -140,6 +204,8 @@ module PgSqlTriggers
140
204
  # rubocop:disable Rails/SkipsModelValidations
141
205
  update_column(:enabled, false)
142
206
  # rubocop:enable Rails/SkipsModelValidations
207
+ after_state = capture_state
208
+ log_audit_success(:trigger_disable, actor, before_state: before_state, after_state: after_state)
143
209
  rescue StandardError => update_error
144
210
  # If update_column also fails, just set the in-memory attribute
145
211
  # The test might reload, but we've done our best
@@ -147,17 +213,30 @@ module PgSqlTriggers
147
213
  Rails.logger.warn("Could not update registry via update_column: #{update_error.message}") if defined?(Rails.logger)
148
214
  # rubocop:enable Layout/LineLength
149
215
  self.enabled = false
216
+ after_state = capture_state
217
+ log_audit_success(:trigger_disable, actor, before_state: before_state, after_state: after_state)
150
218
  end
151
219
  end
152
220
  end
153
221
 
222
+ # Drops this trigger from the database and removes it from the registry.
223
+ #
224
+ # @param reason [String] Required reason for dropping the trigger
225
+ # @param confirmation [String, nil] Optional confirmation text for kill switch protection
226
+ # @param actor [Hash, nil] Information about who is performing the action (must have :type and :id keys)
227
+ # @raise [ArgumentError] If reason is missing or empty
228
+ # @raise [PgSqlTriggers::KillSwitchError] If kill switch blocks the operation
229
+ # @return [true] If drop succeeds
154
230
  def drop!(reason:, confirmation: nil, actor: nil)
231
+ actor ||= { type: "Console", id: "TriggerRegistry#drop!" }
232
+ before_state = capture_state
233
+
155
234
  # Check kill switch before dropping trigger
156
235
  PgSqlTriggers::SQL::KillSwitch.check!(
157
236
  operation: :trigger_drop,
158
237
  environment: Rails.env,
159
238
  confirmation: confirmation,
160
- actor: actor || { type: "Console", id: "TriggerRegistry#drop!" }
239
+ actor: actor
161
240
  )
162
241
 
163
242
  # Validate reason is provided
@@ -170,21 +249,42 @@ module PgSqlTriggers
170
249
  drop_trigger_from_database
171
250
  destroy!
172
251
  log_drop_success
252
+ log_audit_success(:trigger_drop, actor, reason: reason, confirmation_text: confirmation,
253
+ before_state: before_state, after_state: { status: "dropped" })
173
254
  end
255
+ rescue StandardError => e
256
+ log_audit_failure(:trigger_drop, actor, e.message, reason: reason,
257
+ confirmation_text: confirmation, before_state: before_state)
258
+ raise
174
259
  end
175
260
 
261
+ # Re-executes this trigger by dropping and recreating it.
262
+ #
263
+ # @param reason [String] Required reason for re-executing the trigger
264
+ # @param confirmation [String, nil] Optional confirmation text for kill switch protection
265
+ # @param actor [Hash, nil] Information about who is performing the action (must have :type and :id keys)
266
+ # @raise [ArgumentError] If reason is missing or empty, or if function_body is blank
267
+ # @raise [PgSqlTriggers::KillSwitchError] If kill switch blocks the operation
268
+ # @return [PgSqlTriggers::TriggerRegistry] self
176
269
  def re_execute!(reason:, confirmation: nil, actor: nil)
270
+ actor ||= { type: "Console", id: "TriggerRegistry#re_execute!" }
271
+ before_state = capture_state
272
+ drift_info = begin
273
+ drift_result
274
+ rescue StandardError
275
+ nil
276
+ end
277
+
177
278
  # Check kill switch before re-executing trigger
178
279
  PgSqlTriggers::SQL::KillSwitch.check!(
179
280
  operation: :trigger_re_execute,
180
281
  environment: Rails.env,
181
282
  confirmation: confirmation,
182
- actor: actor || { type: "Console", id: "TriggerRegistry#re_execute!" }
283
+ actor: actor
183
284
  )
184
285
 
185
286
  # Validate reason is provided
186
287
  raise ArgumentError, "Reason is required" if reason.nil? || reason.to_s.strip.empty?
187
- raise StandardError, "Cannot re-execute: missing function_body" if function_body.blank?
188
288
 
189
289
  log_re_execute_attempt(reason)
190
290
 
@@ -193,7 +293,18 @@ module PgSqlTriggers
193
293
  drop_existing_trigger_for_re_execute
194
294
  recreate_trigger
195
295
  update_registry_after_re_execute
296
+ after_state = capture_state
297
+ diff = drift_info ? "#{drift_info[:expected_sql]} -> #{after_state[:function_body]}" : nil
298
+ log_audit_success(:trigger_re_execute, actor, reason: reason, confirmation_text: confirmation,
299
+ before_state: before_state, after_state: after_state, diff: diff)
196
300
  end
301
+ rescue StandardError => e
302
+ log_audit_failure(
303
+ :trigger_re_execute, actor, e.message, reason: reason,
304
+ confirmation_text: confirmation,
305
+ before_state: before_state
306
+ )
307
+ raise
197
308
  end
198
309
 
199
310
  private
@@ -209,7 +320,8 @@ module PgSqlTriggers
209
320
  version,
210
321
  function_body || "",
211
322
  condition || "",
212
- timing || "before"
323
+ timing || "before",
324
+ for_each || "row"
213
325
  ].join)
214
326
  end
215
327
 
@@ -281,17 +393,110 @@ module PgSqlTriggers
281
393
  end
282
394
 
283
395
  def recreate_trigger
284
- ActiveRecord::Base.connection.execute(function_body)
285
- Rails.logger.info "[TRIGGER_RE_EXECUTE] Re-created trigger" if defined?(Rails.logger)
396
+ sql = function_body.presence || build_trigger_sql_from_definition
397
+ raise StandardError, "Cannot re-execute: missing function_body" if sql.blank?
398
+
399
+ ActiveRecord::Base.connection.execute(sql)
400
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
401
+ Rails.logger.info "[TRIGGER_RE_EXECUTE] Re-created trigger"
402
+ end
286
403
  rescue ActiveRecord::StatementInvalid, StandardError => e
287
- Rails.logger.error("[TRIGGER_RE_EXECUTE] Failed: #{e.message}") if defined?(Rails.logger)
404
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
405
+ Rails.logger.error("[TRIGGER_RE_EXECUTE] Failed: #{e.message}")
406
+ end
288
407
  raise
289
408
  end
290
409
 
410
+ # Build a CREATE TRIGGER SQL statement from the stored DSL definition JSON.
411
+ # Used by re_execute! when function_body is absent (the normal case for DSL triggers).
412
+ def build_trigger_sql_from_definition
413
+ return nil if definition.blank?
414
+
415
+ defn = JSON.parse(definition)
416
+ fn_name = defn["function_name"]
417
+ return nil if fn_name.blank?
418
+
419
+ t_name = table_name
420
+ timing_kw = (defn["timing"] || timing || "BEFORE").upcase
421
+ events = Array(defn["events"]).map { |e| e.to_s.upcase }.join(" OR ")
422
+ events = "INSERT" if events.blank?
423
+ cond = defn["condition"] || condition
424
+ for_each_kw = (defn["for_each"] || for_each || "row").upcase
425
+
426
+ sql = "CREATE TRIGGER #{quote_identifier(trigger_name)} "
427
+ sql += "#{timing_kw} #{events} ON #{quote_identifier(t_name)} "
428
+ sql += "FOR EACH #{for_each_kw} "
429
+ sql += "WHEN (#{cond}) " if cond.present?
430
+ sql += "EXECUTE FUNCTION #{fn_name}();"
431
+ sql
432
+ rescue JSON::ParserError
433
+ nil
434
+ end
435
+
291
436
  def update_registry_after_re_execute
292
- update!(enabled: true, last_executed_at: Time.current)
437
+ update!(last_executed_at: Time.current)
438
+ if !enabled && ActiveRecord::Base.connection.table_exists?(table_name)
439
+ quoted_table = quote_identifier(table_name)
440
+ quoted_trigger = quote_identifier(trigger_name)
441
+ ActiveRecord::Base.connection.execute(
442
+ "ALTER TABLE #{quoted_table} DISABLE TRIGGER #{quoted_trigger};"
443
+ )
444
+ end
293
445
  Rails.logger.info "[TRIGGER_RE_EXECUTE] Updated registry" if defined?(Rails.logger)
294
446
  end
447
+
448
+ # Audit logging helpers
449
+ def capture_state
450
+ {
451
+ enabled: enabled,
452
+ version: version,
453
+ checksum: checksum,
454
+ table_name: table_name,
455
+ source: source,
456
+ environment: environment,
457
+ installed_at: installed_at&.iso8601,
458
+ function_body: function_body
459
+ }
460
+ end
461
+
462
+ # rubocop:disable Metrics/ParameterLists
463
+ def log_audit_success(operation, actor, reason: nil, confirmation_text: nil,
464
+ before_state: nil, after_state: nil, diff: nil)
465
+ return unless defined?(PgSqlTriggers::AuditLog)
466
+
467
+ PgSqlTriggers::AuditLog.log_success(
468
+ operation: operation,
469
+ trigger_name: trigger_name,
470
+ actor: actor,
471
+ environment: Rails.env,
472
+ reason: reason,
473
+ confirmation_text: confirmation_text,
474
+ before_state: before_state,
475
+ after_state: after_state,
476
+ diff: diff
477
+ )
478
+ rescue StandardError => e
479
+ Rails.logger.error("Failed to log audit entry: #{e.message}") if defined?(Rails.logger)
480
+ end
481
+
482
+ def log_audit_failure(operation, actor, error_message, reason: nil,
483
+ confirmation_text: nil, before_state: nil)
484
+ return unless defined?(PgSqlTriggers::AuditLog)
485
+
486
+ PgSqlTriggers::AuditLog.log_failure(
487
+ operation: operation,
488
+ trigger_name: trigger_name,
489
+ actor: actor,
490
+ environment: Rails.env,
491
+ error_message: error_message,
492
+ reason: reason,
493
+ confirmation_text: confirmation_text,
494
+ before_state: before_state
495
+ )
496
+ rescue StandardError => e
497
+ Rails.logger.error("Failed to log audit entry: #{e.message}") if defined?(Rails.logger)
498
+ end
499
+ # rubocop:enable Metrics/ParameterLists
295
500
  end
296
501
  # rubocop:enable Metrics/ClassLength
297
502
  end
@@ -18,7 +18,7 @@
18
18
  <div>
19
19
  <%= link_to "Dashboard", root_path, style: "color: white; margin-right: 1rem;" %>
20
20
  <%= link_to "Tables", tables_path, style: "color: white; margin-right: 1rem;" %>
21
- <%= link_to "Generator", new_generator_path, style: "color: white;" %>
21
+ <%= link_to "Audit Log", audit_logs_path, style: "color: white;" %>
22
22
  </div>
23
23
  </div>
24
24
  </nav>
@@ -35,7 +35,7 @@
35
35
  <% if flash[:error] %>
36
36
  <div style="background: #f8d7da; color: #721c24; padding: 1rem; margin-bottom: 1rem; border-radius: 4px; border-left: 4px solid #dc3545;">
37
37
  <% error_message = flash[:error].to_s %>
38
- <% if error_message.include?("Kill switch is active") %>
38
+ <% if error_message.include?("Kill switch is active") || error_message.include?("KILL_SWITCH") %>
39
39
  <%# Format kill switch error messages nicely %>
40
40
  <% lines = error_message.split("\n") %>
41
41
 
@@ -55,16 +55,35 @@
55
55
 
56
56
  <%# Instructions section with preserved formatting %>
57
57
  <div style="margin-top: 0.5rem;">
58
- <%# Extract and format the instructions portion %>
59
- <% instructions_start = error_message.index("To override") || 0 %>
60
- <% instructions_text = error_message[instructions_start..-1] %>
58
+ <%# Extract and format the instructions/recovery portion %>
59
+ <% recovery_start = error_message.index("Recovery:") || error_message.index("To override") || 0 %>
60
+ <% recovery_text = error_message[recovery_start..-1] %>
61
+ <% if recovery_text.start_with?("Recovery:") %>
62
+ <% recovery_text = recovery_text.sub("Recovery:", "").strip %>
63
+ <% end %>
61
64
  <div style="white-space: pre-wrap; word-wrap: break-word; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6;">
62
- <%= instructions_text.strip %>
65
+ <%= recovery_text.strip %>
66
+ </div>
67
+ </div>
68
+ <% elsif error_message.include?("Permission denied") || error_message.include?("PERMISSION_DENIED") %>
69
+ <%# Format permission error messages %>
70
+ <div style="display: flex; align-items: flex-start; margin-bottom: 0.5rem;">
71
+ <span style="font-size: 1.5em; margin-right: 0.75rem;">🔒</span>
72
+ <div style="flex: 1;">
73
+ <div style="font-weight: bold; font-size: 1.1em; margin-bottom: 0.5rem;">Permission Denied</div>
74
+ <div style="white-space: pre-wrap; word-wrap: break-word;"><%= error_message %></div>
63
75
  </div>
64
76
  </div>
65
77
  <% else %>
66
78
  <%# Regular error message - preserve formatting %>
67
79
  <div style="white-space: pre-wrap; word-wrap: break-word;"><%= error_message %></div>
80
+ <% if Rails.env.development? && defined?(exception) && exception %>
81
+ <%# Show stack trace in development %>
82
+ <details style="margin-top: 1rem; padding-top: 1rem; border-top: 1px solid #f5c6cb;">
83
+ <summary style="cursor: pointer; font-weight: bold; color: #721c24;">Stack Trace (Development Only)</summary>
84
+ <pre style="background: rgba(0,0,0,0.05); padding: 0.5rem; margin-top: 0.5rem; border-radius: 4px; overflow-x: auto; font-size: 0.85em;"><%= exception.backtrace.first(10).join("\n") %></pre>
85
+ </details>
86
+ <% end %>
68
87
  <% end %>
69
88
  </div>
70
89
  <% end %>