dscf-core 0.2.5 → 0.2.6
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/app/controllers/concerns/dscf/core/auditable_controller.rb +539 -0
- data/app/controllers/concerns/dscf/core/common.rb +5 -0
- data/app/controllers/concerns/dscf/core/reviewable_controller.rb +26 -4
- data/app/controllers/dscf/core/businesses_controller.rb +12 -1
- data/app/jobs/dscf/core/audit_logger_job.rb +256 -0
- data/app/models/concerns/dscf/core/auditable_model.rb +34 -0
- data/app/models/concerns/dscf/core/reviewable_model.rb +34 -1
- data/app/models/dscf/core/audit_log.rb +26 -0
- data/app/models/dscf/core/business.rb +4 -0
- data/app/serializers/dscf/core/audit_log_serializer.rb +40 -0
- data/config/locales/en.yml +2 -0
- data/config/routes.rb +1 -0
- data/db/migrate/20250926102025_create_dscf_core_reviews.rb +1 -1
- data/db/migrate/20251023000000_create_dscf_core_audit_logs.rb +25 -0
- data/lib/dscf/core/version.rb +1 -1
- data/spec/factories/auditable_test_model.rb +11 -0
- data/spec/factories/dscf/core/audit_logs.rb +12 -0
- metadata +10 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 230e498b674cb3acf80fd9c7637b89c3e8da0e697a108f5d1b8998b9218eda59
|
|
4
|
+
data.tar.gz: d62246472662f80db9df2478a08afa9eebd163af916aa9a70f408ccbe695bf25
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: aadfeecee56e8ffc6ade9f47f244a7e313b23e23913dd99636c0bcff48fa53deb029c0461c8f92d4094352932f99f9941c3de5c97987a5f71bad92aec01364e5
|
|
7
|
+
data.tar.gz: 5ec8e1e44f084814ba4139be60f4f9c306f42eb383aced86d12764c61a205369242a09e72450f98104e7cb15bece45680210473827dbb691d9c05388581b188b
|
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
module Dscf
|
|
2
|
+
module Core
|
|
3
|
+
# Custom exception for missing auditable record
|
|
4
|
+
class AuditableRecordNotFoundError < StandardError; end
|
|
5
|
+
|
|
6
|
+
module AuditableController
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
included do
|
|
10
|
+
class_attribute :_audit_configs, default: []
|
|
11
|
+
class_attribute :_explicit_auditable_model, default: nil
|
|
12
|
+
|
|
13
|
+
before_action :capture_audit_snapshot, if: :audit_needed?
|
|
14
|
+
after_action :enqueue_audit, if: :audit_needed?
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class_methods do
|
|
18
|
+
# DSL: Define what to audit
|
|
19
|
+
# auditable associated: [:reviews], only: [:status], on: [:approve, :reject]
|
|
20
|
+
# auditable only: [:name, :email], on: [:create, :update]
|
|
21
|
+
def auditable(associated: [], only: [], except: [], on: [], action: nil, transactional: true)
|
|
22
|
+
# Validate configuration
|
|
23
|
+
validate_audit_config!(associated: associated, only: only, except: except, on: on, action: action)
|
|
24
|
+
|
|
25
|
+
config = {
|
|
26
|
+
associated: Array(associated),
|
|
27
|
+
only: Array(only),
|
|
28
|
+
except: Array(except),
|
|
29
|
+
on: Array(on).map(&:to_sym),
|
|
30
|
+
action: action,
|
|
31
|
+
transactional: transactional
|
|
32
|
+
}
|
|
33
|
+
self._audit_configs += [config]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Validate audit configuration at definition time
|
|
37
|
+
def validate_audit_config!(associated:, only:, except:, on:, action:)
|
|
38
|
+
# Validate associated is array of symbols
|
|
39
|
+
Array(associated).each do |assoc|
|
|
40
|
+
unless assoc.is_a?(Symbol)
|
|
41
|
+
raise ArgumentError, "auditable associated: must be an array of symbols, got #{assoc.class} for #{assoc.inspect}"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Validate only/except don't overlap
|
|
46
|
+
only_arr = Array(only).map(&:to_sym)
|
|
47
|
+
except_arr = Array(except).map(&:to_sym)
|
|
48
|
+
overlap = only_arr & except_arr
|
|
49
|
+
if overlap.any?
|
|
50
|
+
Rails.logger.warn "⚠️ Auditable config warning: 'only' and 'except' overlap for fields: " \
|
|
51
|
+
"#{overlap.join(', ')}. 'except' will take precedence."
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Validate action format
|
|
55
|
+
if action.present?
|
|
56
|
+
unless action.is_a?(String) || action.is_a?(Hash)
|
|
57
|
+
raise ArgumentError, "auditable action: must be a String or Hash, got #{action.class}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
if action.is_a?(Hash)
|
|
61
|
+
action.each do |key, value|
|
|
62
|
+
unless key.is_a?(Symbol) && value.is_a?(String)
|
|
63
|
+
raise ArgumentError, "auditable action: Hash keys must be Symbols and values must be Strings"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Validate on actions
|
|
70
|
+
on_arr = Array(on).map(&:to_sym)
|
|
71
|
+
valid_actions = %i[index show create update destroy approve reject request_modification resubmit submit]
|
|
72
|
+
invalid_actions = on_arr - valid_actions
|
|
73
|
+
return unless invalid_actions.any? && !Rails.env.production?
|
|
74
|
+
|
|
75
|
+
Rails.logger.warn "⚠️ Auditable config warning: Unknown actions detected: #{invalid_actions.join(', ')}. " \
|
|
76
|
+
"Make sure these are valid controller actions."
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# DSL: Explicitly set the model class (optional)
|
|
80
|
+
def auditable_model(klass)
|
|
81
|
+
self._explicit_auditable_model = klass
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# DSL: Define action-specific record resolution (optional)
|
|
85
|
+
# Override auto-discovery for specific actions where needed
|
|
86
|
+
#
|
|
87
|
+
# Example:
|
|
88
|
+
# auditable_record_for :approve, -> { @reviewable_resource }
|
|
89
|
+
# auditable_record_for :reject, -> { @reviewable_resource }
|
|
90
|
+
#
|
|
91
|
+
# Or using a map:
|
|
92
|
+
# def auditable_record_map
|
|
93
|
+
# {
|
|
94
|
+
# approve: -> { @reviewable_resource },
|
|
95
|
+
# reject: -> { @reviewable_resource },
|
|
96
|
+
# custom_action: -> { @my_custom_var }
|
|
97
|
+
# }
|
|
98
|
+
# end
|
|
99
|
+
def auditable_record_for(action_name, resolver = nil, &block)
|
|
100
|
+
self._audit_configs ||= []
|
|
101
|
+
resolver_proc = block || resolver
|
|
102
|
+
|
|
103
|
+
raise ArgumentError, "auditable_record_for requires a block or lambda" unless resolver_proc
|
|
104
|
+
|
|
105
|
+
# Store in class attribute for later access
|
|
106
|
+
@_auditable_record_resolvers ||= {}
|
|
107
|
+
@_auditable_record_resolvers[action_name.to_sym] = resolver_proc
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def auditable_record_resolvers
|
|
111
|
+
@_auditable_record_resolvers || {}
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
# Check if current action needs auditing
|
|
118
|
+
def audit_needed?
|
|
119
|
+
return false if _audit_configs.empty?
|
|
120
|
+
|
|
121
|
+
_audit_configs.any? { |c| c[:on].empty? || c[:on].include?(action_name.to_sym) }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Step 1: Capture state BEFORE action executes
|
|
125
|
+
def capture_audit_snapshot
|
|
126
|
+
@audit_snapshot = {}
|
|
127
|
+
|
|
128
|
+
# For create actions, we'll capture after the record is created
|
|
129
|
+
if action_name.to_sym == :create
|
|
130
|
+
@audit_snapshot[:is_create] = true
|
|
131
|
+
@audit_snapshot[:before_main] = nil
|
|
132
|
+
@audit_snapshot[:before_associated] = {}
|
|
133
|
+
Rails.logger.debug "Audit: Create action detected, will capture state after creation"
|
|
134
|
+
return
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
record = resolve_auditable_record
|
|
138
|
+
return unless record
|
|
139
|
+
|
|
140
|
+
# Store record identification
|
|
141
|
+
@audit_snapshot[:record_id] = record.id
|
|
142
|
+
@audit_snapshot[:record_type] = record.class.name
|
|
143
|
+
|
|
144
|
+
# Capture current state of main record
|
|
145
|
+
@audit_snapshot[:before_main] = capture_record_state(record)
|
|
146
|
+
|
|
147
|
+
# Capture current state of associations
|
|
148
|
+
@audit_snapshot[:before_associated] = {}
|
|
149
|
+
active_configs.each do |config|
|
|
150
|
+
config[:associated].each do |assoc_name|
|
|
151
|
+
next unless record.respond_to?(assoc_name)
|
|
152
|
+
|
|
153
|
+
begin
|
|
154
|
+
assoc_records = Array(record.public_send(assoc_name)).compact
|
|
155
|
+
@audit_snapshot[:before_associated][assoc_name] = assoc_records.map { |r| capture_record_state(r) }
|
|
156
|
+
rescue StandardError => e
|
|
157
|
+
Rails.logger.debug "Audit: Could not capture #{assoc_name}: #{e.message}"
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Clear memoization to allow fresh resolution in after_action
|
|
163
|
+
# This handles cases where controllers reload/modify the record (e.g., Common module)
|
|
164
|
+
remove_instance_variable(:@resolve_auditable_record) if defined?(@resolve_auditable_record)
|
|
165
|
+
rescue StandardError => e
|
|
166
|
+
Rails.logger.warn "Audit snapshot failed: #{e.message}"
|
|
167
|
+
@audit_snapshot = nil
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Step 2: Enqueue audit job AFTER action completes
|
|
171
|
+
def enqueue_audit
|
|
172
|
+
Rails.logger.debug "Audit: enqueue_audit called for #{action_name}"
|
|
173
|
+
|
|
174
|
+
unless @audit_snapshot.present?
|
|
175
|
+
Rails.logger.debug "Audit: No snapshot present, skipping"
|
|
176
|
+
return
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# For create actions, check early if we should skip auditing
|
|
180
|
+
# This prevents misleading error logs for validation failures
|
|
181
|
+
if @audit_snapshot[:is_create]
|
|
182
|
+
# Try to find the record WITHOUT triggering error logging
|
|
183
|
+
model_klass = _explicit_auditable_model || detect_model_class
|
|
184
|
+
record = find_created_record_quietly(model_klass)
|
|
185
|
+
|
|
186
|
+
if record.nil? || !record.persisted?
|
|
187
|
+
Rails.logger.debug "Audit: Skipping failed create (record not persisted)"
|
|
188
|
+
return
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Set record info for successful create
|
|
192
|
+
@audit_snapshot[:record_id] = record.id
|
|
193
|
+
@audit_snapshot[:record_type] = record.class.name
|
|
194
|
+
Rails.logger.debug "Audit: Set record info for create action"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Now resolve the record (for both create and other actions)
|
|
198
|
+
record = resolve_auditable_record
|
|
199
|
+
|
|
200
|
+
# For non-create actions, fail fast if we can't find the record
|
|
201
|
+
unless record
|
|
202
|
+
# CRITICAL: Fail fast if we can't find the record
|
|
203
|
+
log_resolution_failure(detect_model_class, action_name.to_sym)
|
|
204
|
+
return
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
Rails.logger.debug "Audit: Found record #{record.class.name}##{record.id}"
|
|
208
|
+
|
|
209
|
+
# Capture state AFTER action
|
|
210
|
+
after_main = capture_record_state(record)
|
|
211
|
+
after_associated = {}
|
|
212
|
+
|
|
213
|
+
active_configs.each do |config|
|
|
214
|
+
config[:associated].each do |assoc_name|
|
|
215
|
+
next unless record.respond_to?(assoc_name)
|
|
216
|
+
|
|
217
|
+
begin
|
|
218
|
+
# Reload association to get fresh state
|
|
219
|
+
record.association(assoc_name).reload if record.association(assoc_name).loaded?
|
|
220
|
+
assoc_records = Array(record.public_send(assoc_name)).compact
|
|
221
|
+
after_associated[assoc_name] = assoc_records.map { |r| capture_record_state(r) }
|
|
222
|
+
Rails.logger.debug "Audit: Captured #{assoc_records.count} #{assoc_name} records"
|
|
223
|
+
rescue StandardError => e
|
|
224
|
+
Rails.logger.debug "Audit: Could not capture after state for #{assoc_name}: #{e.message}"
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
Rails.logger.debug "Audit: Enqueuing job with #{active_configs.count} configs"
|
|
230
|
+
|
|
231
|
+
# Check if any config requires transactional safety
|
|
232
|
+
requires_transaction = active_configs.any? { |c| c[:transactional] != false }
|
|
233
|
+
|
|
234
|
+
# Enqueue job with complete snapshot
|
|
235
|
+
job_args = {
|
|
236
|
+
record_id: @audit_snapshot[:record_id],
|
|
237
|
+
record_type: @audit_snapshot[:record_type],
|
|
238
|
+
before_main: @audit_snapshot[:before_main],
|
|
239
|
+
after_main: after_main,
|
|
240
|
+
before_associated: @audit_snapshot[:before_associated] || {},
|
|
241
|
+
after_associated: after_associated,
|
|
242
|
+
actor_id: current_actor&.id,
|
|
243
|
+
actor_type: current_actor&.class&.name,
|
|
244
|
+
action_name: action_name,
|
|
245
|
+
controller_name: controller_name,
|
|
246
|
+
request_uuid: request.uuid,
|
|
247
|
+
ip_address: request.remote_ip,
|
|
248
|
+
user_agent: request.user_agent,
|
|
249
|
+
configs: active_configs.map { |c| c.except(:block) }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if requires_transaction
|
|
253
|
+
# For critical actions, ensure audit is enqueued synchronously within transaction
|
|
254
|
+
# This prevents data commits without corresponding audit trail
|
|
255
|
+
begin
|
|
256
|
+
AuditLoggerJob.perform_later(**job_args)
|
|
257
|
+
Rails.logger.debug "Audit: Job enqueued successfully (transactional)"
|
|
258
|
+
rescue StandardError => e
|
|
259
|
+
# Re-raise to trigger transaction rollback
|
|
260
|
+
Rails.logger.error "CRITICAL: Audit enqueue failed - transaction will rollback: #{e.message}"
|
|
261
|
+
raise Dscf::Core::AuditableRecordNotFoundError,
|
|
262
|
+
"Critical Audit Failure: Could not enqueue audit job. Transaction rolled back to prevent untracked changes."
|
|
263
|
+
end
|
|
264
|
+
else
|
|
265
|
+
# For non-critical actions, log errors but don't block the transaction
|
|
266
|
+
AuditLoggerJob.perform_later(**job_args)
|
|
267
|
+
Rails.logger.debug "Audit: Job enqueued successfully (non-transactional)"
|
|
268
|
+
end
|
|
269
|
+
rescue Dscf::Core::AuditableRecordNotFoundError
|
|
270
|
+
# Re-raise audit errors to trigger rollback
|
|
271
|
+
raise
|
|
272
|
+
rescue StandardError => e
|
|
273
|
+
# Handle other unexpected errors based on transactional requirement
|
|
274
|
+
Rails.logger.error "Audit enqueue failed: #{e.message}\n#{e.backtrace.first(5).join("\n")}"
|
|
275
|
+
requires_transaction = active_configs.any? { |c| c[:transactional] != false }
|
|
276
|
+
if requires_transaction
|
|
277
|
+
raise Dscf::Core::AuditableRecordNotFoundError,
|
|
278
|
+
"Critical Audit Failure: Unexpected error during audit enqueue. Transaction rolled back."
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Capture complete state of a record
|
|
283
|
+
def capture_record_state(record)
|
|
284
|
+
return nil unless record&.persisted?
|
|
285
|
+
|
|
286
|
+
{
|
|
287
|
+
id: record.id,
|
|
288
|
+
type: record.class.name,
|
|
289
|
+
attributes: record.attributes.dup,
|
|
290
|
+
timestamp: Time.current.to_f
|
|
291
|
+
}
|
|
292
|
+
rescue StandardError => e
|
|
293
|
+
Rails.logger.debug "Could not capture record state: #{e.message}"
|
|
294
|
+
nil
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# Get active audit configs for current action
|
|
298
|
+
def active_configs
|
|
299
|
+
_audit_configs.select { |c| c[:on].empty? || c[:on].include?(action_name.to_sym) }
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Resolve the main record being audited
|
|
303
|
+
def resolve_auditable_record
|
|
304
|
+
return @resolve_auditable_record if defined?(@resolve_auditable_record)
|
|
305
|
+
|
|
306
|
+
@resolve_auditable_record = perform_record_resolution
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Perform the actual record resolution logic
|
|
310
|
+
def perform_record_resolution
|
|
311
|
+
model_klass = _explicit_auditable_model || detect_model_class
|
|
312
|
+
return nil unless model_klass.respond_to?(:ancestors) && model_klass.ancestors.include?(ActiveRecord::Base)
|
|
313
|
+
|
|
314
|
+
current_action = action_name.to_sym
|
|
315
|
+
|
|
316
|
+
# Priority 0: Action-specific resolver (explicit configuration via DSL)
|
|
317
|
+
resolver = self.class.auditable_record_resolvers[current_action]
|
|
318
|
+
if resolver
|
|
319
|
+
begin
|
|
320
|
+
record = instance_exec(&resolver)
|
|
321
|
+
if record.is_a?(model_klass)
|
|
322
|
+
Rails.logger.debug "Audit: Found record via action-specific resolver for :#{current_action}"
|
|
323
|
+
return record
|
|
324
|
+
elsif record.present?
|
|
325
|
+
Rails.logger.warn "Audit: Action-specific resolver for :#{current_action} returned " \
|
|
326
|
+
"#{record.class.name}, expected #{model_klass.name}"
|
|
327
|
+
end
|
|
328
|
+
rescue StandardError => e
|
|
329
|
+
Rails.logger.error "Audit: Action-specific resolver for :#{current_action} failed: #{e.message}"
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# Priority 1: Instance method map (auditable_record_map)
|
|
334
|
+
if respond_to?(:auditable_record_map, true)
|
|
335
|
+
begin
|
|
336
|
+
record_map = auditable_record_map
|
|
337
|
+
if record_map.is_a?(Hash) && record_map[current_action]
|
|
338
|
+
resolver_proc = record_map[current_action]
|
|
339
|
+
record = instance_exec(&resolver_proc)
|
|
340
|
+
if record.is_a?(model_klass)
|
|
341
|
+
Rails.logger.debug "Audit: Found record via auditable_record_map for :#{current_action}"
|
|
342
|
+
return record
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
rescue StandardError => e
|
|
346
|
+
Rails.logger.warn "Audit: auditable_record_map failed for :#{current_action}: #{e.message}"
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# Priority 2: Generic hook method (auditable_record)
|
|
351
|
+
if respond_to?(:auditable_record, true)
|
|
352
|
+
record = auditable_record
|
|
353
|
+
if record.is_a?(model_klass)
|
|
354
|
+
Rails.logger.debug "Audit: Found record via auditable_record hook"
|
|
355
|
+
return record
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# Priority 3: Check ALL instance variables for objects of the model class
|
|
360
|
+
instance_variables.each do |ivar|
|
|
361
|
+
# Skip internal audit instance variables
|
|
362
|
+
next if ivar.to_s.start_with?("@__")
|
|
363
|
+
|
|
364
|
+
obj = instance_variable_get(ivar)
|
|
365
|
+
if obj.is_a?(model_klass)
|
|
366
|
+
Rails.logger.debug "Audit: Found record via instance variable #{ivar}"
|
|
367
|
+
return obj
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# Priority 4: Try standard Rails @model_name pattern (e.g., @business)
|
|
372
|
+
ivar_name = "@#{model_klass.name.demodulize.underscore}"
|
|
373
|
+
if instance_variable_defined?(ivar_name)
|
|
374
|
+
obj = instance_variable_get(ivar_name)
|
|
375
|
+
if obj.is_a?(model_klass)
|
|
376
|
+
Rails.logger.debug "Audit: Found record via naming convention #{ivar_name}"
|
|
377
|
+
return obj
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# Priority 5: Try finding by params[:id] (for show/update/delete actions)
|
|
382
|
+
if params[:id].present? && current_action != :create
|
|
383
|
+
begin
|
|
384
|
+
record = model_klass.find(params[:id])
|
|
385
|
+
Rails.logger.debug "Audit: Found record via params[:id]"
|
|
386
|
+
return record
|
|
387
|
+
rescue ActiveRecord::RecordNotFound, ArgumentError
|
|
388
|
+
# Record not found or invalid ID
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# Priority 6: For create actions, check if a record was just created
|
|
393
|
+
if current_action == :create
|
|
394
|
+
# Try to find the most recently created record
|
|
395
|
+
# This is a fallback for controllers that don't set instance variables
|
|
396
|
+
recent_record = model_klass.order(created_at: :desc).first
|
|
397
|
+
if recent_record && recent_record.created_at > 5.seconds.ago
|
|
398
|
+
Rails.logger.debug "Audit: Found record via recent creation"
|
|
399
|
+
return recent_record
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# FAIL-FAST: Log helpful error message with configuration instructions
|
|
404
|
+
log_resolution_failure(model_klass, current_action)
|
|
405
|
+
nil
|
|
406
|
+
rescue StandardError => e
|
|
407
|
+
Rails.logger.error "Audit: Failed to resolve record for #{model_klass}: #{e.message}\n#{e.backtrace.first(3).join("\n")}"
|
|
408
|
+
nil
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# Handle missing auditable record with environment-aware fail-fast behavior
|
|
412
|
+
#
|
|
413
|
+
# This method implements a critical safety feature:
|
|
414
|
+
# - In development/test: Raises detailed error with full diagnostic info
|
|
415
|
+
# - In production: Raises concise error and logs full details
|
|
416
|
+
#
|
|
417
|
+
# @param model_class [Class] The model class that couldn't be resolved
|
|
418
|
+
# @param action [Symbol] The controller action being audited
|
|
419
|
+
# @raise [Dscf::Core::AuditableRecordNotFoundError] Always raises to halt transaction
|
|
420
|
+
def log_resolution_failure(model_class, action)
|
|
421
|
+
# Safely collect instance variable information
|
|
422
|
+
instance_vars_info = instance_variables.filter_map do |v|
|
|
423
|
+
obj = instance_variable_get(v)
|
|
424
|
+
" - #{v} (#{obj.class.name})"
|
|
425
|
+
rescue StandardError
|
|
426
|
+
nil
|
|
427
|
+
end.join("\n")
|
|
428
|
+
|
|
429
|
+
# Build comprehensive diagnostic message
|
|
430
|
+
detailed_message = <<~ERROR
|
|
431
|
+
═══════════════════════════════════════════════════════════════════════════
|
|
432
|
+
🚨 AUDITABLE SYSTEM ERROR: Could not resolve record for audit logging
|
|
433
|
+
═══════════════════════════════════════════════════════════════════════════
|
|
434
|
+
|
|
435
|
+
Controller: #{self.class.name}
|
|
436
|
+
Action: #{action}
|
|
437
|
+
Model: #{model_class.name}
|
|
438
|
+
|
|
439
|
+
The Auditable system tried to find the #{model_class.name} record but failed.
|
|
440
|
+
|
|
441
|
+
💡 SOLUTION: Define an action-specific resolver in your controller:
|
|
442
|
+
|
|
443
|
+
Option 1 - Using auditable_record_map (recommended for multiple actions):
|
|
444
|
+
─────────────────────────────────────────────────────────────────────────
|
|
445
|
+
private
|
|
446
|
+
|
|
447
|
+
def auditable_record_map
|
|
448
|
+
{
|
|
449
|
+
#{action}: -> { @your_instance_variable }
|
|
450
|
+
}
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
Option 2 - Using class-level DSL:
|
|
454
|
+
─────────────────────────────────────────────────────────────────────────
|
|
455
|
+
auditable_record_for :#{action}, -> { @your_instance_variable }
|
|
456
|
+
|
|
457
|
+
Option 3 - Using a generic hook (for all actions):
|
|
458
|
+
─────────────────────────────────────────────────────────────────────────
|
|
459
|
+
private
|
|
460
|
+
|
|
461
|
+
def auditable_record
|
|
462
|
+
@your_instance_variable
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
📋 Available instance variables in controller:
|
|
466
|
+
#{instance_vars_info.presence || ' (none found)'}
|
|
467
|
+
|
|
468
|
+
═══════════════════════════════════════════════════════════════════════════
|
|
469
|
+
ERROR
|
|
470
|
+
|
|
471
|
+
# Always log the full detailed message
|
|
472
|
+
Rails.logger.error(detailed_message)
|
|
473
|
+
|
|
474
|
+
# Environment-specific behavior
|
|
475
|
+
raise Dscf::Core::AuditableRecordNotFoundError, detailed_message unless Rails.env.production?
|
|
476
|
+
|
|
477
|
+
Rails.logger.error("[CRITICAL] Audit system failed to resolve #{model_class.name} for action :#{action} in #{self.class.name}")
|
|
478
|
+
|
|
479
|
+
# Raise concise error that will halt the transaction
|
|
480
|
+
raise Dscf::Core::AuditableRecordNotFoundError,
|
|
481
|
+
"Critical Audit Failure: Could not resolve auditable record for #{model_class.name}##{action}. " \
|
|
482
|
+
"This transaction has been rolled back to prevent untracked data changes. " \
|
|
483
|
+
"Check application logs for detailed diagnostic information."
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
# Find created record without triggering error logging
|
|
487
|
+
# This is used for create actions to check if record was saved before attempting full resolution
|
|
488
|
+
# @param model_klass [Class] The model class to search for
|
|
489
|
+
# @return [ActiveRecord::Base, nil] The found record or nil
|
|
490
|
+
def find_created_record_quietly(model_klass)
|
|
491
|
+
return nil unless model_klass.respond_to?(:ancestors) && model_klass.ancestors.include?(ActiveRecord::Base)
|
|
492
|
+
|
|
493
|
+
# Priority 1: Check ALL instance variables for objects of the model class
|
|
494
|
+
instance_variables.each do |ivar|
|
|
495
|
+
# Skip internal instance variables
|
|
496
|
+
next if ivar.to_s.start_with?("@__", "@audit")
|
|
497
|
+
|
|
498
|
+
obj = instance_variable_get(ivar)
|
|
499
|
+
return obj if obj.is_a?(model_klass)
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
# Priority 2: Try standard Rails @model_name pattern (e.g., @business_type)
|
|
503
|
+
ivar_name = "@#{model_klass.name.demodulize.underscore}"
|
|
504
|
+
if instance_variable_defined?(ivar_name)
|
|
505
|
+
obj = instance_variable_get(ivar_name)
|
|
506
|
+
return obj if obj.is_a?(model_klass)
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
# Priority 3: Try finding the most recently created record (fallback)
|
|
510
|
+
recent_record = model_klass.order(created_at: :desc).first
|
|
511
|
+
return recent_record if recent_record && recent_record.created_at > 5.seconds.ago
|
|
512
|
+
|
|
513
|
+
nil
|
|
514
|
+
rescue StandardError => e
|
|
515
|
+
Rails.logger.debug "Audit: Could not quietly find created record: #{e.message}"
|
|
516
|
+
nil
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
# Auto-detect model class from controller name
|
|
520
|
+
def detect_model_class
|
|
521
|
+
model_name = controller_name.classify
|
|
522
|
+
namespace = self.class.name.deconstantize
|
|
523
|
+
|
|
524
|
+
if namespace.present? && namespace != "Object"
|
|
525
|
+
"#{namespace}::#{model_name}".safe_constantize
|
|
526
|
+
else
|
|
527
|
+
model_name.safe_constantize
|
|
528
|
+
end
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
# Get current actor (user performing the action)
|
|
532
|
+
def current_actor
|
|
533
|
+
return @current_actor if defined?(@current_actor)
|
|
534
|
+
|
|
535
|
+
@current_actor = current_user if respond_to?(:current_user, true)
|
|
536
|
+
end
|
|
537
|
+
end
|
|
538
|
+
end
|
|
539
|
+
end
|
|
@@ -103,7 +103,11 @@ module Dscf
|
|
|
103
103
|
end
|
|
104
104
|
|
|
105
105
|
if obj.save
|
|
106
|
+
# Store in instance variable for auditing and other concerns
|
|
107
|
+
@obj = obj
|
|
108
|
+
|
|
106
109
|
obj = @clazz.includes(eager_loaded_associations).find(obj.id) if eager_loaded_associations.present?
|
|
110
|
+
@obj = obj # Update with reloaded version
|
|
107
111
|
|
|
108
112
|
includes = serializer_includes_for_action(:create)
|
|
109
113
|
options[:include] = includes if includes.present?
|
|
@@ -135,6 +139,7 @@ module Dscf
|
|
|
135
139
|
|
|
136
140
|
if obj.update(model_params)
|
|
137
141
|
obj = @clazz.includes(eager_loaded_associations).find(obj.id) if eager_loaded_associations.present?
|
|
142
|
+
@obj = obj # Update with reloaded version for auditing
|
|
138
143
|
|
|
139
144
|
includes = serializer_includes_for_action(:update)
|
|
140
145
|
options[:include] = includes if includes.present?
|
|
@@ -84,6 +84,7 @@ module Dscf
|
|
|
84
84
|
|
|
85
85
|
def default_actions
|
|
86
86
|
{
|
|
87
|
+
submit: {status: "pending", require_submission_ready: true},
|
|
87
88
|
approve: {status: "approved"},
|
|
88
89
|
reject: {status: "rejected", require_feedback: true},
|
|
89
90
|
request_modification: {status: "modify", require_feedback: true},
|
|
@@ -94,9 +95,13 @@ module Dscf
|
|
|
94
95
|
def default_context_config
|
|
95
96
|
{
|
|
96
97
|
default: {
|
|
97
|
-
statuses: %w[pending approved rejected modify],
|
|
98
|
-
initial_status: "
|
|
99
|
-
transitions: {
|
|
98
|
+
statuses: %w[draft pending approved rejected modify],
|
|
99
|
+
initial_status: "draft",
|
|
100
|
+
transitions: {
|
|
101
|
+
"draft" => ["pending"],
|
|
102
|
+
"pending" => %w[approved rejected modify],
|
|
103
|
+
"modify" => ["pending"]
|
|
104
|
+
},
|
|
100
105
|
actions: default_actions
|
|
101
106
|
}
|
|
102
107
|
}
|
|
@@ -141,7 +146,7 @@ module Dscf
|
|
|
141
146
|
self.class.review_action_names.include?(action_name.to_sym)
|
|
142
147
|
end
|
|
143
148
|
|
|
144
|
-
def perform_review_action(status:, require_feedback: false, after: nil, update_model: false)
|
|
149
|
+
def perform_review_action(status:, require_feedback: false, require_submission_ready: false, after: nil, update_model: false)
|
|
145
150
|
begin
|
|
146
151
|
context_config
|
|
147
152
|
rescue ArgumentError => e
|
|
@@ -150,6 +155,14 @@ module Dscf
|
|
|
150
155
|
|
|
151
156
|
current_review = reviewable_resource.current_review_for(current_review_context)
|
|
152
157
|
|
|
158
|
+
# Check if submission is ready (for submit action)
|
|
159
|
+
if require_submission_ready && !submission_ready?(reviewable_resource)
|
|
160
|
+
return render_error(
|
|
161
|
+
errors: ["Resource must be complete and valid before submission"],
|
|
162
|
+
status: :unprocessable_entity
|
|
163
|
+
)
|
|
164
|
+
end
|
|
165
|
+
|
|
153
166
|
feedback = if require_feedback
|
|
154
167
|
feedback_data = params[:review_feedback]
|
|
155
168
|
feedback_data = feedback_data.to_unsafe_h if feedback_data.respond_to?(:to_unsafe_h)
|
|
@@ -261,6 +274,15 @@ module Dscf
|
|
|
261
274
|
# Override for custom auth
|
|
262
275
|
end
|
|
263
276
|
|
|
277
|
+
def submission_ready?(resource)
|
|
278
|
+
# Default implementation - validates the resource is ready for submission
|
|
279
|
+
# Override this method in your controller for custom validation logic
|
|
280
|
+
# Return true if ready, false otherwise
|
|
281
|
+
return true unless resource.respond_to?(:valid?)
|
|
282
|
+
|
|
283
|
+
resource.valid?
|
|
284
|
+
end
|
|
285
|
+
|
|
264
286
|
def before_review_action(review)
|
|
265
287
|
# Default implementation - can be overridden
|
|
266
288
|
# Check if there's a custom hook method defined
|
|
@@ -9,7 +9,7 @@ module Dscf
|
|
|
9
9
|
business = @clazz.new(model_params)
|
|
10
10
|
business.user = current_user
|
|
11
11
|
business.reviews.build(
|
|
12
|
-
status: "
|
|
12
|
+
status: "draft",
|
|
13
13
|
context: "default"
|
|
14
14
|
)
|
|
15
15
|
|
|
@@ -25,6 +25,17 @@ module Dscf
|
|
|
25
25
|
end
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
+
def update
|
|
29
|
+
unless @obj.editable?
|
|
30
|
+
return render_error(
|
|
31
|
+
errors: ["Cannot update Business after submission. Use modification request workflow instead."],
|
|
32
|
+
status: :unprocessable_entity
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
super
|
|
37
|
+
end
|
|
38
|
+
|
|
28
39
|
def my_business
|
|
29
40
|
index do
|
|
30
41
|
current_user.businesses
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
module Dscf
|
|
2
|
+
module Core
|
|
3
|
+
class AuditLoggerJob < ApplicationJob
|
|
4
|
+
queue_as :audit
|
|
5
|
+
retry_on StandardError, wait: 5.seconds, attempts: 3
|
|
6
|
+
discard_on ActiveRecord::RecordNotFound
|
|
7
|
+
|
|
8
|
+
def perform(record_id:, record_type:, before_main:, after_main:, before_associated:, after_associated:,
|
|
9
|
+
action_name:, controller_name:, actor_id: nil, actor_type: nil,
|
|
10
|
+
request_uuid: nil, ip_address: nil, user_agent: nil, configs: [])
|
|
11
|
+
# Find the auditable record
|
|
12
|
+
record = record_type.constantize.find_by(id: record_id)
|
|
13
|
+
return unless record
|
|
14
|
+
|
|
15
|
+
# Find the actor if present
|
|
16
|
+
actor = actor_id && actor_type ? actor_type.constantize.find_by(id: actor_id) : nil
|
|
17
|
+
|
|
18
|
+
# Calculate changes
|
|
19
|
+
all_changes = calculate_changes(
|
|
20
|
+
before_main: before_main,
|
|
21
|
+
after_main: after_main,
|
|
22
|
+
before_associated: before_associated,
|
|
23
|
+
after_associated: after_associated,
|
|
24
|
+
configs: configs
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Skip if no changes detected
|
|
28
|
+
# Check for empty structured format
|
|
29
|
+
return if all_changes.blank? ||
|
|
30
|
+
(all_changes.is_a?(Hash) &&
|
|
31
|
+
all_changes[:main].blank? &&
|
|
32
|
+
all_changes[:associated].blank?)
|
|
33
|
+
|
|
34
|
+
# Resolve action label
|
|
35
|
+
action_label = resolve_action_label(action_name, configs)
|
|
36
|
+
|
|
37
|
+
# Create audit log
|
|
38
|
+
record.record_audit!(
|
|
39
|
+
action_label,
|
|
40
|
+
audited_changes: all_changes,
|
|
41
|
+
metadata: {
|
|
42
|
+
controller: controller_name,
|
|
43
|
+
action: action_name,
|
|
44
|
+
request_uuid: request_uuid
|
|
45
|
+
},
|
|
46
|
+
actor: actor,
|
|
47
|
+
request_uuid: request_uuid,
|
|
48
|
+
ip_address: ip_address,
|
|
49
|
+
user_agent: user_agent
|
|
50
|
+
)
|
|
51
|
+
rescue StandardError => e
|
|
52
|
+
Rails.logger.error "AuditLoggerJob failed: #{e.message}\n#{e.backtrace.first(5).join("\n")}"
|
|
53
|
+
raise
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
# Calculate all changes by comparing before/after snapshots
|
|
59
|
+
# Returns structured format: { main: { changes: {...} }, associated: { assoc_name: [...] } }
|
|
60
|
+
def calculate_changes(before_main:, after_main:, before_associated:, after_associated:, configs:)
|
|
61
|
+
structured_changes = {
|
|
62
|
+
main: {},
|
|
63
|
+
associated: {}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
# Process each config
|
|
67
|
+
configs.each do |config|
|
|
68
|
+
# 1. Main record changes
|
|
69
|
+
main_changes = diff_records(before_main, after_main)
|
|
70
|
+
if main_changes.present?
|
|
71
|
+
filtered = filter_changes(main_changes, config[:only], config[:except])
|
|
72
|
+
if filtered.present?
|
|
73
|
+
structured_changes[:main][:changes] ||= {}
|
|
74
|
+
structured_changes[:main][:changes].merge!(filtered)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# 2. Associated record changes
|
|
79
|
+
config[:associated].each do |assoc_name|
|
|
80
|
+
assoc_key = assoc_name.to_sym
|
|
81
|
+
before_assoc = before_associated[assoc_key] || []
|
|
82
|
+
after_assoc = after_associated[assoc_key] || []
|
|
83
|
+
|
|
84
|
+
assoc_changes = diff_associated_records(before_assoc, after_assoc, assoc_name)
|
|
85
|
+
next unless assoc_changes.present?
|
|
86
|
+
|
|
87
|
+
# Apply filtering to associated changes
|
|
88
|
+
filtered_assoc_changes = filter_associated_changes(assoc_changes, config[:only], config[:except])
|
|
89
|
+
next unless filtered_assoc_changes.present?
|
|
90
|
+
|
|
91
|
+
# Structure: { "reviews.123" => { action: "created", changes: {...} } }
|
|
92
|
+
# Convert to: { reviews: [{ id: 123, action: "created", model: "Review", changes: {...} }] }
|
|
93
|
+
structured_changes[:associated][assoc_name.to_s] ||= []
|
|
94
|
+
|
|
95
|
+
filtered_assoc_changes.each do |key, value|
|
|
96
|
+
# Extract record ID from key like "reviews.123"
|
|
97
|
+
record_id = key.to_s.split(".").last
|
|
98
|
+
model_name = after_assoc.find { |r| r[:id].to_s == record_id }&.dig(:type) ||
|
|
99
|
+
before_assoc.find { |r| r[:id].to_s == record_id }&.dig(:type) ||
|
|
100
|
+
assoc_name.to_s.singularize.camelize
|
|
101
|
+
|
|
102
|
+
structured_changes[:associated][assoc_name.to_s] << {
|
|
103
|
+
id: record_id.to_i,
|
|
104
|
+
action: value[:action] || value["action"],
|
|
105
|
+
model: model_name,
|
|
106
|
+
changes: value[:changes] || value["changes"] || {}
|
|
107
|
+
}
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Return flat hash if no changes (for compatibility)
|
|
113
|
+
return {} if structured_changes[:main].empty? && structured_changes[:associated].empty?
|
|
114
|
+
|
|
115
|
+
structured_changes
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Diff two record states (before/after)
|
|
119
|
+
def diff_records(before, after)
|
|
120
|
+
return {} if after.blank?
|
|
121
|
+
|
|
122
|
+
# For create actions, before is nil - treat all attributes as new
|
|
123
|
+
if before.blank?
|
|
124
|
+
changes = {}
|
|
125
|
+
after_attrs = after[:attributes] || {}
|
|
126
|
+
after_attrs.each do |key, new_val|
|
|
127
|
+
# Skip timestamps and IDs for create
|
|
128
|
+
next if %w[id created_at updated_at].include?(key.to_s)
|
|
129
|
+
# Skip nil valbues
|
|
130
|
+
next if new_val.nil?
|
|
131
|
+
|
|
132
|
+
changes[key] = {old: nil, new: new_val}
|
|
133
|
+
end
|
|
134
|
+
return changes
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
return {} if before[:id] != after[:id]
|
|
138
|
+
|
|
139
|
+
changes = {}
|
|
140
|
+
before_attrs = before[:attributes] || {}
|
|
141
|
+
after_attrs = after[:attributes] || {}
|
|
142
|
+
|
|
143
|
+
# Find changed attributes
|
|
144
|
+
all_keys = (before_attrs.keys + after_attrs.keys).uniq
|
|
145
|
+
all_keys.each do |key|
|
|
146
|
+
old_val = before_attrs[key]
|
|
147
|
+
new_val = after_attrs[key]
|
|
148
|
+
|
|
149
|
+
# Skip if unchanged
|
|
150
|
+
next if old_val == new_val
|
|
151
|
+
|
|
152
|
+
changes[key] = {old: old_val, new: new_val}
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
changes
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Diff associated records (handle create/update/delete)
|
|
159
|
+
def diff_associated_records(before_list, after_list, assoc_name)
|
|
160
|
+
changes = {}
|
|
161
|
+
|
|
162
|
+
# Build ID maps
|
|
163
|
+
before_map = before_list.each_with_object({}) { |rec, h| h[rec[:id]] = rec if rec[:id] }
|
|
164
|
+
after_map = after_list.each_with_object({}) { |rec, h| h[rec[:id]] = rec if rec[:id] }
|
|
165
|
+
|
|
166
|
+
# Detect created records (in after but not in before)
|
|
167
|
+
after_map.each do |id, after_rec|
|
|
168
|
+
next if before_map[id]
|
|
169
|
+
|
|
170
|
+
changes["#{assoc_name}.#{id}"] = {
|
|
171
|
+
action: "created",
|
|
172
|
+
attributes: after_rec[:attributes]
|
|
173
|
+
}
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Detect updated records (in both, but with differences)
|
|
177
|
+
before_map.each do |id, before_rec|
|
|
178
|
+
after_rec = after_map[id]
|
|
179
|
+
next unless after_rec
|
|
180
|
+
|
|
181
|
+
record_changes = diff_records(before_rec, after_rec)
|
|
182
|
+
next unless record_changes.present?
|
|
183
|
+
|
|
184
|
+
changes["#{assoc_name}.#{id}"] = {
|
|
185
|
+
action: "updated",
|
|
186
|
+
changes: record_changes
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
# Detect deleted records (in before but not in after)
|
|
190
|
+
next if after_map[id]
|
|
191
|
+
|
|
192
|
+
changes["#{assoc_name}.#{id}"] = {
|
|
193
|
+
action: "deleted",
|
|
194
|
+
attributes: before_rec[:attributes]
|
|
195
|
+
}
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
changes
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Filter changes based on only/except rules
|
|
202
|
+
def filter_changes(changes, only, except)
|
|
203
|
+
return changes if only.blank? && except.blank?
|
|
204
|
+
|
|
205
|
+
filtered = changes.dup
|
|
206
|
+
filtered = filtered.slice(*only.map(&:to_s)) if only.present?
|
|
207
|
+
filtered = filtered.except(*except.map(&:to_s)) if except.present?
|
|
208
|
+
filtered
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Filter associated changes
|
|
212
|
+
# Input: { "reviews.46" => { action: "updated", changes: {...} } }
|
|
213
|
+
# Output: Same structure but with filtered changes
|
|
214
|
+
def filter_associated_changes(assoc_changes, only, except)
|
|
215
|
+
return assoc_changes if only.blank? && except.blank?
|
|
216
|
+
|
|
217
|
+
filtered = {}
|
|
218
|
+
|
|
219
|
+
assoc_changes.each do |key, record_data|
|
|
220
|
+
# Filter attributes (for created/deleted records)
|
|
221
|
+
if record_data[:attributes].present?
|
|
222
|
+
filtered_attrs = filter_changes(record_data[:attributes], only, except)
|
|
223
|
+
next if filtered_attrs.blank?
|
|
224
|
+
|
|
225
|
+
filtered[key] = record_data.merge(attributes: filtered_attrs)
|
|
226
|
+
# Filter changes (for updated records)
|
|
227
|
+
elsif record_data[:changes].present?
|
|
228
|
+
filtered_changes = filter_changes(record_data[:changes], only, except)
|
|
229
|
+
next if filtered_changes.blank?
|
|
230
|
+
|
|
231
|
+
filtered[key] = record_data.merge(changes: filtered_changes)
|
|
232
|
+
else
|
|
233
|
+
filtered[key] = record_data
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
filtered
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Resolve human-readable action label
|
|
241
|
+
def resolve_action_label(action, configs)
|
|
242
|
+
config = configs.find { |c| c[:action].present? }
|
|
243
|
+
return action.to_s.humanize unless config
|
|
244
|
+
|
|
245
|
+
case config[:action]
|
|
246
|
+
when String
|
|
247
|
+
config[:action]
|
|
248
|
+
when Hash
|
|
249
|
+
config[:action][action.to_sym] || config[:action][action.to_s] || action.to_s.humanize
|
|
250
|
+
else
|
|
251
|
+
action.to_s.humanize
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
module Dscf
|
|
2
|
+
module Core
|
|
3
|
+
module AuditableModel
|
|
4
|
+
extend ActiveSupport::Concern
|
|
5
|
+
|
|
6
|
+
included do
|
|
7
|
+
has_many :audit_logs,
|
|
8
|
+
as: :auditable,
|
|
9
|
+
class_name: "Dscf::Core::AuditLog",
|
|
10
|
+
dependent: :destroy
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def record_audit!(action_name, audited_changes: {}, metadata: {}, actor: nil, request_uuid: nil, ip_address: nil, user_agent: nil)
|
|
14
|
+
audit_logs.create!(
|
|
15
|
+
action: action_name,
|
|
16
|
+
audited_changes: audited_changes.presence || {},
|
|
17
|
+
metadata: metadata.presence || {},
|
|
18
|
+
actor: actor,
|
|
19
|
+
request_uuid: request_uuid,
|
|
20
|
+
ip_address: ip_address,
|
|
21
|
+
user_agent: user_agent
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def audit_history(limit: 50)
|
|
26
|
+
audit_logs.order(created_at: :desc).limit(limit)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def last_audit
|
|
30
|
+
audit_logs.order(created_at: :desc).first
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -20,12 +20,45 @@ module Dscf
|
|
|
20
20
|
return current_review.status if current_review
|
|
21
21
|
|
|
22
22
|
# Return default initial status for zero-config
|
|
23
|
-
"
|
|
23
|
+
"draft"
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
def build_review_for(context = :default)
|
|
27
27
|
reviews.build(context: context.to_s)
|
|
28
28
|
end
|
|
29
|
+
|
|
30
|
+
# Helper methods for common status checks
|
|
31
|
+
def draft?(context = :default)
|
|
32
|
+
current_status_for(context) == "draft"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def pending?(context = :default)
|
|
36
|
+
current_status_for(context) == "pending"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def approved?(context = :default)
|
|
40
|
+
current_status_for(context) == "approved"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def rejected?(context = :default)
|
|
44
|
+
current_status_for(context) == "rejected"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def modification_requested?(context = :default)
|
|
48
|
+
current_status_for(context) == "modify"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Check if resource can be edited via regular update endpoint
|
|
52
|
+
# Only draft records can be edited freely
|
|
53
|
+
# For modify status, use resubmit action with update_model: true
|
|
54
|
+
def editable?(context = :default)
|
|
55
|
+
draft?(context)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Check if resource has been submitted
|
|
59
|
+
def submitted?(context = :default)
|
|
60
|
+
!draft?(context)
|
|
61
|
+
end
|
|
29
62
|
end
|
|
30
63
|
end
|
|
31
64
|
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# app/models/dscf/core/audit_log.rb
|
|
2
|
+
module Dscf
|
|
3
|
+
module Core
|
|
4
|
+
class AuditLog < ApplicationRecord
|
|
5
|
+
self.table_name = "dscf_core_audit_logs"
|
|
6
|
+
|
|
7
|
+
belongs_to :auditable, polymorphic: true
|
|
8
|
+
belongs_to :actor, polymorphic: true, optional: true
|
|
9
|
+
|
|
10
|
+
validates :action, presence: true
|
|
11
|
+
|
|
12
|
+
scope :for_auditable, ->(obj) { where(auditable_type: obj.class.name, auditable_id: obj.id) }
|
|
13
|
+
scope :by_actor, ->(actor) { where(actor_type: actor.class.name, actor_id: actor.id) }
|
|
14
|
+
scope :for_action, ->(name) { where(action: name.to_s) }
|
|
15
|
+
scope :recent, -> { order(created_at: :desc) }
|
|
16
|
+
|
|
17
|
+
def self.ransackable_attributes(_auth_object = nil)
|
|
18
|
+
%w[id auditable_type auditable_id action created_at updated_at]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.ransackable_associations(_auth_object = nil)
|
|
22
|
+
%w[auditable actor]
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -22,6 +22,10 @@ module Dscf
|
|
|
22
22
|
%w[business_type_id contact_email contact_phone created_at description id id_value name tin_number
|
|
23
23
|
updated_at user_id]
|
|
24
24
|
end
|
|
25
|
+
|
|
26
|
+
def self.ransackable_associations(_auth_object = nil)
|
|
27
|
+
%w[user business_type documents reviews]
|
|
28
|
+
end
|
|
25
29
|
end
|
|
26
30
|
end
|
|
27
31
|
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
module Dscf
|
|
2
|
+
module Core
|
|
3
|
+
class AuditLogSerializer < ActiveModel::Serializer
|
|
4
|
+
attributes :id, :action, :audited_changes, :metadata,
|
|
5
|
+
:request_uuid, :ip_address, :user_agent,
|
|
6
|
+
:created_at, :updated_at
|
|
7
|
+
|
|
8
|
+
attribute :actor do
|
|
9
|
+
if object.actor
|
|
10
|
+
{
|
|
11
|
+
id: object.actor.id,
|
|
12
|
+
type: object.actor_type,
|
|
13
|
+
name: self.class.actor_display_name(object.actor)
|
|
14
|
+
}
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
attribute :auditable do
|
|
19
|
+
{
|
|
20
|
+
id: object.auditable_id,
|
|
21
|
+
type: object.auditable_type
|
|
22
|
+
}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.actor_display_name(actor)
|
|
26
|
+
return nil unless actor
|
|
27
|
+
|
|
28
|
+
if actor.respond_to?(:full_name) && actor.full_name.present?
|
|
29
|
+
actor.full_name
|
|
30
|
+
elsif actor.respond_to?(:name) && actor.name.present?
|
|
31
|
+
actor.name
|
|
32
|
+
elsif actor.respond_to?(:email)
|
|
33
|
+
actor.email
|
|
34
|
+
else
|
|
35
|
+
"#{actor.class.name.demodulize} ##{actor.id}"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
data/config/locales/en.yml
CHANGED
|
@@ -40,6 +40,7 @@ en:
|
|
|
40
40
|
show: "Business details retrieved successfully"
|
|
41
41
|
create: "Business created successfully"
|
|
42
42
|
update: "Business updated successfully"
|
|
43
|
+
submit: "Business submitted successfully"
|
|
43
44
|
approve: "Business approved successfully"
|
|
44
45
|
reject: "Business rejected successfully"
|
|
45
46
|
request_modification: "Business modification requested successfully"
|
|
@@ -50,6 +51,7 @@ en:
|
|
|
50
51
|
show: "Business not found"
|
|
51
52
|
create: "Failed to create business"
|
|
52
53
|
update: "Failed to update business"
|
|
54
|
+
submit: "Failed to submit business"
|
|
53
55
|
approve: "Failed to approve business"
|
|
54
56
|
reject: "Failed to reject business"
|
|
55
57
|
request_modification: "Failed to request modification for business"
|
data/config/routes.rb
CHANGED
|
@@ -3,7 +3,7 @@ class CreateDscfCoreReviews < ActiveRecord::Migration[8.0]
|
|
|
3
3
|
create_table :dscf_core_reviews do |t|
|
|
4
4
|
t.references :reviewable, polymorphic: true, null: false
|
|
5
5
|
t.string :context
|
|
6
|
-
t.string :status, default: "
|
|
6
|
+
t.string :status, default: "draft"
|
|
7
7
|
t.jsonb :feedback
|
|
8
8
|
t.references :reviewed_by, polymorphic: true
|
|
9
9
|
t.datetime :reviewed_at
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
class CreateDscfCoreAuditLogs < ActiveRecord::Migration[6.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :dscf_core_audit_logs do |t|
|
|
4
|
+
t.references :auditable, polymorphic: true, null: false
|
|
5
|
+
|
|
6
|
+
t.string :action, null: false
|
|
7
|
+
t.jsonb :audited_changes, null: false, default: {}
|
|
8
|
+
t.jsonb :metadata, default: {}
|
|
9
|
+
|
|
10
|
+
t.references :actor, polymorphic: true
|
|
11
|
+
|
|
12
|
+
t.string :request_uuid
|
|
13
|
+
t.string :ip_address
|
|
14
|
+
t.string :user_agent
|
|
15
|
+
|
|
16
|
+
t.timestamps
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
add_index :dscf_core_audit_logs, %i[auditable_type auditable_id], name: "index_audit_logs_on_auditable"
|
|
20
|
+
add_index :dscf_core_audit_logs, %i[actor_type actor_id], name: "index_audit_logs_on_actor"
|
|
21
|
+
add_index :dscf_core_audit_logs, :request_uuid
|
|
22
|
+
add_index :dscf_core_audit_logs, :created_at
|
|
23
|
+
add_index :dscf_core_audit_logs, :action
|
|
24
|
+
end
|
|
25
|
+
end
|
data/lib/dscf/core/version.rb
CHANGED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
FactoryBot.define do
|
|
2
|
+
factory :auditable_test_model, class: "AuditableTestModel" do
|
|
3
|
+
name { Faker::Company.name }
|
|
4
|
+
description { Faker::Company.catch_phrase }
|
|
5
|
+
contact_email { Faker::Internet.email }
|
|
6
|
+
contact_phone { Faker::PhoneNumber.phone_number }
|
|
7
|
+
tin_number { Faker::Number.number(digits: 10).to_s }
|
|
8
|
+
user
|
|
9
|
+
business_type
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
FactoryBot.define do
|
|
2
|
+
factory :audit_log, class: "Dscf::Core::AuditLog" do
|
|
3
|
+
auditable { association :business }
|
|
4
|
+
action { "update" }
|
|
5
|
+
audited_changes { {"name" => ["Old name", "New name"]} }
|
|
6
|
+
metadata { {} }
|
|
7
|
+
actor { association :user }
|
|
8
|
+
request_uuid { SecureRandom.uuid }
|
|
9
|
+
ip_address { "127.0.0.1" }
|
|
10
|
+
user_agent { "RSpec" }
|
|
11
|
+
end
|
|
12
|
+
end
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: dscf-core
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.6
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Asrat
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2025-
|
|
10
|
+
date: 2025-11-03 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: rails
|
|
@@ -425,6 +425,7 @@ files:
|
|
|
425
425
|
- MIT-LICENSE
|
|
426
426
|
- README.md
|
|
427
427
|
- Rakefile
|
|
428
|
+
- app/controllers/concerns/dscf/core/auditable_controller.rb
|
|
428
429
|
- app/controllers/concerns/dscf/core/authenticatable.rb
|
|
429
430
|
- app/controllers/concerns/dscf/core/common.rb
|
|
430
431
|
- app/controllers/concerns/dscf/core/filterable.rb
|
|
@@ -439,11 +440,14 @@ files:
|
|
|
439
440
|
- app/controllers/dscf/core/businesses_controller.rb
|
|
440
441
|
- app/errors/dscf/core/authentication_error.rb
|
|
441
442
|
- app/jobs/dscf/core/application_job.rb
|
|
443
|
+
- app/jobs/dscf/core/audit_logger_job.rb
|
|
442
444
|
- app/mailers/dscf/core/application_mailer.rb
|
|
445
|
+
- app/models/concerns/dscf/core/auditable_model.rb
|
|
443
446
|
- app/models/concerns/dscf/core/reviewable_model.rb
|
|
444
447
|
- app/models/concerns/dscf/core/user_authenticatable.rb
|
|
445
448
|
- app/models/dscf/core/address.rb
|
|
446
449
|
- app/models/dscf/core/application_record.rb
|
|
450
|
+
- app/models/dscf/core/audit_log.rb
|
|
447
451
|
- app/models/dscf/core/business.rb
|
|
448
452
|
- app/models/dscf/core/business_type.rb
|
|
449
453
|
- app/models/dscf/core/document.rb
|
|
@@ -454,6 +458,7 @@ files:
|
|
|
454
458
|
- app/models/dscf/core/user_profile.rb
|
|
455
459
|
- app/models/dscf/core/user_role.rb
|
|
456
460
|
- app/serializers/dscf/core/address_serializer.rb
|
|
461
|
+
- app/serializers/dscf/core/audit_log_serializer.rb
|
|
457
462
|
- app/serializers/dscf/core/business_serializer.rb
|
|
458
463
|
- app/serializers/dscf/core/business_type_serializer.rb
|
|
459
464
|
- app/serializers/dscf/core/document_serializer.rb
|
|
@@ -482,6 +487,7 @@ files:
|
|
|
482
487
|
- db/migrate/20250824200927_make_email_and_phone_optional_for_users.rb
|
|
483
488
|
- db/migrate/20250825192113_add_defaults_to_user_profiles.rb
|
|
484
489
|
- db/migrate/20250926102025_create_dscf_core_reviews.rb
|
|
490
|
+
- db/migrate/20251023000000_create_dscf_core_audit_logs.rb
|
|
485
491
|
- lib/dscf/core.rb
|
|
486
492
|
- lib/dscf/core/engine.rb
|
|
487
493
|
- lib/dscf/core/version.rb
|
|
@@ -491,7 +497,9 @@ files:
|
|
|
491
497
|
- lib/generators/common/templates/request_spec.rb.erb
|
|
492
498
|
- lib/generators/common/templates/serializer.rb.erb
|
|
493
499
|
- lib/tasks/dscf/core_tasks.rake
|
|
500
|
+
- spec/factories/auditable_test_model.rb
|
|
494
501
|
- spec/factories/dscf/core/addresses.rb
|
|
502
|
+
- spec/factories/dscf/core/audit_logs.rb
|
|
495
503
|
- spec/factories/dscf/core/business_types.rb
|
|
496
504
|
- spec/factories/dscf/core/businesses.rb
|
|
497
505
|
- spec/factories/dscf/core/documents.rb
|