dscf-core 0.2.4 → 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 +94 -0
- 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/models/dscf/core/document.rb +6 -5
- data/app/serializers/dscf/core/audit_log_serializer.rb +40 -0
- data/app/serializers/dscf/core/business_serializer.rb +1 -0
- data/app/serializers/dscf/core/document_serializer.rb +17 -0
- data/config/locales/en.yml +24 -0
- data/config/routes.rb +13 -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
- data/spec/factories/dscf/core/documents.rb +1 -1
- metadata +12 -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
|