action-audit 1.0.1

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.
data/docs/usage.md ADDED
@@ -0,0 +1,347 @@
1
+ # Usage Guide
2
+
3
+ This guide covers how to use ActionAudit in your Rails controllers and common usage patterns.
4
+
5
+ ## Basic Usage
6
+
7
+ ### Including ActionAudit
8
+
9
+ Include the `ActionAudit` module in any controller you want to audit:
10
+
11
+ ```ruby
12
+ class UsersController < ApplicationController
13
+ include ActionAudit
14
+
15
+ def create
16
+ @user = User.create!(user_params)
17
+ # ActionAudit automatically logs this action after completion
18
+ end
19
+
20
+ def update
21
+ @user = User.find(params[:id])
22
+ @user.update!(user_params)
23
+ # Will log with interpolated parameters
24
+ end
25
+
26
+ private
27
+
28
+ def user_params
29
+ params.require(:user).permit(:name, :email, :role)
30
+ end
31
+ end
32
+ ```
33
+
34
+ ### How It Works
35
+
36
+ 1. **Automatic Hook**: When you include `ActionAudit`, it adds an `after_action :audit_request` callback
37
+ 2. **Message Lookup**: After each action, it looks up the corresponding message in your `audit.yml` configuration
38
+ 3. **Parameter Interpolation**: It interpolates the message with parameters from `params`
39
+ 4. **Logging**: It logs the final message using `Rails.logger`
40
+
41
+ ## Parameter Interpolation
42
+
43
+ ActionAudit uses Ruby's string interpolation (`%{key}`) to inject parameter values into audit messages.
44
+
45
+ ### Basic Interpolation
46
+
47
+ ```yaml
48
+ # config/audit.yml
49
+ users:
50
+ create: "Created user %{email}"
51
+ update: "Updated user %{id} with name %{name}"
52
+ ```
53
+
54
+ ```ruby
55
+ class UsersController < ApplicationController
56
+ include ActionAudit
57
+
58
+ def create
59
+ # If params = { email: "john@example.com", name: "John Doe" }
60
+ # Will log: "Created user john@example.com"
61
+ end
62
+
63
+ def update
64
+ # If params = { id: "123", name: "Jane Doe" }
65
+ # Will log: "Updated user 123 with name Jane Doe"
66
+ end
67
+ end
68
+ ```
69
+
70
+ ### Nested Parameters
71
+
72
+ ActionAudit automatically flattens nested parameters for interpolation:
73
+
74
+ ```ruby
75
+ # If params = { user: { email: "john@example.com" }, id: "123" }
76
+ # You can reference both %{id} and %{email} in your audit message
77
+ ```
78
+
79
+ ### Error Handling
80
+
81
+ If a parameter referenced in the audit message is missing, ActionAudit handles it gracefully:
82
+
83
+ ```yaml
84
+ users:
85
+ create: "Created user %{email} with role %{role}"
86
+ ```
87
+
88
+ If `params[:role]` is missing, the log will show:
89
+ ```
90
+ Created user john@example.com with role %{role} (interpolation error: key{role} not found)
91
+ ```
92
+
93
+ ## Controller Patterns
94
+
95
+ ### Application-Wide Auditing
96
+
97
+ Include ActionAudit in your `ApplicationController` to audit all controller actions:
98
+
99
+ ```ruby
100
+ class ApplicationController < ActionController::Base
101
+ include ActionAudit
102
+
103
+ # All controllers inheriting from this will be audited
104
+ end
105
+ ```
106
+
107
+ ### Selective Auditing
108
+
109
+ Include ActionAudit only in specific controllers:
110
+
111
+ ```ruby
112
+ class Admin::UsersController < ApplicationController
113
+ include ActionAudit # Only admin actions are audited
114
+ end
115
+
116
+ class PublicController < ApplicationController
117
+ # No auditing for public actions
118
+ end
119
+ ```
120
+
121
+ ### Namespaced Controllers
122
+
123
+ ActionAudit automatically handles namespaced controllers:
124
+
125
+ ```ruby
126
+ class Admin::Users::ProfilesController < ApplicationController
127
+ include ActionAudit
128
+
129
+ def update
130
+ # Will look up: admin/users/profiles/update in audit.yml
131
+ end
132
+ end
133
+ ```
134
+
135
+ ## Common Usage Patterns
136
+
137
+ ### User Management
138
+
139
+ ```yaml
140
+ # config/audit.yml
141
+ admin:
142
+ users:
143
+ create: "Admin created user %{email} with role %{role}"
144
+ update: "Admin updated user %{id}"
145
+ destroy: "Admin deleted user %{id}"
146
+ activate: "Admin activated user %{id}"
147
+ deactivate: "Admin deactivated user %{id}"
148
+ ```
149
+
150
+ ```ruby
151
+ class Admin::UsersController < ApplicationController
152
+ include ActionAudit
153
+
154
+ def create
155
+ @user = User.create!(user_params)
156
+ # Logs: "Admin created user john@example.com with role editor"
157
+ end
158
+
159
+ def activate
160
+ @user = User.find(params[:id])
161
+ @user.update!(active: true)
162
+ # Logs: "Admin activated user 123"
163
+ end
164
+ end
165
+ ```
166
+
167
+ ### Authentication & Sessions
168
+
169
+ ```yaml
170
+ # config/audit.yml
171
+ sessions:
172
+ create: "User logged in with %{email}"
173
+ destroy: "User logged out"
174
+
175
+ passwords:
176
+ create: "Password reset requested for %{email}"
177
+ update: "Password changed for user %{user_id}"
178
+ ```
179
+
180
+ ```ruby
181
+ class SessionsController < ApplicationController
182
+ include ActionAudit
183
+
184
+ def create
185
+ # params[:email] = "user@example.com"
186
+ # Logs: "User logged in with user@example.com"
187
+ end
188
+
189
+ def destroy
190
+ # Logs: "User logged out"
191
+ end
192
+ end
193
+ ```
194
+
195
+ ### API Endpoints
196
+
197
+ ```yaml
198
+ # config/audit.yml
199
+ api:
200
+ v1:
201
+ webhooks:
202
+ create: "Webhook received from %{source} with %{event_type}"
203
+
204
+ users:
205
+ create: "API user created via client %{client_id}"
206
+ update: "API user %{id} updated via client %{client_id}"
207
+ ```
208
+
209
+ ```ruby
210
+ class API::V1::WebhooksController < ApplicationController
211
+ include ActionAudit
212
+
213
+ def create
214
+ # params = { source: "stripe", event_type: "payment.succeeded" }
215
+ # Logs: "Webhook received from stripe with payment.succeeded"
216
+ end
217
+ end
218
+ ```
219
+
220
+ ### Content Management
221
+
222
+ ```yaml
223
+ # config/audit.yml
224
+ posts:
225
+ create: "Created post '%{title}'"
226
+ update: "Updated post %{id}"
227
+ destroy: "Deleted post '%{title}'"
228
+ publish: "Published post '%{title}'"
229
+ unpublish: "Unpublished post '%{title}'"
230
+
231
+ categories:
232
+ create: "Created category '%{name}'"
233
+ update: "Updated category %{id} to '%{name}'"
234
+ destroy: "Deleted category '%{name}'"
235
+ ```
236
+
237
+ ## Advanced Usage
238
+
239
+ ### Custom Parameter Extraction
240
+
241
+ Sometimes you need to log information that's not directly in `params`. You can modify parameters before the audit:
242
+
243
+ ```ruby
244
+ class PostsController < ApplicationController
245
+ include ActionAudit
246
+
247
+ before_action :set_audit_params, only: [:publish, :unpublish]
248
+
249
+ def publish
250
+ @post = Post.find(params[:id])
251
+ @post.update!(published: true)
252
+ # Will use the custom title parameter we set
253
+ end
254
+
255
+ private
256
+
257
+ def set_audit_params
258
+ @post = Post.find(params[:id])
259
+ params[:title] = @post.title # Add title to params for auditing
260
+ end
261
+ end
262
+ ```
263
+
264
+ ### Conditional Auditing
265
+
266
+ You can conditionally include ActionAudit or skip certain actions:
267
+
268
+ ```ruby
269
+ class UsersController < ApplicationController
270
+ include ActionAudit
271
+
272
+ # Skip auditing for certain actions
273
+ skip_after_action :audit_request, only: [:show, :index]
274
+
275
+ # Or use conditional logic
276
+ def sensitive_action
277
+ # Custom auditing logic here if needed
278
+ end
279
+ end
280
+ ```
281
+
282
+ ### Integration with Current User
283
+
284
+ ActionAudit works well with authentication systems. The custom formatter can access `current_user`:
285
+
286
+ ```ruby
287
+ # config/initializers/action_audit.rb
288
+ ActionAudit.log_formatter = lambda do |controller, action, message|
289
+ if defined?(current_user) && current_user
290
+ "#{message} (by #{current_user.email})"
291
+ else
292
+ "#{message} (by anonymous user)"
293
+ end
294
+ end
295
+ ```
296
+
297
+ ## Testing
298
+
299
+ ### Testing Audit Messages
300
+
301
+ You can test that audit messages are being logged correctly:
302
+
303
+ ```ruby
304
+ # spec/controllers/users_controller_spec.rb
305
+ RSpec.describe UsersController, type: :controller do
306
+ describe "#create" do
307
+ it "logs user creation" do
308
+ expect(Rails.logger).to receive(:info).with(/Created user.*john@example\.com/)
309
+
310
+ post :create, params: { user: { email: "john@example.com" } }
311
+ end
312
+ end
313
+ end
314
+ ```
315
+
316
+ ### Testing Without Auditing
317
+
318
+ In tests where you don't want audit logging, you can stub it:
319
+
320
+ ```ruby
321
+ before do
322
+ allow(controller).to receive(:audit_request)
323
+ end
324
+ ```
325
+
326
+ ## Performance Considerations
327
+
328
+ ActionAudit is designed to be lightweight:
329
+
330
+ - **Minimal Overhead**: Only runs after successful actions
331
+ - **Lazy Loading**: Audit messages are loaded once at startup
332
+ - **No Database Calls**: All auditing happens through Rails logger
333
+ - **Graceful Failures**: Missing messages or parameters won't break your application
334
+
335
+ ## Error Handling
336
+
337
+ ActionAudit handles errors gracefully:
338
+
339
+ 1. **Missing Messages**: If no audit message is configured for an action, nothing is logged
340
+ 2. **Missing Parameters**: If interpolation fails, the error is logged alongside the original message
341
+ 3. **Invalid YAML**: Rails will warn about YAML syntax errors during loading
342
+
343
+ ## Next Steps
344
+
345
+ - [Learn about multi-engine setup](multi-engine.md)
346
+ - [See real-world examples](examples.md)
347
+ - [Check the API reference](api-reference.md)
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "action_audit"
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module ActionAudit
6
+ class AuditMessages
7
+ class << self
8
+ def messages
9
+ @messages ||= {}
10
+ end
11
+
12
+ def load_from_file(file_path)
13
+ return unless File.exist?(file_path)
14
+
15
+ content = YAML.load_file(file_path)
16
+ return unless content.is_a?(Hash)
17
+
18
+ messages.deep_merge!(content)
19
+ end
20
+
21
+ def load_from_engines
22
+ # Load from all Rails engines and the main app
23
+ if defined?(Rails) && Rails.application
24
+ # Load from main application
25
+ main_audit_file = Rails.root.join("config", "audit.yml")
26
+ load_from_file(main_audit_file) if File.exist?(main_audit_file)
27
+
28
+ # Load from all engines
29
+ Rails.application.railties.each do |railtie|
30
+ next unless railtie.respond_to?(:root)
31
+
32
+ engine_audit_file = railtie.root.join("config", "audit.yml")
33
+ load_from_file(engine_audit_file) if File.exist?(engine_audit_file)
34
+ end
35
+ end
36
+ end
37
+
38
+ def lookup(controller_path, action_name)
39
+ # Convert controller path to nested hash lookup
40
+ # e.g., "manage/accounts" becomes ["manage", "accounts"]
41
+ path_parts = controller_path.split("/")
42
+
43
+ # Navigate through nested hash structure
44
+ current_level = messages
45
+ path_parts.each do |part|
46
+ current_level = current_level[part]
47
+ return nil unless current_level.is_a?(Hash)
48
+ end
49
+
50
+ # Look up the action
51
+ current_level[action_name]
52
+ end
53
+
54
+ def clear!
55
+ @messages = {}
56
+ end
57
+
58
+ def add_message(controller_path, action_name, message)
59
+ path_parts = controller_path.split("/")
60
+
61
+ # Navigate/create nested structure
62
+ current_level = messages
63
+ path_parts.each do |part|
64
+ current_level[part] ||= {}
65
+ current_level = current_level[part]
66
+ end
67
+
68
+ # Set the message
69
+ current_level[action_name] = message
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAudit
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace ActionAudit
6
+
7
+ initializer "action_audit.load_audit_messages", after: :load_config_initializers do
8
+ ActionAudit::AuditMessages.load_from_engines
9
+ end
10
+
11
+ # Reload audit messages in development when files change
12
+ config.to_prepare do
13
+ if Rails.env.development?
14
+ ActionAudit::AuditMessages.clear!
15
+ ActionAudit::AuditMessages.load_from_engines
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAudit
4
+ VERSION = "1.0.1"
5
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_support/concern"
5
+ require "rails"
6
+
7
+ require_relative "action_audit/version"
8
+ require_relative "action_audit/audit_messages"
9
+ require_relative "action_audit/engine" if defined?(Rails)
10
+
11
+ module ActionAudit
12
+ class Error < StandardError; end
13
+
14
+ # Configuration attributes
15
+ mattr_accessor :log_formatter, default: nil
16
+ mattr_accessor :log_tag, default: nil
17
+
18
+ extend ActiveSupport::Concern
19
+
20
+ included do
21
+ after_action :audit_request
22
+ end
23
+
24
+ private
25
+
26
+ def audit_request
27
+ controller_path = self.class.name.underscore.gsub("_controller", "")
28
+ action_name = action_name()
29
+
30
+ # Look up the audit message
31
+ message = ActionAudit::AuditMessages.lookup(controller_path, action_name)
32
+ return unless message
33
+
34
+ # Interpolate the message with params
35
+ interpolated_message = interpolate_message(message, params)
36
+
37
+ # Format the log entry
38
+ formatted_message = if ActionAudit.log_formatter
39
+ ActionAudit.log_formatter.call(controller_path, action_name, interpolated_message, params)
40
+ else
41
+ "#{controller_path}/#{action_name} - #{interpolated_message}"
42
+ end
43
+
44
+ # Log with optional tag
45
+ if ActionAudit.log_tag
46
+ Rails.logger.tagged(ActionAudit.log_tag) do
47
+ Rails.logger.info(formatted_message)
48
+ end
49
+ else
50
+ Rails.logger.info(formatted_message)
51
+ end
52
+ end
53
+
54
+ def interpolate_message(message, interpolation_params)
55
+ return "" if message.nil?
56
+ return message.to_s unless message.respond_to?(:%)
57
+
58
+ # Convert params to hash with symbol keys for interpolation
59
+ unsafe_hash = interpolation_params.to_unsafe_h
60
+ string_params = unsafe_hash.respond_to?(:deep_stringify_keys) ? unsafe_hash.deep_stringify_keys : unsafe_hash
61
+
62
+ # Convert to symbol keys for string interpolation
63
+ symbol_params = {}
64
+ string_params.each { |k, v| symbol_params[k.to_sym] = v }
65
+
66
+ # Perform string interpolation
67
+ message % symbol_params
68
+ rescue KeyError => e
69
+ # If interpolation fails, log the original message with error info
70
+ "#{message} (interpolation error: #{e.message})"
71
+ rescue TypeError
72
+ # If message is not a string or doesn't support %, return as-is
73
+ message.to_s
74
+ end
75
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module ActionAudit
6
+ class InstallGenerator < Rails::Generators::Base
7
+ desc "Install ActionAudit configuration"
8
+
9
+ def self.source_root
10
+ @source_root ||= File.expand_path("templates", __dir__)
11
+ end
12
+
13
+ def copy_audit_config
14
+ template "audit.yml", "config/audit.yml"
15
+ end
16
+
17
+ def create_initializer
18
+ template "action_audit.rb", "config/initializers/action_audit.rb"
19
+ end
20
+
21
+ def show_readme
22
+ readme "README"
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,25 @@
1
+ ===============================================================================
2
+
3
+ ActionAudit has been installed!
4
+
5
+ Next steps:
6
+
7
+ 1. Include ActionAudit in your controllers:
8
+
9
+ class ApplicationController < ActionController::Base
10
+ include ActionAudit
11
+ end
12
+
13
+ Or include it in specific controllers:
14
+
15
+ class Admin::UsersController < ApplicationController
16
+ include ActionAudit
17
+ end
18
+
19
+ 2. Edit config/audit.yml to define your audit messages
20
+
21
+ 3. Customize logging in config/initializers/action_audit.rb (optional)
22
+
23
+ 4. Test your setup by triggering a controller action and checking your logs
24
+
25
+ ===============================================================================
@@ -0,0 +1,17 @@
1
+ # ActionAudit configuration
2
+ # Customize how audit logs are formatted and tagged
3
+
4
+ # Add a tag to all audit log entries
5
+ # ActionAudit.log_tag = "AUDIT"
6
+
7
+ # Customize the log message format
8
+ # The formatter receives (controller_path, action_name, interpolated_message)
9
+ # ActionAudit.log_formatter = lambda do |controller, action, message|
10
+ # user_info = defined?(current_user) && current_user ? "User: #{current_user.email}" : "User: anonymous"
11
+ # "[#{Time.current.iso8601}] #{controller}/#{action} | #{message} | #{user_info}"
12
+ # end
13
+
14
+ # Simple formatter example:
15
+ # ActionAudit.log_formatter = ->(controller, action, msg) do
16
+ # "AUDIT: #{controller}/#{action} - #{msg}"
17
+ # end
@@ -0,0 +1,30 @@
1
+ # ActionAudit configuration file
2
+ # This file defines audit messages for your controllers
3
+ # Organize by controller namespace and action
4
+
5
+ # Example structure:
6
+ # namespace:
7
+ # controller:
8
+ # action: "Message with %{param} interpolation"
9
+
10
+ # Example audit messages
11
+ # Uncomment and customize as needed
12
+
13
+ # admin:
14
+ # users:
15
+ # create: "Created user %{email}"
16
+ # update: "Updated user %{id}"
17
+ # destroy: "Deleted user %{id}"
18
+ # accounts:
19
+ # create: "Created account %{name}"
20
+ # update: "Updated account %{id}"
21
+
22
+ # sessions:
23
+ # create: "User logged in with %{email}"
24
+ # destroy: "User logged out"
25
+
26
+ # posts:
27
+ # create: "Created post '%{title}'"
28
+ # update: "Updated post %{id}"
29
+ # publish: "Published post %{id}"
30
+ # unpublish: "Unpublished post %{id}"
@@ -0,0 +1,4 @@
1
+ module ActionAudit
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end