whodunit 0.1.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.
data/README.md ADDED
@@ -0,0 +1,376 @@
1
+ # Whodunit
2
+
3
+ Lightweight creator/updater/deleter tracking for Rails ActiveRecord models.
4
+
5
+ > **Fun Fact**: The term "whodunit" was coined by literary critic Donald Gordon in 1930 when reviewing a murder mystery novel for _American News of Books_. He described Milward Kennedy's _Half Mast Murder_ as "a satisfactory whodunit" - the first recorded use of this now-famous term for mystery stories! _([Source: Wikipedia](https://en.wikipedia.org/wiki/Whodunit))_
6
+
7
+ ## Overview
8
+
9
+ Whodunit provides simple auditing for Rails applications by tracking who created, updated, and deleted records. Unlike heavyweight solutions like PaperTrail or Audited, Whodunit focuses solely on user tracking with zero performance overhead.
10
+
11
+ ## Requirements
12
+
13
+ - Ruby 3.1.1+ (tested on 3.1.1, 3.2.0, 3.3.0, 3.4). See the [the ruby-version matrix strategy of the CI workflow](https://github.com/kanutocd/whodunit/blob/main/.github/workflows/ci.yml#L15).
14
+ - Rails 7.2+ (tested on 7.2, 8.2, and edge)
15
+ - ActiveRecord for database operations
16
+
17
+ ## Features
18
+
19
+ - **Lightweight**: Only tracks user IDs, no change history or versioning
20
+ - **Smart Soft-Delete Detection**: Automatically detects Discard, Paranoia, and custom soft-delete implementations
21
+ - **Thread-Safe**: Uses Rails `CurrentAttributes` pattern for user context
22
+ - **Zero Dependencies**: Only requires Rails 7.2+
23
+ - **Performance Focused**: No default scopes or method overrides
24
+
25
+ ## Installation
26
+
27
+ Add this line to your application's Gemfile:
28
+
29
+ ```ruby
30
+ gem 'whodunit'
31
+ ```
32
+
33
+ And then execute:
34
+
35
+ $ bundle install
36
+
37
+ ## Quick Start
38
+
39
+ ### 1. Add Stamp Columns
40
+
41
+ Generate a migration to add the tracking columns:
42
+
43
+ ```ruby
44
+ class AddStampsToUsers < ActiveRecord::Migration[7.0]
45
+ def change
46
+ add_whodunit_stamps :users # Adds creator_id, updater_id columns
47
+ end
48
+ end
49
+ ```
50
+
51
+ For models with soft-delete, deleter tracking is automatically detected:
52
+
53
+ ```ruby
54
+ class AddStampsToDocuments < ActiveRecord::Migration[7.0]
55
+ def change
56
+ add_whodunit_stamps :documents # Adds creator_id, updater_id, deleter_id (if soft-delete detected)
57
+ end
58
+ end
59
+ ```
60
+
61
+ ### 2. Include Stampable in Models
62
+
63
+ ```ruby
64
+ class User < ApplicationRecord
65
+ include Whodunit::Stampable
66
+ end
67
+
68
+ class Document < ApplicationRecord
69
+ include Discard::Model # or acts_as_paranoid, etc.
70
+ include Whodunit::Stampable # Automatically detects soft-delete!
71
+ end
72
+ ```
73
+
74
+ ### 3. Set Up Controller Integration
75
+
76
+ ```ruby
77
+ class ApplicationController < ActionController::Base
78
+ # Whodunit::ControllerMethods is automatically included via Railtie
79
+ # It will automatically set the current user for stamping
80
+ end
81
+ ```
82
+
83
+ ## Usage
84
+
85
+ Once set up, stamping happens automatically:
86
+
87
+ ```ruby
88
+ # Creating records
89
+ user = User.create!(name: "Ken")
90
+ # => Sets user.creator_id to current_user.id
91
+
92
+ # Updating records
93
+ user.update!(name: "Sophia")
94
+ # => Sets user.updater_id to current_user.id
95
+
96
+ # Soft deleting (if soft-delete gem is detected)
97
+ document.discard
98
+ # => Sets document.deleter_id to current_user.id
99
+ ```
100
+
101
+ Access the stamp information via associations:
102
+
103
+ ```ruby
104
+ user.creator # => User who created this record
105
+ user.updater # => User who last updated this record
106
+ user.deleter # => User who deleted this record (if soft-delete enabled)
107
+ ```
108
+
109
+ ## Smart Soft-Delete Detection
110
+
111
+ Whodunit automatically detects popular soft-delete solutions:
112
+
113
+ - **Discard** (`gem 'discard'`)
114
+ - **Paranoia** (`gem 'paranoia'`)
115
+ - **ActsAsParanoid** (`gem 'acts_as_paranoid'`)
116
+ - **Custom implementations** with timestamp columns like `deleted_at`, `discarded_at`, etc.
117
+
118
+ ## Configuration
119
+
120
+ ```ruby
121
+ # config/initializers/whodunit.rb
122
+ Whodunit.configure do |config|
123
+ config.user_class = 'Account' # Default: 'User'
124
+ config.creator_column = :created_by_id # Default: :creator_id
125
+ config.updater_column = :updated_by_id # Default: :updater_id
126
+ config.deleter_column = :deleted_by_id # Default: :deleter_id
127
+ config.auto_detect_soft_delete = false # Default: true
128
+
129
+ # Column data type configuration
130
+ config.column_data_type = :integer # Default: :bigint (applies to all columns)
131
+ config.creator_column_type = :string # Default: nil (uses column_data_type)
132
+ config.updater_column_type = :uuid # Default: nil (uses column_data_type)
133
+ config.deleter_column_type = :integer # Default: nil (uses column_data_type)
134
+ end
135
+ ```
136
+
137
+ ### Data Type Configuration
138
+
139
+ By default, all stamp columns use `:bigint` data type. You can customize this in several ways:
140
+
141
+ - **Global**: Set `column_data_type` to change the default for all columns
142
+ - **Individual**: Set specific column types to override the global default
143
+ - **Per-migration**: Override types on a per-migration basis (see Migration Helpers)
144
+
145
+ ## Manual User Setting
146
+
147
+ For background jobs, tests, or special scenarios:
148
+
149
+ ```ruby
150
+ # Temporarily set user
151
+ Whodunit::Current.user = User.find(123)
152
+ MyModel.create!(name: "test") # Will be stamped with user 123
153
+
154
+ # Within a block
155
+ controller.with_whodunit_user(admin_user) do
156
+ Document.create!(title: "Admin Document")
157
+ end
158
+
159
+ # Disable stamping temporarily
160
+ controller.without_whodunit_user do
161
+ Document.create!(title: "System Document") # No stamps
162
+ end
163
+ ```
164
+
165
+ ## Migration Helpers
166
+
167
+ ```ruby
168
+ # Basic usage (uses configured data types)
169
+ class CreatePosts < ActiveRecord::Migration[7.0]
170
+ def change
171
+ create_table :posts do |t|
172
+ t.string :title
173
+ t.whodunit_stamps # Adds creator_id, updater_id with configured types
174
+ t.timestamps
175
+ end
176
+ end
177
+ end
178
+
179
+ # Custom data types per migration
180
+ class CreateUsers < ActiveRecord::Migration[7.0]
181
+ def change
182
+ create_table :users do |t|
183
+ t.string :email
184
+ t.whodunit_stamps include_deleter: true,
185
+ creator_type: :uuid,
186
+ updater_type: :string,
187
+ deleter_type: :integer
188
+ t.timestamps
189
+ end
190
+ end
191
+ end
192
+
193
+ # Add to existing table with custom types
194
+ class AddStampsToExistingTable < ActiveRecord::Migration[7.0]
195
+ def change
196
+ add_whodunit_stamps :existing_table,
197
+ include_deleter: :auto,
198
+ creator_type: :string,
199
+ updater_type: :uuid
200
+ end
201
+ end
202
+
203
+ # Mixed approach - some custom, some default
204
+ class CreateDocuments < ActiveRecord::Migration[7.0]
205
+ def change
206
+ create_table :documents do |t|
207
+ t.string :title
208
+ t.whodunit_stamps creator_type: :uuid # Only override creator, others use defaults
209
+ t.timestamps
210
+ end
211
+ end
212
+ end
213
+ ```
214
+
215
+ ### Data Type Options
216
+
217
+ Common data types you can use:
218
+
219
+ - `:bigint` (default) - 64-bit integer, suitable for large user bases
220
+ - `:integer` - 32-bit integer, suitable for smaller applications
221
+ - `:string` - For string-based user identifiers
222
+ - `:uuid` - For UUID-based user systems
223
+ - Any other Rails column type
224
+
225
+ ## Controller Methods
226
+
227
+ Skip stamping for specific actions:
228
+
229
+ ```ruby
230
+ class ApiController < ApplicationController
231
+ skip_whodunit_for :index, :show
232
+ end
233
+ ```
234
+
235
+ Only stamp specific actions:
236
+
237
+ ```ruby
238
+ class ReadOnlyController < ApplicationController
239
+ whodunit_only_for :create, :update, :destroy
240
+ end
241
+ ```
242
+
243
+ ## Thread Safety
244
+
245
+ Whodunit uses Rails `CurrentAttributes` for thread-safe user context:
246
+
247
+ ```ruby
248
+ # Each thread maintains its own user context
249
+ Thread.new { Whodunit::Current.user = user1; create_records }
250
+ Thread.new { Whodunit::Current.user = user2; create_records }
251
+ ```
252
+
253
+ ## Testing
254
+
255
+ In your tests, you can set the user context:
256
+
257
+ ```ruby
258
+ # RSpec
259
+ before do
260
+ Whodunit::Current.user = create(:user)
261
+ end
262
+
263
+ # Or within specific tests
264
+ it "tracks creator" do
265
+ user = create(:user)
266
+ Whodunit::Current.user = user
267
+
268
+ post = create(:post)
269
+ expect(post.creator).to eq(user)
270
+ end
271
+ ```
272
+
273
+ ## Comparisons
274
+
275
+ | Feature | Whodunit | PaperTrail | Audited |
276
+ | --------------------- | -------- | ---------- | ------- |
277
+ | User tracking | ✅ | ✅ | ✅ |
278
+ | Change history | ❌ | ✅ | ✅ |
279
+ | Performance overhead | None | High | Medium |
280
+ | Soft-delete detection | ✅ | ❌ | ❌ |
281
+ | Setup complexity | Low | Medium | Medium |
282
+
283
+ ## Documentation
284
+
285
+ Complete API documentation is available at: **[https://kanutocd.github.io/whodunit](https://kanutocd.github.io/whodunit)**
286
+
287
+ The documentation includes:
288
+
289
+ - Comprehensive API reference with examples
290
+ - Configuration options and their defaults
291
+ - Migration helper methods
292
+ - Controller integration patterns
293
+ - Advanced usage scenarios
294
+
295
+ To generate documentation locally:
296
+
297
+ ```bash
298
+ bundle exec yard doc
299
+ open doc/index.html
300
+ ```
301
+
302
+ ## Development
303
+
304
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `bundle exec rspec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
305
+
306
+ To install this gem onto your local machine, run `bundle exec gem build whodunit.gemspec && gem install ./whodunit-*.gem`.
307
+
308
+ ### Testing
309
+
310
+ ```bash
311
+ # Run all tests
312
+ bundle exec rspec
313
+
314
+ # Run tests with coverage
315
+ COVERAGE=true bundle exec rspec
316
+
317
+ # Run RuboCop
318
+ bundle exec rubocop
319
+
320
+ # Run security audit
321
+ bundle exec bundle audit check --update
322
+
323
+ # Generate documentation
324
+ bundle exec yard doc
325
+ ```
326
+
327
+ ### Release Process
328
+
329
+ The gem uses automated CI/CD workflows:
330
+
331
+ - **CI**: Automatically runs tests, linting, and security checks on every push and PR
332
+ - **Release**: Supports both automatic releases (on GitHub release creation) and manual releases via workflow dispatch
333
+ - **Documentation**: Automatically deploys API documentation to GitHub Pages
334
+
335
+ To perform a release:
336
+
337
+ 1. **Dry Run**: Test the release process without publishing
338
+
339
+ ```bash
340
+ # Via GitHub Actions UI: Run "Release" workflow with dry_run=true
341
+ ```
342
+
343
+ 2. **Create Release**:
344
+
345
+ ```bash
346
+ # Update version in lib/whodunit/version.rb
347
+ # Commit and push changes
348
+ # Create a GitHub release via UI or CLI
349
+ gh release create v0.1.0 --title "Release v0.1.0" --notes "Release notes here"
350
+ ```
351
+
352
+ 3. **Manual Release** (if needed):
353
+ ```bash
354
+ # Via GitHub Actions UI: Run "Release" workflow with dry_run=false
355
+ ```
356
+
357
+ ## Contributing
358
+
359
+ Bug reports and pull requests are welcome on GitHub at https://github.com/kanutocd/whodunit.
360
+
361
+ ### Development Workflow
362
+
363
+ 1. Fork the repository
364
+ 2. Create a feature branch (`git checkout -b feature/amazing-feature`)
365
+ 3. Make your changes
366
+ 4. Add tests for new functionality
367
+ 5. Ensure all tests pass (`bundle exec rspec`)
368
+ 6. Run RuboCop and fix any style issues (`bundle exec rubocop`)
369
+ 7. Update documentation if needed
370
+ 8. Commit your changes (`git commit -am 'Add amazing feature'`)
371
+ 9. Push to the branch (`git push origin feature/amazing-feature`)
372
+ 10. Open a Pull Request
373
+
374
+ ## License
375
+
376
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ # Load YARD tasks
9
+ begin
10
+ require "yard"
11
+ YARD::Rake::YardocTask.new do |t|
12
+ t.files = ["lib/**/*.rb"]
13
+ t.options = %w[--output-dir doc --markup markdown]
14
+ end
15
+ rescue LoadError
16
+ # YARD not available
17
+ end
18
+
19
+ task default: :spec
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ # Rails 7.2 testing
6
+ gem "activerecord", "~> 7.2.0"
7
+ gem "activesupport", "~> 7.2.0"
8
+
9
+ # Specify your gem's dependencies in whodunit.gemspec
10
+ gemspec path: "../"
11
+
12
+ gem "irb"
13
+ gem "rake", "~> 13.0"
14
+
15
+ gem "rspec", "~> 3.0"
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ # Rails 8.0.2 testing
6
+ gem "activerecord", "~> 8.0.0"
7
+ gem "activesupport", "~> 8.0.0"
8
+
9
+ # Specify your gem's dependencies in whodunit.gemspec
10
+ gemspec path: "../"
11
+
12
+ gem "irb"
13
+ gem "rake", "~> 13.0"
14
+
15
+ gem "rspec", "~> 3.0"
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ # Rails edge testing
6
+ gem "activerecord", git: "https://github.com/rails/rails.git"
7
+ gem "activesupport", git: "https://github.com/rails/rails.git"
8
+
9
+ # Specify your gem's dependencies in whodunit.gemspec
10
+ gemspec path: "../"
11
+
12
+ gem "irb"
13
+ gem "rake", "~> 13.0"
14
+
15
+ gem "rspec", "~> 3.0"
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whodunit
4
+ # Controller integration for automatic user setting.
5
+ #
6
+ # This module provides seamless integration with Rails controllers by automatically
7
+ # setting and resetting the current user context for auditing purposes. It includes
8
+ # methods for manual user management and supports various user detection patterns.
9
+ #
10
+ # @example Automatic integration (via Railtie)
11
+ # # This module is automatically included in ActionController::Base
12
+ # class PostsController < ApplicationController
13
+ # # Whodunit automatically tracks current_user for all actions
14
+ # def create
15
+ # @post = Post.create(params[:post])
16
+ # # @post.creator_id is automatically set to current_user.id
17
+ # end
18
+ # end
19
+ #
20
+ # @example Manual user setting
21
+ # class PostsController < ApplicationController
22
+ # def admin_action
23
+ # with_whodunit_user(User.admin) do
24
+ # # Actions here will be tracked as performed by admin user
25
+ # Post.create(title: "Admin Post")
26
+ # end
27
+ # end
28
+ # end
29
+ #
30
+ # @example Skipping whodunit for specific actions
31
+ # class PostsController < ApplicationController
32
+ # skip_whodunit_for :index, :show
33
+ # # or
34
+ # whodunit_only_for :create, :update, :destroy
35
+ # end
36
+ #
37
+ # @since 0.1.0
38
+ module ControllerMethods
39
+ extend ActiveSupport::Concern
40
+
41
+ included do
42
+ # Set up callbacks only for controllers that have user authentication
43
+ # Only if Rails controller methods are available
44
+ if respond_to?(:before_action) && respond_to?(:after_action)
45
+ before_action :set_whodunit_user, if: :whodunit_user_available?
46
+ after_action :reset_whodunit_user
47
+ end
48
+ end
49
+
50
+ # Set the current user for whodunit tracking.
51
+ #
52
+ # This method is automatically called as a before_action in controllers.
53
+ # Can also be called manually to set a specific user context.
54
+ #
55
+ # @param user [Object, nil] the user object or user ID to set
56
+ # If nil, attempts to detect user via current_whodunit_user
57
+ # @return [void]
58
+ # @example Manual usage
59
+ # set_whodunit_user(User.find(123))
60
+ # set_whodunit_user(nil) # Uses current_whodunit_user detection
61
+ def set_whodunit_user(user = nil)
62
+ user ||= current_whodunit_user
63
+ Whodunit::Current.user = user
64
+ end
65
+
66
+ # Reset the current user context.
67
+ #
68
+ # This method is automatically called as an after_action in controllers
69
+ # to ensure proper cleanup between requests.
70
+ #
71
+ # @return [void]
72
+ def reset_whodunit_user
73
+ Whodunit::Current.reset
74
+ end
75
+
76
+ # Detect the current user for auditing.
77
+ #
78
+ # This method tries multiple strategies to find the current user:
79
+ # 1. current_user method (most common Rails pattern)
80
+ # 2. @current_user instance variable
81
+ # 3. Other common authentication method names
82
+ #
83
+ # Override this method in your controller to customize user detection.
84
+ #
85
+ # @return [Object, nil] the current user object or nil if none found
86
+ # @example Custom user detection
87
+ # def current_whodunit_user
88
+ # # Custom logic for finding user
89
+ # session[:admin_user] || super
90
+ # end
91
+ def current_whodunit_user
92
+ return current_user if respond_to?(:current_user) && current_user
93
+ return @current_user if defined?(@current_user) && @current_user
94
+
95
+ # Try other common patterns
96
+ user_methods = %i[
97
+ current_user current_account current_admin
98
+ signed_in_user authenticated_user
99
+ ]
100
+
101
+ user_methods.each do |method|
102
+ return send(method) if respond_to?(method, true) && send(method)
103
+ end
104
+
105
+ nil
106
+ end
107
+
108
+ # Check if a user is available for stamping.
109
+ #
110
+ # Used internally by callbacks to determine if user tracking should occur.
111
+ #
112
+ # @return [Boolean] true if a user is available
113
+ def whodunit_user_available?
114
+ current_whodunit_user.present?
115
+ end
116
+
117
+ # Temporarily set a different user for a block of code.
118
+ #
119
+ # Useful for admin actions or background jobs that need to run
120
+ # as a specific user. Automatically restores the previous user context.
121
+ #
122
+ # @param user [Object] the user object to set temporarily
123
+ # @yield the block to execute with the temporary user
124
+ # @return [Object] the return value of the block
125
+ # @example
126
+ # with_whodunit_user(admin_user) do
127
+ # Post.create(title: "Admin post")
128
+ # end
129
+ def with_whodunit_user(user)
130
+ previous_user = Whodunit::Current.user
131
+ begin
132
+ Whodunit::Current.user = user
133
+ yield
134
+ ensure
135
+ Whodunit::Current.user = previous_user
136
+ end
137
+ end
138
+
139
+ # Temporarily disable user tracking for a block of code.
140
+ #
141
+ # Useful for system operations or bulk imports where user tracking
142
+ # is not desired.
143
+ #
144
+ # @yield the block to execute without user tracking
145
+ # @return [Object] the return value of the block
146
+ # @example
147
+ # without_whodunit_user do
148
+ # Post.bulk_import(data) # No creator_id will be set
149
+ # end
150
+ def without_whodunit_user(&block)
151
+ with_whodunit_user(nil, &block)
152
+ end
153
+
154
+ class_methods do
155
+ # Skip whodunit tracking for specific controller actions.
156
+ #
157
+ # Disables automatic user tracking for the specified actions.
158
+ # Useful for read-only actions where auditing is not needed.
159
+ #
160
+ # @param actions [Array<Symbol>] the action names to skip
161
+ # @return [void]
162
+ # @example
163
+ # class PostsController < ApplicationController
164
+ # skip_whodunit_for :index, :show
165
+ # end
166
+ def skip_whodunit_for(*actions)
167
+ return unless respond_to?(:skip_before_action) && respond_to?(:skip_after_action)
168
+
169
+ skip_before_action :set_whodunit_user, only: actions
170
+ skip_after_action :reset_whodunit_user, only: actions
171
+ end
172
+
173
+ # Enable whodunit tracking only for specific controller actions.
174
+ #
175
+ # Disables automatic tracking for all actions, then re-enables it
176
+ # only for the specified actions. Useful for controllers where
177
+ # only certain actions need auditing.
178
+ #
179
+ # @param actions [Array<Symbol>] the action names to enable tracking for
180
+ # @return [void]
181
+ # @example
182
+ # class PostsController < ApplicationController
183
+ # whodunit_only_for :create, :update, :destroy
184
+ # end
185
+ def whodunit_only_for(*actions)
186
+ return unless respond_to?(:skip_before_action) && respond_to?(:before_action) && respond_to?(:after_action)
187
+
188
+ skip_before_action :set_whodunit_user
189
+ skip_after_action :reset_whodunit_user
190
+
191
+ before_action :set_whodunit_user, only: actions, if: :whodunit_user_available?
192
+ after_action :reset_whodunit_user, only: actions
193
+ end
194
+ end
195
+ end
196
+ end