standard_audit 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 44b12d653a138dc099f16e659e14cd139b612369f2279859b9fcae7eff57827c
4
+ data.tar.gz: f36ceca0425ba35b8ad73c1ac91cf63e82eb6da21fdaf7f7117c73f9c09ecc4e
5
+ SHA512:
6
+ metadata.gz: e10c0a45d0941ab4284f3f1fd0a8bd25b5b9c83b0052a1e354d3acb43be984eb1997e934ff8ea45beb9df772e12420300f41403bd018d3a75e2080b32df05cd0
7
+ data.tar.gz: c0fcae8a3eee766f6a2caf7f01323788e4eeadf3de73ff3b37a7258e3a037b2771e557a7865adf559ebd98afc3d975b2e92bbdfc0b2735b1819fffaef80511e8
data/CHANGELOG.md ADDED
@@ -0,0 +1,18 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-03-03)
4
+
5
+ Initial release.
6
+
7
+ - Core audit log model with UUID primary keys and GlobalID-based polymorphic references
8
+ - Convenience API: `StandardAudit.record` with sync, async, and block forms
9
+ - ActiveSupport::Notifications subscriber for automatic event capture
10
+ - Configurable Current attribute resolvers for request context
11
+ - Multi-tenancy support via scope column
12
+ - 20+ composable query scopes (by actor, target, scope, event type, time, request context)
13
+ - Async processing via ActiveJob with configurable queue
14
+ - Sensitive key filtering for metadata
15
+ - GDPR compliance: `anonymize_actor!` (right to erasure) and `export_for_actor` (right to access)
16
+ - Model concerns: `Auditable` for actors/targets, `AuditScope` for tenant models
17
+ - Install generator with migration and initializer templates
18
+ - Rake tasks for cleanup, archival, statistics, and GDPR operations
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright TODO: Write your name
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,365 @@
1
+ # StandardAudit
2
+
3
+ Database-backed audit logging for Rails via ActiveSupport::Notifications.
4
+
5
+ StandardAudit is a standalone Rails engine that captures audit events into a dedicated `audit_logs` table. It uses [GlobalID](https://github.com/rails/globalid) for polymorphic references, making it work with any ActiveRecord model without foreign keys or tight coupling.
6
+
7
+ ## Installation
8
+
9
+ Add to your Gemfile:
10
+
11
+ ```ruby
12
+ gem "standard_audit"
13
+ ```
14
+
15
+ Run the install generator:
16
+
17
+ ```bash
18
+ rails generate standard_audit:install
19
+ rails db:migrate
20
+ ```
21
+
22
+ This creates:
23
+ - A migration for the `audit_logs` table (UUID primary keys, JSON metadata)
24
+ - An initializer at `config/initializers/standard_audit.rb`
25
+
26
+ ## Quick Start
27
+
28
+ ### 1. Subscribe to events
29
+
30
+ ```ruby
31
+ # config/initializers/standard_audit.rb
32
+ StandardAudit.configure do |config|
33
+ config.subscribe_to "myapp.*"
34
+ end
35
+ ```
36
+
37
+ ### 2. Instrument events in your code
38
+
39
+ ```ruby
40
+ ActiveSupport::Notifications.instrument("myapp.orders.created", {
41
+ actor: current_user,
42
+ target: @order,
43
+ scope: current_organisation
44
+ })
45
+ ```
46
+
47
+ ### 3. Query the logs
48
+
49
+ ```ruby
50
+ StandardAudit::AuditLog.for_actor(current_user).this_week
51
+ ```
52
+
53
+ ## Recording Events
54
+
55
+ StandardAudit provides three ways to record audit events.
56
+
57
+ ### Convenience API
58
+
59
+ The simplest approach — call `StandardAudit.record` directly:
60
+
61
+ ```ruby
62
+ StandardAudit.record("orders.created",
63
+ actor: current_user,
64
+ target: @order,
65
+ scope: current_organisation,
66
+ metadata: { total: @order.total }
67
+ )
68
+ ```
69
+
70
+ When `actor` is omitted, it falls back to the configured `current_actor_resolver` (which reads from `Current.user` by default).
71
+
72
+ ### ActiveSupport::Notifications
73
+
74
+ Instrument events and let the subscriber handle persistence:
75
+
76
+ ```ruby
77
+ ActiveSupport::Notifications.instrument("myapp.orders.created", {
78
+ actor: current_user,
79
+ target: @order,
80
+ scope: current_organisation,
81
+ total: 99.99
82
+ })
83
+ ```
84
+
85
+ Any payload keys not in the reserved set (`actor`, `target`, `scope`, `request_id`, `ip_address`, `user_agent`, `session_id`) are stored as metadata.
86
+
87
+ ### Block form
88
+
89
+ Wrap an operation so the event is only recorded if the block succeeds:
90
+
91
+ ```ruby
92
+ StandardAudit.record("orders.created", actor: current_user, target: @order) do
93
+ @order.process!
94
+ end
95
+ ```
96
+
97
+ This uses `ActiveSupport::Notifications.instrument` under the hood.
98
+
99
+ ## Model Concerns
100
+
101
+ ### Auditable
102
+
103
+ Include `StandardAudit::Auditable` in models that act as actors or targets:
104
+
105
+ ```ruby
106
+ class User < ApplicationRecord
107
+ include StandardAudit::Auditable
108
+ end
109
+ ```
110
+
111
+ This provides:
112
+
113
+ ```ruby
114
+ user.audit_logs_as_actor # logs where this user is the actor
115
+ user.audit_logs_as_target # logs where this user is the target
116
+ user.audit_logs # logs where this user is either
117
+ user.record_audit("users.updated", target: @profile)
118
+ ```
119
+
120
+ ### AuditScope
121
+
122
+ Include `StandardAudit::AuditScope` in tenant/organisation models:
123
+
124
+ ```ruby
125
+ class Organisation < ApplicationRecord
126
+ include StandardAudit::AuditScope
127
+ end
128
+ ```
129
+
130
+ This provides:
131
+
132
+ ```ruby
133
+ organisation.scoped_audit_logs # all logs scoped to this organisation
134
+ ```
135
+
136
+ ## Configuration Reference
137
+
138
+ ```ruby
139
+ StandardAudit.configure do |config|
140
+ # -- Subscriptions --
141
+ # Subscribe to ActiveSupport::Notifications patterns.
142
+ # Supports wildcards.
143
+ config.subscribe_to "myapp.*"
144
+ config.subscribe_to "auth.*"
145
+
146
+ # -- Extractors --
147
+ # How to pull actor/target/scope from notification payloads.
148
+ # Defaults shown below.
149
+ config.actor_extractor = ->(payload) { payload[:actor] }
150
+ config.target_extractor = ->(payload) { payload[:target] }
151
+ config.scope_extractor = ->(payload) { payload[:scope] }
152
+
153
+ # -- Current Attribute Resolvers --
154
+ # Fallbacks used when payload values are nil.
155
+ # Designed to work with Rails Current attributes.
156
+ config.current_actor_resolver = -> { Current.user }
157
+ config.current_request_id_resolver = -> { Current.request_id }
158
+ config.current_ip_address_resolver = -> { Current.ip_address }
159
+ config.current_user_agent_resolver = -> { Current.user_agent }
160
+ config.current_session_id_resolver = -> { Current.session_id }
161
+
162
+ # -- Sensitive Data --
163
+ # Keys automatically stripped from metadata.
164
+ config.sensitive_keys = %i[password password_confirmation token secret]
165
+
166
+ # -- Metadata Builder --
167
+ # Optional proc to transform metadata before storage.
168
+ config.metadata_builder = ->(metadata) { metadata.slice(:relevant_key) }
169
+
170
+ # -- Async Processing --
171
+ # Offload audit log creation to ActiveJob.
172
+ config.async = false
173
+ config.queue_name = :default
174
+
175
+ # -- Feature Toggle --
176
+ config.enabled = true
177
+
178
+ # -- GDPR --
179
+ # Metadata keys to strip during anonymization.
180
+ config.anonymizable_metadata_keys = %i[email name ip_address]
181
+
182
+ # -- Retention --
183
+ config.retention_days = 90
184
+ config.auto_cleanup = false
185
+ end
186
+ ```
187
+
188
+ ### Default Current Attribute Resolvers
189
+
190
+ Out of the box, StandardAudit reads from `Current` if it responds to the relevant method. This means if your app (or an auth library like StandardId) populates `Current.user`, `Current.request_id`, etc., audit logs automatically capture request context with zero configuration.
191
+
192
+ ## Query Interface
193
+
194
+ `StandardAudit::AuditLog` ships with composable scopes:
195
+
196
+ ### By association
197
+
198
+ ```ruby
199
+ AuditLog.for_actor(user) # logs for a specific actor
200
+ AuditLog.for_target(order) # logs for a specific target
201
+ AuditLog.for_scope(organisation) # logs within a scope/tenant
202
+ AuditLog.by_actor_type("User") # logs by actor class name
203
+ AuditLog.by_target_type("Order") # logs by target class name
204
+ AuditLog.by_scope_type("Organisation")
205
+ ```
206
+
207
+ ### By event
208
+
209
+ ```ruby
210
+ AuditLog.by_event_type("orders.created") # exact match
211
+ AuditLog.matching_event("orders.%") # SQL LIKE pattern
212
+ ```
213
+
214
+ ### By time
215
+
216
+ ```ruby
217
+ AuditLog.today
218
+ AuditLog.yesterday
219
+ AuditLog.this_week
220
+ AuditLog.this_month
221
+ AuditLog.last_n_days(30)
222
+ AuditLog.since(1.hour.ago)
223
+ AuditLog.before(1.day.ago)
224
+ AuditLog.between(start_time, end_time)
225
+ ```
226
+
227
+ ### By request context
228
+
229
+ ```ruby
230
+ AuditLog.for_request("req-abc-123")
231
+ AuditLog.from_ip("192.168.1.1")
232
+ AuditLog.for_session("session-xyz")
233
+ ```
234
+
235
+ ### Ordering
236
+
237
+ ```ruby
238
+ AuditLog.chronological # oldest first
239
+ AuditLog.reverse_chronological # newest first
240
+ AuditLog.recent(20) # newest 20 records
241
+ ```
242
+
243
+ ### Composing queries
244
+
245
+ All scopes are chainable:
246
+
247
+ ```ruby
248
+ AuditLog
249
+ .for_scope(current_organisation)
250
+ .by_event_type("orders.created")
251
+ .this_month
252
+ .reverse_chronological
253
+ ```
254
+
255
+ ## Multi-Tenancy
256
+
257
+ StandardAudit supports multi-tenancy through the `scope` column. Pass any ActiveRecord model as the scope — typically an Organisation or Account:
258
+
259
+ ```ruby
260
+ StandardAudit.record("orders.created",
261
+ actor: current_user,
262
+ target: @order,
263
+ scope: current_organisation
264
+ )
265
+ ```
266
+
267
+ Then query all audit activity within that tenant:
268
+
269
+ ```ruby
270
+ StandardAudit::AuditLog.for_scope(current_organisation)
271
+ ```
272
+
273
+ The scope is stored as a GlobalID string, so it works with any model class.
274
+
275
+ ## Async Processing
276
+
277
+ For high-throughput applications, offload audit log creation to a background job:
278
+
279
+ ```ruby
280
+ StandardAudit.configure do |config|
281
+ config.async = true
282
+ config.queue_name = :audit # default: :default
283
+ end
284
+ ```
285
+
286
+ When async is enabled, `StandardAudit::CreateAuditLogJob` serialises actor, target, and scope as GlobalID strings and resolves them back when the job runs. If a referenced record has been deleted between event capture and job execution, the GID string and type are preserved on the audit log (the record just won't be resolvable).
287
+
288
+ ## GDPR Compliance
289
+
290
+ ### Right to Erasure (Anonymization)
291
+
292
+ Strip personally identifiable information from audit logs while preserving the event timeline:
293
+
294
+ ```ruby
295
+ StandardAudit::AuditLog.anonymize_actor!(user)
296
+ ```
297
+
298
+ This:
299
+ - Replaces `actor_gid` / `target_gid` with `[anonymized]` where the user appears
300
+ - Clears `ip_address`, `user_agent`, and `session_id`
301
+ - Removes metadata keys listed in `anonymizable_metadata_keys`
302
+
303
+ ### Right to Access (Export)
304
+
305
+ Export all audit data for a specific user:
306
+
307
+ ```ruby
308
+ data = StandardAudit::AuditLog.export_for_actor(user)
309
+ File.write("export.json", JSON.pretty_generate(data))
310
+ ```
311
+
312
+ Returns a hash with `subject`, `exported_at`, `total_records`, and a `records` array.
313
+
314
+ ## Rake Tasks
315
+
316
+ ```bash
317
+ # Delete logs older than N days (default: retention_days config or 90)
318
+ rake standard_audit:cleanup[180]
319
+
320
+ # Archive old logs to a JSON file before deleting
321
+ rake standard_audit:archive[90,audit_backup.json]
322
+
323
+ # Show statistics
324
+ rake standard_audit:stats
325
+
326
+ # GDPR: anonymize all logs for an actor
327
+ rake "standard_audit:anonymize_actor[gid://myapp/User/123]"
328
+
329
+ # GDPR: export all logs for an actor
330
+ rake "standard_audit:export_actor[gid://myapp/User/123,export.json]"
331
+ ```
332
+
333
+ ## Database Support
334
+
335
+ The migration uses `json` column type by default, which works across:
336
+
337
+ | Database | Column Type | Notes |
338
+ |------------|-------------|-------|
339
+ | PostgreSQL | `jsonb` | Consider changing `json` to `jsonb` in the migration for better query performance |
340
+ | MySQL | `json` | Native JSON support |
341
+ | SQLite | `json` | Stored as text; suitable for development and testing |
342
+
343
+ For PostgreSQL, edit the generated migration to use `jsonb` instead of `json`:
344
+
345
+ ```ruby
346
+ t.jsonb :metadata, default: {}
347
+ ```
348
+
349
+ ## Best Practices
350
+
351
+ **What to audit**: Authentication events, data mutations, permission changes, financial transactions, admin actions, data exports, and API access from external services.
352
+
353
+ **Sensitive data**: Configure `sensitive_keys` to automatically strip passwords, tokens, and secrets from metadata. Add domain-specific keys as needed:
354
+
355
+ ```ruby
356
+ config.sensitive_keys = %i[password token secret ssn credit_card_number]
357
+ ```
358
+
359
+ **Performance**: For high-volume applications, enable async processing and ensure your `audit_logs` table has appropriate indexes (the install generator adds them by default). Consider partitioning by `occurred_at` for very large tables.
360
+
361
+ **Retention**: Set `retention_days` in your configuration and run `rake standard_audit:cleanup` via a scheduled job (e.g., cron or SolidQueue recurring). Archive before deleting if you need long-term storage.
362
+
363
+ ## License
364
+
365
+ 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,8 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("spec/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ load "rails/tasks/statistics.rake"
7
+
8
+ require "bundler/gem_tasks"
@@ -0,0 +1,47 @@
1
+ module StandardAudit
2
+ class CreateAuditLogJob < ActiveJob::Base
3
+ queue_as { StandardAudit.config.queue_name }
4
+
5
+ def perform(attrs)
6
+ attrs = attrs.symbolize_keys
7
+
8
+ actor_gid = attrs.delete(:actor_gid)
9
+ target_gid = attrs.delete(:target_gid)
10
+ scope_gid = attrs.delete(:scope_gid)
11
+ actor_type = attrs.delete(:actor_type)
12
+ target_type = attrs.delete(:target_type)
13
+ scope_type = attrs.delete(:scope_type)
14
+
15
+ log = StandardAudit::AuditLog.new(attrs)
16
+
17
+ if actor_gid.present?
18
+ begin
19
+ log.actor = GlobalID::Locator.locate(actor_gid)
20
+ rescue ActiveRecord::RecordNotFound
21
+ log.actor_gid = actor_gid
22
+ log.actor_type = actor_type
23
+ end
24
+ end
25
+
26
+ if target_gid.present?
27
+ begin
28
+ log.target = GlobalID::Locator.locate(target_gid)
29
+ rescue ActiveRecord::RecordNotFound
30
+ log.target_gid = target_gid
31
+ log.target_type = target_type
32
+ end
33
+ end
34
+
35
+ if scope_gid.present?
36
+ begin
37
+ log.scope = GlobalID::Locator.locate(scope_gid)
38
+ rescue ActiveRecord::RecordNotFound
39
+ log.scope_gid = scope_gid
40
+ log.scope_type = scope_type
41
+ end
42
+ end
43
+
44
+ log.save!
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,5 @@
1
+ module StandardAudit
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,157 @@
1
+ module StandardAudit
2
+ class AuditLog < ApplicationRecord
3
+ self.table_name = "audit_logs"
4
+
5
+ before_create :assign_uuid, if: -> { id.blank? }
6
+
7
+ validates :event_type, presence: true
8
+ validates :occurred_at, presence: true
9
+
10
+ # -- Actor assignment via GlobalID --
11
+
12
+ def actor=(record)
13
+ if record.nil?
14
+ self.actor_gid = nil
15
+ self.actor_type = nil
16
+ else
17
+ self.actor_gid = record.to_global_id.to_s
18
+ self.actor_type = record.class.name
19
+ end
20
+ end
21
+
22
+ def actor
23
+ return nil if actor_gid.blank?
24
+ GlobalID::Locator.locate(actor_gid)
25
+ rescue ActiveRecord::RecordNotFound
26
+ nil
27
+ end
28
+
29
+ # -- Target assignment via GlobalID --
30
+
31
+ def target=(record)
32
+ if record.nil?
33
+ self.target_gid = nil
34
+ self.target_type = nil
35
+ else
36
+ self.target_gid = record.to_global_id.to_s
37
+ self.target_type = record.class.name
38
+ end
39
+ end
40
+
41
+ def target
42
+ return nil if target_gid.blank?
43
+ GlobalID::Locator.locate(target_gid)
44
+ rescue ActiveRecord::RecordNotFound
45
+ nil
46
+ end
47
+
48
+ # -- Scope assignment via GlobalID --
49
+
50
+ def scope=(record)
51
+ if record.nil?
52
+ self.scope_gid = nil
53
+ self.scope_type = nil
54
+ else
55
+ self.scope_gid = record.to_global_id.to_s
56
+ self.scope_type = record.class.name
57
+ end
58
+ end
59
+
60
+ def scope
61
+ return nil if scope_gid.blank?
62
+ GlobalID::Locator.locate(scope_gid)
63
+ rescue ActiveRecord::RecordNotFound
64
+ nil
65
+ end
66
+
67
+ # -- Query scopes --
68
+
69
+ scope :for_actor, ->(record) { where(actor_gid: record.to_global_id.to_s) }
70
+ scope :by_actor_type, ->(type) { where(actor_type: type.is_a?(Class) ? type.name : type.to_s) }
71
+ scope :for_target, ->(record) { where(target_gid: record.to_global_id.to_s) }
72
+ scope :by_target_type, ->(type) { where(target_type: type.is_a?(Class) ? type.name : type.to_s) }
73
+ scope :for_scope, ->(record) { where(scope_gid: record.to_global_id.to_s) }
74
+ scope :by_scope_type, ->(type) { where(scope_type: type.is_a?(Class) ? type.name : type.to_s) }
75
+ scope :by_event_type, ->(event_type) { where(event_type: event_type) }
76
+ scope :matching_event, ->(pattern) { where("event_type LIKE ?", pattern) }
77
+ scope :between, ->(start_time, end_time) { where(occurred_at: start_time..end_time) }
78
+ scope :since, ->(time) { where("occurred_at >= ?", time) }
79
+ scope :before, ->(time) { where("occurred_at < ?", time) }
80
+ scope :today, -> { where(occurred_at: Time.current.beginning_of_day..Time.current.end_of_day) }
81
+ scope :yesterday, -> { where(occurred_at: 1.day.ago.beginning_of_day..1.day.ago.end_of_day) }
82
+ scope :this_week, -> { where(occurred_at: Time.current.beginning_of_week..Time.current.end_of_week) }
83
+ scope :this_month, -> { where(occurred_at: Time.current.beginning_of_month..Time.current.end_of_month) }
84
+ scope :last_n_days, ->(n) { where("occurred_at >= ?", n.days.ago.beginning_of_day) }
85
+ scope :for_request, ->(request_id) { where(request_id: request_id) }
86
+ scope :from_ip, ->(ip_address) { where(ip_address: ip_address) }
87
+ scope :for_session, ->(session_id) { where(session_id: session_id) }
88
+ scope :chronological, -> { order(occurred_at: :asc) }
89
+ scope :reverse_chronological, -> { order(occurred_at: :desc) }
90
+ scope :recent, ->(n = 10) { reverse_chronological.limit(n) }
91
+
92
+ # -- GDPR methods --
93
+
94
+ def self.anonymize_actor!(record)
95
+ gid = record.to_global_id.to_s
96
+ logs = where("actor_gid = ? OR target_gid = ?", gid, gid)
97
+ count = logs.count
98
+
99
+ anonymizable_keys = StandardAudit.config.anonymizable_metadata_keys.map(&:to_s)
100
+
101
+ logs.find_each do |log|
102
+ attrs = {
103
+ ip_address: nil,
104
+ user_agent: nil,
105
+ session_id: nil
106
+ }
107
+
108
+ attrs[:actor_gid] = "[anonymized]" if log.actor_gid == gid
109
+ attrs[:actor_type] = "[anonymized]" if log.actor_gid == gid
110
+ attrs[:target_gid] = "[anonymized]" if log.target_gid == gid
111
+ attrs[:target_type] = "[anonymized]" if log.target_gid == gid
112
+
113
+ if log.metadata.present? && anonymizable_keys.any?
114
+ cleaned_metadata = log.metadata.reject { |k, _| anonymizable_keys.include?(k.to_s) }
115
+ attrs[:metadata] = cleaned_metadata
116
+ end
117
+
118
+ log.update_columns(attrs)
119
+ end
120
+
121
+ count
122
+ end
123
+
124
+ def self.export_for_actor(record)
125
+ gid = record.to_global_id.to_s
126
+ logs = where("actor_gid = ? OR target_gid = ?", gid, gid).chronological
127
+
128
+ records = logs.map do |log|
129
+ {
130
+ id: log.id,
131
+ event_type: log.event_type,
132
+ actor_gid: log.actor_gid,
133
+ target_gid: log.target_gid,
134
+ scope_gid: log.scope_gid,
135
+ metadata: log.metadata,
136
+ occurred_at: log.occurred_at.iso8601,
137
+ ip_address: log.ip_address,
138
+ user_agent: log.user_agent,
139
+ request_id: log.request_id
140
+ }
141
+ end
142
+
143
+ {
144
+ subject: gid,
145
+ exported_at: Time.current.iso8601,
146
+ total_records: records.size,
147
+ records: records
148
+ }
149
+ end
150
+
151
+ private
152
+
153
+ def assign_uuid
154
+ self.id = SecureRandom.uuid
155
+ end
156
+ end
157
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,2 @@
1
+ StandardAudit::Engine.routes.draw do
2
+ end
@@ -0,0 +1,20 @@
1
+ module StandardAudit
2
+ module Generators
3
+ class InstallGenerator < Rails::Generators::Base
4
+ include Rails::Generators::Migration
5
+ source_root File.expand_path("templates", __dir__)
6
+
7
+ def self.next_migration_number(dirname)
8
+ Time.now.utc.strftime("%Y%m%d%H%M%S")
9
+ end
10
+
11
+ def copy_migration
12
+ migration_template "create_audit_logs.rb.erb", "db/migrate/create_audit_logs.rb"
13
+ end
14
+
15
+ def copy_initializer
16
+ template "initializer.rb.erb", "config/initializers/standard_audit.rb"
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,30 @@
1
+ class CreateAuditLogs < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ create_table :audit_logs, id: :uuid do |t|
4
+ t.string :actor_gid
5
+ t.string :actor_type
6
+ t.string :target_gid
7
+ t.string :target_type
8
+ t.string :scope_gid
9
+ t.string :scope_type
10
+ t.string :event_type, null: false
11
+ t.string :request_id
12
+ t.string :ip_address
13
+ t.string :user_agent
14
+ t.string :session_id
15
+ t.json :metadata, default: {}
16
+ t.datetime :occurred_at, null: false
17
+ t.timestamps
18
+ end
19
+
20
+ add_index :audit_logs, :event_type
21
+ add_index :audit_logs, :actor_type
22
+ add_index :audit_logs, :target_type
23
+ add_index :audit_logs, [:scope_type, :scope_gid]
24
+ add_index :audit_logs, :scope_type
25
+ add_index :audit_logs, :request_id
26
+ add_index :audit_logs, :occurred_at
27
+ add_index :audit_logs, :ip_address
28
+ add_index :audit_logs, :session_id
29
+ end
30
+ end
@@ -0,0 +1,37 @@
1
+ StandardAudit.configure do |config|
2
+ # Subscribe to ActiveSupport::Notifications patterns
3
+ # config.subscribe_to "audit.**"
4
+
5
+ # Actor extractor from notification payload
6
+ # config.actor_extractor = ->(payload) { payload[:actor] }
7
+
8
+ # Target extractor from notification payload
9
+ # config.target_extractor = ->(payload) { payload[:target] }
10
+
11
+ # Scope extractor from notification payload
12
+ # config.scope_extractor = ->(payload) { payload[:scope] }
13
+
14
+ # Fallback resolvers (used when payload values are nil)
15
+ # config.current_actor_resolver = -> { Current.user }
16
+ # config.current_request_id_resolver = -> { Current.request_id }
17
+ # config.current_ip_address_resolver = -> { Current.ip_address }
18
+ # config.current_user_agent_resolver = -> { Current.user_agent }
19
+ # config.current_session_id_resolver = -> { Current.session_id }
20
+
21
+ # Keys to strip from metadata
22
+ # config.sensitive_keys = %i[password password_confirmation token secret]
23
+
24
+ # Run audit log creation in background job
25
+ # config.async = false
26
+ # config.queue_name = :default
27
+
28
+ # Enable/disable audit logging
29
+ # config.enabled = true
30
+
31
+ # GDPR: metadata keys to strip on anonymization
32
+ # config.anonymizable_metadata_keys = %i[email name ip_address]
33
+
34
+ # Data retention
35
+ # config.retention_days = nil
36
+ # config.auto_cleanup = false
37
+ end
@@ -0,0 +1,9 @@
1
+ module StandardAudit
2
+ module AuditScope
3
+ extend ActiveSupport::Concern
4
+
5
+ def scoped_audit_logs
6
+ StandardAudit::AuditLog.for_scope(self)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,29 @@
1
+ module StandardAudit
2
+ module Auditable
3
+ extend ActiveSupport::Concern
4
+
5
+ def audit_logs_as_actor
6
+ StandardAudit::AuditLog.for_actor(self)
7
+ end
8
+
9
+ def audit_logs_as_target
10
+ StandardAudit::AuditLog.for_target(self)
11
+ end
12
+
13
+ def audit_logs
14
+ gid = to_global_id.to_s
15
+ StandardAudit::AuditLog.where("actor_gid = ? OR target_gid = ?", gid, gid)
16
+ end
17
+
18
+ def record_audit(event_type, target: nil, scope: nil, metadata: {}, **options)
19
+ StandardAudit.record(
20
+ event_type,
21
+ actor: self,
22
+ target: target,
23
+ scope: scope,
24
+ metadata: metadata,
25
+ **options
26
+ )
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,52 @@
1
+ module StandardAudit
2
+ class Configuration
3
+ attr_accessor :async, :queue_name, :enabled,
4
+ :actor_extractor, :target_extractor, :scope_extractor,
5
+ :current_actor_resolver, :current_request_id_resolver,
6
+ :current_ip_address_resolver, :current_user_agent_resolver,
7
+ :current_session_id_resolver,
8
+ :sensitive_keys, :metadata_builder,
9
+ :anonymizable_metadata_keys, :retention_days, :auto_cleanup
10
+
11
+ def initialize
12
+ @subscriptions = []
13
+ @async = false
14
+ @queue_name = :default
15
+ @enabled = true
16
+
17
+ @actor_extractor = ->(payload) { payload[:actor] }
18
+ @target_extractor = ->(payload) { payload[:target] }
19
+ @scope_extractor = ->(payload) { payload[:scope] }
20
+
21
+ @current_actor_resolver = -> {
22
+ defined?(Current) && Current.respond_to?(:user) ? Current.user : nil
23
+ }
24
+ @current_request_id_resolver = -> {
25
+ defined?(Current) && Current.respond_to?(:request_id) ? Current.request_id : nil
26
+ }
27
+ @current_ip_address_resolver = -> {
28
+ defined?(Current) && Current.respond_to?(:ip_address) ? Current.ip_address : nil
29
+ }
30
+ @current_user_agent_resolver = -> {
31
+ defined?(Current) && Current.respond_to?(:user_agent) ? Current.user_agent : nil
32
+ }
33
+ @current_session_id_resolver = -> {
34
+ defined?(Current) && Current.respond_to?(:session_id) ? Current.session_id : nil
35
+ }
36
+
37
+ @sensitive_keys = %i[password password_confirmation token secret]
38
+ @metadata_builder = nil
39
+ @anonymizable_metadata_keys = %i[email name ip_address]
40
+ @retention_days = nil
41
+ @auto_cleanup = false
42
+ end
43
+
44
+ def subscribe_to(pattern)
45
+ @subscriptions << pattern
46
+ end
47
+
48
+ def subscriptions
49
+ @subscriptions.dup.freeze
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,11 @@
1
+ module StandardAudit
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace StandardAudit
4
+
5
+ initializer "standard_audit.subscriber" do
6
+ ActiveSupport.on_load(:active_record) do
7
+ StandardAudit.subscriber.setup!
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,87 @@
1
+ module StandardAudit
2
+ class Subscriber
3
+ attr_reader :subscriptions
4
+
5
+ def initialize
6
+ @subscriptions = []
7
+ end
8
+
9
+ def setup!
10
+ config = StandardAudit.config
11
+ config.subscriptions.each do |pattern|
12
+ subscriber = ActiveSupport::Notifications.subscribe(pattern) do |event|
13
+ handle_event(event)
14
+ end
15
+ @subscriptions << subscriber
16
+ end
17
+ end
18
+
19
+ def teardown!
20
+ @subscriptions.each do |subscriber|
21
+ ActiveSupport::Notifications.unsubscribe(subscriber)
22
+ end
23
+ @subscriptions.clear
24
+ end
25
+
26
+ private
27
+
28
+ def handle_event(event)
29
+ return unless StandardAudit.config.enabled
30
+
31
+ config = StandardAudit.config
32
+ payload = event.payload
33
+
34
+ actor = config.actor_extractor.call(payload)
35
+ target = config.target_extractor.call(payload)
36
+ scope = config.scope_extractor.call(payload)
37
+
38
+ # Fall back to Current attributes when payload values are nil
39
+ actor ||= config.current_actor_resolver.call
40
+
41
+ metadata = extract_metadata(payload, config)
42
+
43
+ attrs = {
44
+ event_type: event.name,
45
+ occurred_at: Time.current,
46
+ request_id: payload[:request_id] || config.current_request_id_resolver.call,
47
+ ip_address: payload[:ip_address] || config.current_ip_address_resolver.call,
48
+ user_agent: payload[:user_agent] || config.current_user_agent_resolver.call,
49
+ session_id: payload[:session_id] || config.current_session_id_resolver.call,
50
+ metadata: metadata
51
+ }
52
+
53
+ if config.async
54
+ job_attrs = attrs.dup
55
+ job_attrs[:actor_gid] = actor&.to_global_id&.to_s
56
+ job_attrs[:target_gid] = target&.to_global_id&.to_s
57
+ job_attrs[:scope_gid] = scope&.to_global_id&.to_s
58
+ job_attrs[:actor_type] = actor&.class&.name
59
+ job_attrs[:target_type] = target&.class&.name
60
+ job_attrs[:scope_type] = scope&.class&.name
61
+ StandardAudit::CreateAuditLogJob.perform_later(job_attrs.stringify_keys)
62
+ else
63
+ log = StandardAudit::AuditLog.new(attrs)
64
+ log.actor = actor
65
+ log.target = target
66
+ log.scope = scope
67
+ log.save!
68
+ end
69
+ rescue => e
70
+ Rails.logger.error("[StandardAudit] Error creating audit log: #{e.message}")
71
+ end
72
+
73
+ def extract_metadata(payload, config)
74
+ # Remove known non-metadata keys
75
+ excluded_keys = %i[actor target scope request_id ip_address user_agent session_id]
76
+ raw_metadata = payload.except(*excluded_keys)
77
+
78
+ if config.metadata_builder
79
+ raw_metadata = config.metadata_builder.call(raw_metadata)
80
+ end
81
+
82
+ # Filter sensitive keys
83
+ sensitive = config.sensitive_keys.map(&:to_s)
84
+ raw_metadata.reject { |k, _| sensitive.include?(k.to_s) }
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,3 @@
1
+ module StandardAudit
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,74 @@
1
+ require "standard_audit/version"
2
+ require "standard_audit/engine"
3
+ require "standard_audit/configuration"
4
+ require "standard_audit/subscriber"
5
+ require "standard_audit/auditable"
6
+ require "standard_audit/audit_scope"
7
+
8
+ module StandardAudit
9
+ class << self
10
+ def configure
11
+ yield(config) if block_given?
12
+ end
13
+
14
+ def config
15
+ @configuration ||= Configuration.new
16
+ end
17
+
18
+ def record(event_type, actor: nil, target: nil, scope: nil, metadata: {}, **options)
19
+ return unless config.enabled
20
+
21
+ actor ||= config.current_actor_resolver.call
22
+
23
+ # Filter sensitive keys
24
+ sensitive = config.sensitive_keys.map(&:to_s)
25
+ filtered_metadata = metadata.reject { |k, _| sensitive.include?(k.to_s) }
26
+
27
+ attrs = {
28
+ event_type: event_type,
29
+ occurred_at: Time.current,
30
+ request_id: options[:request_id] || config.current_request_id_resolver.call,
31
+ ip_address: options[:ip_address] || config.current_ip_address_resolver.call,
32
+ user_agent: options[:user_agent] || config.current_user_agent_resolver.call,
33
+ session_id: options[:session_id] || config.current_session_id_resolver.call,
34
+ metadata: filtered_metadata
35
+ }
36
+
37
+ if block_given?
38
+ # Block form: instrument via ActiveSupport::Notifications
39
+ ActiveSupport::Notifications.instrument(event_type, metadata.merge(
40
+ actor: actor, target: target, scope: scope
41
+ )) do
42
+ yield
43
+ end
44
+ return
45
+ end
46
+
47
+ if config.async
48
+ job_attrs = attrs.dup
49
+ job_attrs[:actor_gid] = actor&.to_global_id&.to_s
50
+ job_attrs[:target_gid] = target&.to_global_id&.to_s
51
+ job_attrs[:scope_gid] = scope&.to_global_id&.to_s
52
+ job_attrs[:actor_type] = actor&.class&.name
53
+ job_attrs[:target_type] = target&.class&.name
54
+ job_attrs[:scope_type] = scope&.class&.name
55
+ StandardAudit::CreateAuditLogJob.perform_later(job_attrs.stringify_keys)
56
+ else
57
+ log = StandardAudit::AuditLog.new(attrs)
58
+ log.actor = actor
59
+ log.target = target
60
+ log.scope = scope
61
+ log.save!
62
+ log
63
+ end
64
+ end
65
+
66
+ def subscriber
67
+ @subscriber ||= Subscriber.new
68
+ end
69
+
70
+ def reset_configuration!
71
+ @configuration = nil
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,71 @@
1
+ namespace :standard_audit do
2
+ desc "Delete audit logs older than specified days (default: 90)"
3
+ task :cleanup, [:days] => :environment do |_t, args|
4
+ days = (args[:days] || StandardAudit.config.retention_days || 90).to_i
5
+ cutoff = days.days.ago
6
+
7
+ deleted = StandardAudit::AuditLog.where("occurred_at < ?", cutoff).delete_all
8
+ puts "Deleted #{deleted} audit logs older than #{days} days"
9
+ end
10
+
11
+ desc "Archive audit logs to JSON file"
12
+ task :archive, [:days, :output] => :environment do |_t, args|
13
+ days = (args[:days] || 90).to_i
14
+ output = args[:output] || "audit_logs_archive_#{Date.current}.json"
15
+ cutoff = days.days.ago
16
+
17
+ logs = StandardAudit::AuditLog.where("occurred_at < ?", cutoff)
18
+
19
+ File.open(output, "w") do |f|
20
+ logs.find_each do |log|
21
+ f.puts log.attributes.to_json
22
+ end
23
+ end
24
+
25
+ puts "Archived #{logs.count} logs to #{output}"
26
+ end
27
+
28
+ desc "Show audit log statistics"
29
+ task stats: :environment do
30
+ total = StandardAudit::AuditLog.count
31
+ today = StandardAudit::AuditLog.today.count
32
+ this_week = StandardAudit::AuditLog.this_week.count
33
+
34
+ by_type = StandardAudit::AuditLog
35
+ .group(:event_type)
36
+ .order(count_all: :desc)
37
+ .limit(10)
38
+ .count
39
+
40
+ puts "Audit Log Statistics"
41
+ puts "===================="
42
+ puts "Total: #{total}"
43
+ puts "Today: #{today}"
44
+ puts "This week: #{this_week}"
45
+ puts ""
46
+ puts "Top 10 Event Types:"
47
+ by_type.each { |type, count| puts " #{type}: #{count}" }
48
+ end
49
+
50
+ desc "Anonymize audit logs for a specific actor (GDPR right to erasure)"
51
+ task :anonymize_actor, [:actor_gid] => :environment do |_t, args|
52
+ raise "actor_gid is required" unless args[:actor_gid].present?
53
+
54
+ count = StandardAudit::AuditLog.anonymize_actor!(args[:actor_gid])
55
+ puts "Anonymized #{count} audit logs for #{args[:actor_gid]}"
56
+ end
57
+
58
+ desc "Export audit logs for a specific actor (GDPR right to access)"
59
+ task :export_actor, [:actor_gid, :output] => :environment do |_t, args|
60
+ raise "actor_gid is required" unless args[:actor_gid].present?
61
+ output = args[:output] || "audit_export_#{Date.current}.json"
62
+
63
+ data = StandardAudit::AuditLog.export_for_actor(args[:actor_gid])
64
+
65
+ File.open(output, "w") do |f|
66
+ f.puts JSON.pretty_generate(data)
67
+ end
68
+
69
+ puts "Exported #{data[:total_records]} audit logs to #{output}"
70
+ end
71
+ end
metadata ADDED
@@ -0,0 +1,119 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: standard_audit
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Jaryl Sim
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activerecord
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: activejob
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '7.1'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '7.1'
40
+ - !ruby/object:Gem::Dependency
41
+ name: activesupport
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '7.1'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '7.1'
54
+ - !ruby/object:Gem::Dependency
55
+ name: globalid
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '1.0'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '1.0'
68
+ description: StandardAudit is a standalone Rails gem for database-backed audit logging
69
+ via ActiveSupport::Notifications. Generic, flexible, and works with any Rails application.
70
+ email:
71
+ - code@jaryl.dev
72
+ executables: []
73
+ extensions: []
74
+ extra_rdoc_files: []
75
+ files:
76
+ - CHANGELOG.md
77
+ - MIT-LICENSE
78
+ - README.md
79
+ - Rakefile
80
+ - app/jobs/standard_audit/create_audit_log_job.rb
81
+ - app/models/standard_audit/application_record.rb
82
+ - app/models/standard_audit/audit_log.rb
83
+ - config/routes.rb
84
+ - lib/generators/standard_audit/install/install_generator.rb
85
+ - lib/generators/standard_audit/install/templates/create_audit_logs.rb.erb
86
+ - lib/generators/standard_audit/install/templates/initializer.rb.erb
87
+ - lib/standard_audit.rb
88
+ - lib/standard_audit/audit_scope.rb
89
+ - lib/standard_audit/auditable.rb
90
+ - lib/standard_audit/configuration.rb
91
+ - lib/standard_audit/engine.rb
92
+ - lib/standard_audit/subscriber.rb
93
+ - lib/standard_audit/version.rb
94
+ - lib/tasks/standard_audit_tasks.rake
95
+ homepage: https://github.com/rarebit-one/standard_audit
96
+ licenses:
97
+ - MIT
98
+ metadata:
99
+ homepage_uri: https://github.com/rarebit-one/standard_audit
100
+ source_code_uri: https://github.com/rarebit-one/standard_audit
101
+ changelog_uri: https://github.com/rarebit-one/standard_audit/blob/main/CHANGELOG.md
102
+ rdoc_options: []
103
+ require_paths:
104
+ - lib
105
+ required_ruby_version: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '3.2'
110
+ required_rubygems_version: !ruby/object:Gem::Requirement
111
+ requirements:
112
+ - - ">="
113
+ - !ruby/object:Gem::Version
114
+ version: '0'
115
+ requirements: []
116
+ rubygems_version: 4.0.3
117
+ specification_version: 4
118
+ summary: Database-backed audit logging for Rails via ActiveSupport::Notifications.
119
+ test_files: []