pg_sql_triggers 1.1.1 → 1.3.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 +4 -4
- data/.rubocop.yml +15 -0
- data/CHANGELOG.md +200 -0
- data/COVERAGE.md +45 -34
- data/Goal.md +276 -155
- data/README.md +56 -1
- data/app/assets/javascripts/pg_sql_triggers/trigger_actions.js +50 -0
- data/app/controllers/concerns/pg_sql_triggers/error_handling.rb +56 -0
- data/app/controllers/concerns/pg_sql_triggers/kill_switch_protection.rb +66 -0
- data/app/controllers/concerns/pg_sql_triggers/permission_checking.rb +117 -0
- data/app/controllers/pg_sql_triggers/application_controller.rb +10 -62
- data/app/controllers/pg_sql_triggers/audit_logs_controller.rb +102 -0
- data/app/controllers/pg_sql_triggers/dashboard_controller.rb +6 -1
- data/app/controllers/pg_sql_triggers/migrations_controller.rb +62 -10
- data/app/controllers/pg_sql_triggers/sql_capsules_controller.rb +161 -0
- data/app/controllers/pg_sql_triggers/tables_controller.rb +30 -4
- data/app/controllers/pg_sql_triggers/triggers_controller.rb +147 -0
- data/app/helpers/pg_sql_triggers/permissions_helper.rb +43 -0
- data/app/models/pg_sql_triggers/audit_log.rb +106 -0
- data/app/models/pg_sql_triggers/trigger_registry.rb +297 -5
- data/app/views/layouts/pg_sql_triggers/application.html.erb +26 -6
- data/app/views/pg_sql_triggers/audit_logs/index.html.erb +177 -0
- data/app/views/pg_sql_triggers/dashboard/index.html.erb +65 -2
- data/app/views/pg_sql_triggers/sql_capsules/new.html.erb +81 -0
- data/app/views/pg_sql_triggers/sql_capsules/show.html.erb +85 -0
- data/app/views/pg_sql_triggers/tables/index.html.erb +76 -3
- data/app/views/pg_sql_triggers/tables/show.html.erb +49 -2
- data/app/views/pg_sql_triggers/triggers/_drop_modal.html.erb +138 -0
- data/app/views/pg_sql_triggers/triggers/_re_execute_modal.html.erb +145 -0
- data/app/views/pg_sql_triggers/triggers/show.html.erb +206 -0
- data/config/routes.rb +11 -0
- data/db/migrate/20260103000001_create_pg_sql_triggers_audit_log.rb +28 -0
- data/docs/README.md +15 -5
- data/docs/api-reference.md +443 -4
- data/docs/audit-trail.md +413 -0
- data/docs/configuration.md +6 -6
- data/docs/permissions.md +369 -0
- data/docs/troubleshooting.md +486 -0
- data/docs/ui-guide.md +211 -0
- data/docs/web-ui.md +328 -40
- data/lib/pg_sql_triggers/errors.rb +245 -0
- data/lib/pg_sql_triggers/generator/service.rb +32 -0
- data/lib/pg_sql_triggers/permissions/checker.rb +9 -2
- data/lib/pg_sql_triggers/registry/manager.rb +28 -13
- data/lib/pg_sql_triggers/registry.rb +176 -2
- data/lib/pg_sql_triggers/sql/capsule.rb +79 -0
- data/lib/pg_sql_triggers/sql/executor.rb +200 -0
- data/lib/pg_sql_triggers/sql/kill_switch.rb +33 -5
- data/lib/pg_sql_triggers/testing/function_tester.rb +2 -0
- data/lib/pg_sql_triggers/version.rb +1 -1
- data/lib/pg_sql_triggers.rb +3 -6
- metadata +38 -6
- data/docs/screenshots/.gitkeep +0 -1
- data/docs/screenshots/Generate Trigger.png +0 -0
- data/docs/screenshots/Triggers Page.png +0 -0
- data/docs/screenshots/kill error.png +0 -0
- data/docs/screenshots/kill modal for migration down.png +0 -0
data/README.md
CHANGED
|
@@ -95,11 +95,66 @@ Automatically detect when database triggers drift from your DSL definitions.
|
|
|
95
95
|
Multi-layered safety mechanism preventing accidental destructive operations in production environments.
|
|
96
96
|
|
|
97
97
|
### Web Dashboard
|
|
98
|
-
Visual interface for managing triggers, running migrations, and executing SQL capsules.
|
|
98
|
+
Visual interface for managing triggers, running migrations, and executing SQL capsules. Includes:
|
|
99
|
+
- **Quick Actions**: Enable/disable, drop, and re-execute triggers from dashboard
|
|
100
|
+
- **Last Applied Tracking**: See when triggers were last applied with human-readable timestamps
|
|
101
|
+
- **Breadcrumb Navigation**: Easy navigation between dashboard, tables, and triggers
|
|
102
|
+
- **Permission-Aware UI**: Buttons show/hide based on user role
|
|
103
|
+
|
|
104
|
+
### Audit Logging
|
|
105
|
+
Comprehensive audit trail for all trigger operations:
|
|
106
|
+
- Track who performed each operation (actor tracking)
|
|
107
|
+
- Before and after state capture
|
|
108
|
+
- Success/failure logging with error messages
|
|
109
|
+
- Reason tracking for drop and re-execute operations
|
|
110
|
+
|
|
111
|
+
### SQL Capsules
|
|
112
|
+
Emergency SQL execution feature for critical operations with Admin permission requirements, kill switch protection, and comprehensive logging.
|
|
113
|
+
|
|
114
|
+
### Drop & Re-Execute Flow
|
|
115
|
+
Operational controls for trigger lifecycle management with drop and re-execute capabilities, drift comparison, and required reason logging.
|
|
99
116
|
|
|
100
117
|
### Permissions
|
|
101
118
|
Three-tier permission system (Viewer, Operator, Admin) with customizable authorization.
|
|
102
119
|
|
|
120
|
+
## Console API
|
|
121
|
+
|
|
122
|
+
PgSqlTriggers provides a comprehensive console API for managing triggers programmatically:
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
# Query triggers
|
|
126
|
+
triggers = PgSqlTriggers::Registry.list
|
|
127
|
+
enabled = PgSqlTriggers::Registry.enabled
|
|
128
|
+
disabled = PgSqlTriggers::Registry.disabled
|
|
129
|
+
user_triggers = PgSqlTriggers::Registry.for_table(:users)
|
|
130
|
+
|
|
131
|
+
# Check drift status
|
|
132
|
+
drift_info = PgSqlTriggers::Registry.diff
|
|
133
|
+
drifted = PgSqlTriggers::Registry.drifted
|
|
134
|
+
in_sync = PgSqlTriggers::Registry.in_sync
|
|
135
|
+
unknown = PgSqlTriggers::Registry.unknown_triggers
|
|
136
|
+
dropped = PgSqlTriggers::Registry.dropped
|
|
137
|
+
|
|
138
|
+
# Enable/disable triggers
|
|
139
|
+
PgSqlTriggers::Registry.enable("users_email_validation", actor: current_user, confirmation: "EXECUTE TRIGGER_ENABLE")
|
|
140
|
+
PgSqlTriggers::Registry.disable("users_email_validation", actor: current_user, confirmation: "EXECUTE TRIGGER_DISABLE")
|
|
141
|
+
|
|
142
|
+
# Drop and re-execute triggers
|
|
143
|
+
PgSqlTriggers::Registry.drop("old_trigger", actor: current_user, reason: "No longer needed", confirmation: "EXECUTE TRIGGER_DROP")
|
|
144
|
+
PgSqlTriggers::Registry.re_execute("drifted_trigger", actor: current_user, reason: "Fix drift", confirmation: "EXECUTE TRIGGER_RE_EXECUTE")
|
|
145
|
+
|
|
146
|
+
# Execute SQL capsules
|
|
147
|
+
capsule = PgSqlTriggers::SQL::Capsule.new(
|
|
148
|
+
name: "emergency_fix",
|
|
149
|
+
environment: "production",
|
|
150
|
+
purpose: "Fix critical data issue",
|
|
151
|
+
sql: "UPDATE users SET status = 'active' WHERE id = 123"
|
|
152
|
+
)
|
|
153
|
+
PgSqlTriggers::SQL::Executor.execute(capsule, actor: current_user, confirmation: "EXECUTE SQL")
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
See the [API Reference](docs/api-reference.md) for complete documentation of all console APIs.
|
|
157
|
+
|
|
103
158
|
## Examples
|
|
104
159
|
|
|
105
160
|
For working examples and complete demonstrations, check out the [example repository](https://github.com/samaswin/pg_triggers_example).
|
|
@@ -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
|
-
#
|
|
13
|
-
|
|
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
|
|
@@ -2,8 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
module PgSqlTriggers
|
|
4
4
|
class DashboardController < ApplicationController
|
|
5
|
+
before_action :check_viewer_permission
|
|
6
|
+
|
|
5
7
|
def index
|
|
6
|
-
|
|
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
|
+
)
|
|
7
12
|
|
|
8
13
|
# Get drift summary
|
|
9
14
|
drift_summary = PgSqlTriggers::Drift::Reporter.summary
|
|
@@ -4,6 +4,8 @@ module PgSqlTriggers
|
|
|
4
4
|
# Controller for managing trigger migrations via web UI
|
|
5
5
|
# Provides actions to run migrations up, down, and redo
|
|
6
6
|
class MigrationsController < ApplicationController
|
|
7
|
+
before_action :check_operator_permission
|
|
8
|
+
|
|
7
9
|
def up
|
|
8
10
|
# Check kill switch before running migration
|
|
9
11
|
check_kill_switch(operation: :ui_migration_up, confirmation: params[:confirmation_text])
|
|
@@ -73,18 +75,16 @@ module PgSqlTriggers
|
|
|
73
75
|
target_version = params[:version]&.to_i
|
|
74
76
|
PgSqlTriggers::Migrator.ensure_migrations_table!
|
|
75
77
|
|
|
78
|
+
current_version = PgSqlTriggers::Migrator.current_version
|
|
79
|
+
if current_version.zero?
|
|
80
|
+
flash[:warning] = "No migrations to redo."
|
|
81
|
+
redirect_to root_path
|
|
82
|
+
return
|
|
83
|
+
end
|
|
84
|
+
|
|
76
85
|
if target_version
|
|
77
|
-
|
|
78
|
-
PgSqlTriggers::Migrator.run_up(target_version)
|
|
79
|
-
flash[:success] = "Migration #{target_version} redone successfully."
|
|
86
|
+
redo_target_migration(target_version, current_version)
|
|
80
87
|
else
|
|
81
|
-
current_version = PgSqlTriggers::Migrator.current_version
|
|
82
|
-
if current_version.zero?
|
|
83
|
-
flash[:warning] = "No migrations to redo."
|
|
84
|
-
redirect_to root_path
|
|
85
|
-
return
|
|
86
|
-
end
|
|
87
|
-
|
|
88
88
|
PgSqlTriggers::Migrator.run_down
|
|
89
89
|
PgSqlTriggers::Migrator.run_up
|
|
90
90
|
flash[:success] = "Last migration redone successfully."
|
|
@@ -98,5 +98,57 @@ module PgSqlTriggers
|
|
|
98
98
|
flash[:error] = "Failed to redo migration: #{e.message}"
|
|
99
99
|
redirect_to root_path
|
|
100
100
|
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def check_operator_permission
|
|
105
|
+
return if PgSqlTriggers::Permissions.can?(current_actor, :apply_trigger)
|
|
106
|
+
|
|
107
|
+
redirect_to root_path, alert: "Insufficient permissions. Operator role required."
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def redo_target_migration(target_version, current_version)
|
|
111
|
+
# For redo, we need to rollback the specific migration and re-apply it
|
|
112
|
+
# If target_version is the current version, rollback the last migration
|
|
113
|
+
# Otherwise, rollback to one version before target, then run up to target
|
|
114
|
+
if current_version == target_version
|
|
115
|
+
# Rollback the last migration (which is the target)
|
|
116
|
+
PgSqlTriggers::Migrator.run_down
|
|
117
|
+
elsif current_version > target_version
|
|
118
|
+
rollback_to_before_target(target_version)
|
|
119
|
+
else
|
|
120
|
+
# Target version is not applied yet, just run it up
|
|
121
|
+
PgSqlTriggers::Migrator.run_up(target_version)
|
|
122
|
+
flash[:success] = "Migration #{target_version} redone successfully."
|
|
123
|
+
redirect_to root_path
|
|
124
|
+
return
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Now run up to the target version
|
|
128
|
+
PgSqlTriggers::Migrator.run_up(target_version)
|
|
129
|
+
flash.now[:success] = "Migration #{target_version} redone successfully."
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def rollback_to_before_target(target_version)
|
|
133
|
+
# Rollback to one version before target (this will rollback target_version too)
|
|
134
|
+
# Find the migration just before target_version
|
|
135
|
+
all_migrations = PgSqlTriggers::Migrator.migrations.sort_by(&:version)
|
|
136
|
+
prev_migration = all_migrations.find { |m| m.version < target_version }
|
|
137
|
+
if prev_migration
|
|
138
|
+
# Rollback to the previous migration (this rolls back target_version)
|
|
139
|
+
PgSqlTriggers::Migrator.run_down(prev_migration.version)
|
|
140
|
+
else
|
|
141
|
+
# No previous migration, target_version is the first migration
|
|
142
|
+
# Rollback all migrations until we're below target_version
|
|
143
|
+
rollback_until_below_target(target_version)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def rollback_until_below_target(target_version)
|
|
148
|
+
while PgSqlTriggers::Migrator.current_version >= target_version
|
|
149
|
+
PgSqlTriggers::Migrator.run_down
|
|
150
|
+
break if PgSqlTriggers::Migrator.current_version.zero?
|
|
151
|
+
end
|
|
152
|
+
end
|
|
101
153
|
end
|
|
102
154
|
end
|