Module: Dscf::Core::AuditableController
- Extended by:
- ActiveSupport::Concern
- Defined in:
- app/controllers/concerns/dscf/core/auditable_controller.rb
Instance Method Summary collapse
-
#active_configs ⇒ Object
Get active audit configs for current action.
-
#audit_needed? ⇒ Boolean
Check if current action needs auditing.
-
#capture_audit_snapshot ⇒ Object
Step 1: Capture state BEFORE action executes.
-
#capture_record_state(record) ⇒ Object
Capture complete state of a record.
-
#current_actor ⇒ Object
Get current actor (user performing the action).
-
#detect_model_class ⇒ Object
Auto-detect model class from controller name.
-
#enqueue_audit ⇒ Object
Step 2: Enqueue audit job AFTER action completes.
-
#find_created_record_quietly(model_klass) ⇒ ActiveRecord::Base?
Find created record without triggering error logging This is used for create actions to check if record was saved before attempting full resolution.
-
#log_resolution_failure(model_class, action) ⇒ Object
Handle missing auditable record with environment-aware fail-fast behavior.
-
#perform_record_resolution ⇒ Object
Perform the actual record resolution logic.
-
#resolve_auditable_record ⇒ Object
Resolve the main record being audited.
Instance Method Details
#active_configs ⇒ Object
Get active audit configs for current action
354 355 356 |
# File 'app/controllers/concerns/dscf/core/auditable_controller.rb', line 354 def active_configs _audit_configs.select { |c| c[:on].empty? || c[:on].include?(action_name.to_sym) } end |
#audit_needed? ⇒ Boolean
Check if current action needs auditing
170 171 172 173 174 |
# File 'app/controllers/concerns/dscf/core/auditable_controller.rb', line 170 def audit_needed? return false if _audit_configs.empty? _audit_configs.any? { |c| c[:on].empty? || c[:on].include?(action_name.to_sym) } end |
#capture_audit_snapshot ⇒ Object
Step 1: Capture state BEFORE action executes
177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 |
# File 'app/controllers/concerns/dscf/core/auditable_controller.rb', line 177 def capture_audit_snapshot @audit_snapshot = {} # For create actions, we'll capture after the record is created if action_name.to_sym == :create @audit_snapshot[:is_create] = true @audit_snapshot[:before_main] = nil @audit_snapshot[:before_associated] = {} Rails.logger.debug "Audit: Create action detected, will capture state after creation" return end record = resolve_auditable_record return unless record # Store record identification @audit_snapshot[:record_id] = record.id @audit_snapshot[:record_type] = record.class.name # Capture current state of main record @audit_snapshot[:before_main] = capture_record_state(record) # Capture current state of associations @audit_snapshot[:before_associated] = {} active_configs.each do |config| # Handle both array and hash formats for associated assoc_names = config[:associated].is_a?(Hash) ? config[:associated].keys : config[:associated] assoc_names.each do |assoc_name| next unless record.respond_to?(assoc_name) begin assoc_records = Array(record.public_send(assoc_name)).compact @audit_snapshot[:before_associated][assoc_name] = assoc_records.map { |r| capture_record_state(r) } rescue StandardError => e Rails.logger.debug "Audit: Could not capture #{assoc_name}: #{e.}" end end end # Clear memoization to allow fresh resolution in after_action # This handles cases where controllers reload/modify the record (e.g., Common module) remove_instance_variable(:@resolve_auditable_record) if defined?(@resolve_auditable_record) rescue StandardError => e Rails.logger.warn "Audit snapshot failed: #{e.}" @audit_snapshot = nil end |
#capture_record_state(record) ⇒ Object
Capture complete state of a record
339 340 341 342 343 344 345 346 347 348 349 350 351 |
# File 'app/controllers/concerns/dscf/core/auditable_controller.rb', line 339 def capture_record_state(record) return nil unless record&.persisted? { id: record.id, type: record.class.name, attributes: record.attributes.dup, timestamp: Time.current.to_f } rescue StandardError => e Rails.logger.debug "Could not capture record state: #{e.}" nil end |
#current_actor ⇒ Object
Get current actor (user performing the action)
588 589 590 591 592 |
# File 'app/controllers/concerns/dscf/core/auditable_controller.rb', line 588 def current_actor return @current_actor if defined?(@current_actor) @current_actor = current_user if respond_to?(:current_user, true) end |
#detect_model_class ⇒ Object
Auto-detect model class from controller name
576 577 578 579 580 581 582 583 584 585 |
# File 'app/controllers/concerns/dscf/core/auditable_controller.rb', line 576 def detect_model_class model_name = controller_name.classify namespace = self.class.name.deconstantize if namespace.present? && namespace != "Object" "#{namespace}::#{model_name}".safe_constantize else model_name.safe_constantize end end |
#enqueue_audit ⇒ Object
Step 2: Enqueue audit job AFTER action completes
225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 |
# File 'app/controllers/concerns/dscf/core/auditable_controller.rb', line 225 def enqueue_audit Rails.logger.debug "Audit: enqueue_audit called for #{action_name}" unless @audit_snapshot.present? Rails.logger.debug "Audit: No snapshot present, skipping" return end # For create actions, check early if we should skip auditing # This prevents misleading error logs for validation failures if @audit_snapshot[:is_create] # Try to find the record WITHOUT triggering error logging model_klass = _explicit_auditable_model || detect_model_class record = find_created_record_quietly(model_klass) if record.nil? || !record.persisted? Rails.logger.debug "Audit: Skipping failed create (record not persisted)" return end # Set record info for successful create @audit_snapshot[:record_id] = record.id @audit_snapshot[:record_type] = record.class.name Rails.logger.debug "Audit: Set record info for create action" end # Now resolve the record (for both create and other actions) record = resolve_auditable_record # For non-create actions, fail fast if we can't find the record unless record # CRITICAL: Fail fast if we can't find the record log_resolution_failure(detect_model_class, action_name.to_sym) return end Rails.logger.debug "Audit: Found record #{record.class.name}##{record.id}" # Capture state AFTER action after_main = capture_record_state(record) after_associated = {} active_configs.each do |config| # Handle both array and hash formats for associated assoc_names = config[:associated].is_a?(Hash) ? config[:associated].keys : config[:associated] assoc_names.each do |assoc_name| next unless record.respond_to?(assoc_name) begin # Reload association to get fresh state record.association(assoc_name).reload if record.association(assoc_name).loaded? assoc_records = Array(record.public_send(assoc_name)).compact after_associated[assoc_name] = assoc_records.map { |r| capture_record_state(r) } Rails.logger.debug "Audit: Captured #{assoc_records.count} #{assoc_name} records" rescue StandardError => e Rails.logger.debug "Audit: Could not capture after state for #{assoc_name}: #{e.}" end end end Rails.logger.debug "Audit: Enqueuing job with #{active_configs.count} configs" # Check if any config requires transactional safety requires_transaction = active_configs.any? { |c| c[:transactional] != false } # Enqueue job with complete snapshot job_args = { record_id: @audit_snapshot[:record_id], record_type: @audit_snapshot[:record_type], before_main: @audit_snapshot[:before_main], after_main: after_main, before_associated: @audit_snapshot[:before_associated] || {}, after_associated: after_associated, actor_id: current_actor&.id, actor_type: current_actor&.class&.name, action_name: action_name, controller_name: controller_name, request_uuid: request.uuid, ip_address: request.remote_ip, user_agent: request.user_agent, configs: active_configs.map { |c| c.except(:block) } } if requires_transaction # For critical actions, ensure audit is enqueued synchronously within transaction # This prevents data commits without corresponding audit trail begin AuditLoggerJob.perform_later(**job_args) Rails.logger.debug "Audit: Job enqueued successfully (transactional)" rescue StandardError => e # Re-raise to trigger transaction rollback Rails.logger.error "CRITICAL: Audit enqueue failed - transaction will rollback: #{e.}" raise Dscf::Core::AuditableRecordNotFoundError, "Critical Audit Failure: Could not enqueue audit job. Transaction rolled back to prevent untracked changes." end else # For non-critical actions, log errors but don't block the transaction AuditLoggerJob.perform_later(**job_args) Rails.logger.debug "Audit: Job enqueued successfully (non-transactional)" end rescue Dscf::Core::AuditableRecordNotFoundError # Re-raise audit errors to trigger rollback raise rescue StandardError => e # Handle other unexpected errors based on transactional requirement Rails.logger.error "Audit enqueue failed: #{e.}\n#{e.backtrace.first(5).join("\n")}" requires_transaction = active_configs.any? { |c| c[:transactional] != false } if requires_transaction raise Dscf::Core::AuditableRecordNotFoundError, "Critical Audit Failure: Unexpected error during audit enqueue. Transaction rolled back." end end |
#find_created_record_quietly(model_klass) ⇒ ActiveRecord::Base?
Find created record without triggering error logging This is used for create actions to check if record was saved before attempting full resolution
546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 |
# File 'app/controllers/concerns/dscf/core/auditable_controller.rb', line 546 def find_created_record_quietly(model_klass) return nil unless model_klass.respond_to?(:ancestors) && model_klass.ancestors.include?(ActiveRecord::Base) # Priority 1: Check ALL instance variables for objects of the model class instance_variables.each do |ivar| # Skip internal instance variables next if ivar.to_s.start_with?("@__", "@audit") obj = instance_variable_get(ivar) return obj if obj.is_a?(model_klass) end # Priority 2: Try standard Rails @model_name pattern (e.g., @business_type) ivar_name = "@#{model_klass.name.demodulize.underscore}" if instance_variable_defined?(ivar_name) obj = instance_variable_get(ivar_name) return obj if obj.is_a?(model_klass) end # Priority 3: Try finding the most recently created record (fallback) recent_record = model_klass.order(created_at: :desc).first return recent_record if recent_record && recent_record.created_at > 5.seconds.ago nil rescue StandardError => e Rails.logger.debug "Audit: Could not quietly find created record: #{e.}" nil end |
#log_resolution_failure(model_class, action) ⇒ Object
Handle missing auditable record with environment-aware fail-fast behavior
This method implements a critical safety feature:
-
In development/test: Raises detailed error with full diagnostic info
-
In production: Raises concise error and logs full details
476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 |
# File 'app/controllers/concerns/dscf/core/auditable_controller.rb', line 476 def log_resolution_failure(model_class, action) # Safely collect instance variable information instance_vars_info = instance_variables.filter_map do |v| obj = instance_variable_get(v) " - #{v} (#{obj.class.name})" rescue StandardError nil end.join("\n") # Build comprehensive diagnostic message = <<~ERROR ═══════════════════════════════════════════════════════════════════════════ 🚨 AUDITABLE SYSTEM ERROR: Could not resolve record for audit logging ═══════════════════════════════════════════════════════════════════════════ Controller: #{self.class.name} Action: #{action} Model: #{model_class.name} The Auditable system tried to find the #{model_class.name} record but failed. 💡 SOLUTION: Define an action-specific resolver in your controller: Option 1 - Using auditable_record_map (recommended for multiple actions): ───────────────────────────────────────────────────────────────────────── private def auditable_record_map { #{action}: -> { @your_instance_variable } } end Option 2 - Using class-level DSL: ───────────────────────────────────────────────────────────────────────── auditable_record_for :#{action}, -> { @your_instance_variable } Option 3 - Using a generic hook (for all actions): ───────────────────────────────────────────────────────────────────────── private def auditable_record @your_instance_variable end 📋 Available instance variables in controller: #{instance_vars_info.presence || ' (none found)'} ═══════════════════════════════════════════════════════════════════════════ ERROR # Always log the full detailed message Rails.logger.error() # Environment-specific behavior raise Dscf::Core::AuditableRecordNotFoundError, unless Rails.env.production? Rails.logger.error("[CRITICAL] Audit system failed to resolve #{model_class.name} for action :#{action} in #{self.class.name}") # Raise concise error that will halt the transaction raise Dscf::Core::AuditableRecordNotFoundError, "Critical Audit Failure: Could not resolve auditable record for #{model_class.name}##{action}. " \ "This transaction has been rolled back to prevent untracked data changes. " \ "Check application logs for detailed diagnostic information." end |
#perform_record_resolution ⇒ Object
Perform the actual record resolution logic
366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 |
# File 'app/controllers/concerns/dscf/core/auditable_controller.rb', line 366 def perform_record_resolution model_klass = _explicit_auditable_model || detect_model_class return nil unless model_klass.respond_to?(:ancestors) && model_klass.ancestors.include?(ActiveRecord::Base) current_action = action_name.to_sym # Priority 0: Action-specific resolver (explicit configuration via DSL) resolver = self.class.auditable_record_resolvers[current_action] if resolver begin record = instance_exec(&resolver) if record.is_a?(model_klass) Rails.logger.debug "Audit: Found record via action-specific resolver for :#{current_action}" return record elsif record.present? Rails.logger.warn "Audit: Action-specific resolver for :#{current_action} returned " \ "#{record.class.name}, expected #{model_klass.name}" end rescue StandardError => e Rails.logger.error "Audit: Action-specific resolver for :#{current_action} failed: #{e.}" end end # Priority 1: Instance method map (auditable_record_map) if respond_to?(:auditable_record_map, true) begin record_map = auditable_record_map if record_map.is_a?(Hash) && record_map[current_action] resolver_proc = record_map[current_action] record = instance_exec(&resolver_proc) if record.is_a?(model_klass) Rails.logger.debug "Audit: Found record via auditable_record_map for :#{current_action}" return record end end rescue StandardError => e Rails.logger.warn "Audit: auditable_record_map failed for :#{current_action}: #{e.}" end end # Priority 2: Generic hook method (auditable_record) if respond_to?(:auditable_record, true) record = auditable_record if record.is_a?(model_klass) Rails.logger.debug "Audit: Found record via auditable_record hook" return record end end # Priority 3: Check ALL instance variables for objects of the model class instance_variables.each do |ivar| # Skip internal audit instance variables next if ivar.to_s.start_with?("@__") obj = instance_variable_get(ivar) if obj.is_a?(model_klass) Rails.logger.debug "Audit: Found record via instance variable #{ivar}" return obj end end # Priority 4: Try standard Rails @model_name pattern (e.g., @business) ivar_name = "@#{model_klass.name.demodulize.underscore}" if instance_variable_defined?(ivar_name) obj = instance_variable_get(ivar_name) if obj.is_a?(model_klass) Rails.logger.debug "Audit: Found record via naming convention #{ivar_name}" return obj end end # Priority 5: Try finding by params[:id] (for show/update/delete actions) if params[:id].present? && current_action != :create begin record = model_klass.find(params[:id]) Rails.logger.debug "Audit: Found record via params[:id]" return record rescue ActiveRecord::RecordNotFound, ArgumentError # Record not found or invalid ID end end # Priority 6: For create actions, check if a record was just created if current_action == :create # Try to find the most recently created record # This is a fallback for controllers that don't set instance variables recent_record = model_klass.order(created_at: :desc).first if recent_record && recent_record.created_at > 5.seconds.ago Rails.logger.debug "Audit: Found record via recent creation" return recent_record end end # FAIL-FAST: Log helpful error message with configuration instructions log_resolution_failure(model_klass, current_action) nil rescue StandardError => e Rails.logger.error "Audit: Failed to resolve record for #{model_klass}: #{e.}\n#{e.backtrace.first(3).join("\n")}" nil end |
#resolve_auditable_record ⇒ Object
Resolve the main record being audited
359 360 361 362 363 |
# File 'app/controllers/concerns/dscf/core/auditable_controller.rb', line 359 def resolve_auditable_record return @resolve_auditable_record if defined?(@resolve_auditable_record) @resolve_auditable_record = perform_record_resolution end |