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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +397 -1
- data/COVERAGE.md +26 -19
- data/GEM_ANALYSIS.md +368 -0
- data/Goal.md +276 -155
- data/README.md +45 -22
- 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 +4 -9
- data/app/controllers/pg_sql_triggers/tables_controller.rb +30 -4
- data/app/controllers/pg_sql_triggers/triggers_controller.rb +3 -21
- 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 +218 -13
- data/app/views/layouts/pg_sql_triggers/application.html.erb +25 -6
- data/app/views/pg_sql_triggers/audit_logs/index.html.erb +177 -0
- data/app/views/pg_sql_triggers/dashboard/index.html.erb +34 -12
- data/app/views/pg_sql_triggers/tables/index.html.erb +75 -5
- data/app/views/pg_sql_triggers/tables/show.html.erb +17 -6
- data/app/views/pg_sql_triggers/triggers/_drop_modal.html.erb +16 -7
- data/app/views/pg_sql_triggers/triggers/_re_execute_modal.html.erb +16 -7
- data/app/views/pg_sql_triggers/triggers/show.html.erb +26 -6
- data/config/routes.rb +2 -14
- data/db/migrate/20260103000001_create_pg_sql_triggers_audit_log.rb +28 -0
- data/db/migrate/20260228000001_add_for_each_to_pg_sql_triggers_registry.rb +8 -0
- data/docs/README.md +15 -5
- data/docs/api-reference.md +233 -151
- data/docs/audit-trail.md +413 -0
- data/docs/configuration.md +28 -7
- data/docs/getting-started.md +17 -16
- data/docs/permissions.md +369 -0
- data/docs/troubleshooting.md +486 -0
- data/docs/ui-guide.md +211 -0
- data/docs/usage-guide.md +38 -67
- data/docs/web-ui.md +251 -128
- data/lib/generators/pg_sql_triggers/templates/trigger_dsl.rb.tt +11 -0
- data/lib/generators/pg_sql_triggers/templates/trigger_migration_full.rb.tt +29 -0
- data/lib/generators/pg_sql_triggers/trigger_generator.rb +83 -0
- data/lib/pg_sql_triggers/drift/db_queries.rb +12 -8
- data/lib/pg_sql_triggers/drift/detector.rb +51 -38
- data/lib/pg_sql_triggers/dsl/trigger_definition.rb +17 -23
- data/lib/pg_sql_triggers/engine.rb +14 -0
- data/lib/pg_sql_triggers/errors.rb +245 -0
- data/lib/pg_sql_triggers/migrator/pre_apply_comparator.rb +8 -9
- data/lib/pg_sql_triggers/migrator/safety_validator.rb +32 -12
- data/lib/pg_sql_triggers/migrator.rb +53 -6
- data/lib/pg_sql_triggers/permissions/checker.rb +9 -2
- data/lib/pg_sql_triggers/registry/manager.rb +36 -11
- data/lib/pg_sql_triggers/registry/validator.rb +62 -5
- data/lib/pg_sql_triggers/registry.rb +141 -8
- data/lib/pg_sql_triggers/sql/kill_switch.rb +153 -247
- data/lib/pg_sql_triggers/sql.rb +0 -6
- 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 +7 -7
- data/pg_sql_triggers.gemspec +53 -0
- metadata +35 -18
- data/app/controllers/pg_sql_triggers/generator_controller.rb +0 -213
- data/app/controllers/pg_sql_triggers/sql_capsules_controller.rb +0 -161
- data/app/views/pg_sql_triggers/generator/new.html.erb +0 -388
- data/app/views/pg_sql_triggers/generator/preview.html.erb +0 -305
- data/app/views/pg_sql_triggers/sql_capsules/new.html.erb +0 -81
- data/app/views/pg_sql_triggers/sql_capsules/show.html.erb +0 -85
- 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/lib/generators/trigger/migration_generator.rb +0 -60
- data/lib/pg_sql_triggers/generator/form.rb +0 -80
- data/lib/pg_sql_triggers/generator/service.rb +0 -307
- data/lib/pg_sql_triggers/generator.rb +0 -8
- data/lib/pg_sql_triggers/sql/capsule.rb +0 -79
- 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
|
|
55
|
-
|
|
53
|
+
self.version = 1
|
|
54
|
+
self.enabled = true
|
|
55
|
+
timing :before
|
|
56
56
|
end
|
|
57
57
|
```
|
|
58
58
|
|
|
59
|
-
###
|
|
59
|
+
### Generate and Run Migration
|
|
60
60
|
|
|
61
61
|
```bash
|
|
62
|
-
|
|
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
|
|
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
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
- **
|
|
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
|
-
#
|
|
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
|
|
@@ -5,7 +5,10 @@ module PgSqlTriggers
|
|
|
5
5
|
before_action :check_viewer_permission
|
|
6
6
|
|
|
7
7
|
def index
|
|
8
|
-
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
@
|
|
10
|
-
@
|
|
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
|