mongoid-auditor 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mongoid
4
+ module Auditor
5
+ module InitAuditLog
6
+ extend ActiveSupport::Concern
7
+
8
+ MUTATION_METHODS = %w[POST PUT PATCH DELETE].freeze
9
+ included do
10
+ prepend_before_action :init_audit_store, if: -> { MUTATION_METHODS.include?(request.method) }
11
+ include ControllerAuditHelper
12
+ end
13
+
14
+ def init_audit_store
15
+ return unless ::Mongoid::Auditor.config.enabled && ::Mongoid::Auditor.config.log_models
16
+
17
+ set_current(:request_id, request.request_id)
18
+ set_current(:actor_id, current_user&.id&.to_s)
19
+ set_current(:path, "#{request.method} #{request.fullpath}")
20
+ set_current(:request_params, filter_sensitive_params(request.filtered_parameters))
21
+ set_current(:audit_logs, [])
22
+ set_current(:audit_sequence, 0)
23
+ end
24
+
25
+ def filter_sensitive_params(params)
26
+ skip_fields = ::Mongoid::Auditor.config.skip_password_fields || []
27
+ params.except(*skip_fields)
28
+ end
29
+
30
+ def set_current(key, value)
31
+ Mongoid::Auditor::Current.send("#{key}=", value)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mongoid
4
+ module Auditor
5
+ class AuditorJob < ActiveJob::Base
6
+ class_attribute :skip_audit, default: false
7
+
8
+ self.skip_audit = true
9
+
10
+ queue_as :default
11
+
12
+ def perform(state, exception = nil, entry = nil)
13
+ return unless config.enabled
14
+
15
+ case state.to_sym
16
+ when :model
17
+ log_model(entry)
18
+ when :controller
19
+ log_controller_error(exception, entry)
20
+ else
21
+ log_unknown_state(state)
22
+ end
23
+ rescue StandardError => e
24
+ log_failure(e)
25
+ raise
26
+ end
27
+
28
+ private
29
+
30
+ def config
31
+ ::Mongoid::Auditor.config
32
+ end
33
+
34
+ def log_model(entry)
35
+ Audit.log(entry) if config.log_models
36
+ end
37
+
38
+ def log_controller_error(exception, entry)
39
+ return unless config.log_controllers
40
+
41
+ Audit.send(:log_error, exception, entry)
42
+ end
43
+
44
+ def log_unknown_state(state)
45
+ logger&.error("AuditorJob unknown state: #{state}")
46
+ end
47
+
48
+ def log_failure(error)
49
+ logger&.error(
50
+ "AuditorJob failed: #{error.class} #{error.message}\n" \
51
+ "#{error.backtrace&.take(5)&.join("\n")}"
52
+ )
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mongoid
4
+ module Auditor
5
+ module AuditableJobError
6
+ def audit_job_exceptions(job)
7
+ @current_job = job
8
+ yield
9
+ rescue StandardError => e
10
+ audit_job_error(e)
11
+ raise
12
+ end
13
+
14
+ private
15
+
16
+ def audit_job_error(exception)
17
+ return unless ::Mongoid::Auditor.config.enabled
18
+
19
+ data = build_job_entry(exception)
20
+ ::Mongoid::Auditor::AuditorJob.perform_later(:controller, data[:error], data[:entry])
21
+ rescue StandardError => e
22
+ Rails.logger&.error("AuditableJobError failed: #{e.class} #{e.message}\n#{e.backtrace&.take(5)&.join("\n")}")
23
+ end
24
+
25
+ def build_job_entry(exception)
26
+ request_id = set_request_id
27
+ actor = 'BackgroundJobError'
28
+
29
+ {
30
+ entry: {
31
+ request_id: request_id,
32
+ actor: actor,
33
+ path: "Job #{@current_job.class.name}",
34
+ params: @current_job.arguments,
35
+ model_type: @current_job.class.name,
36
+ model_id: nil,
37
+ has_error: true,
38
+ event: 'perform',
39
+ dirties: {},
40
+ sequence: ::Mongoid::Auditor::Current.audit_sequence.to_i + 1
41
+ },
42
+ error: {
43
+ class: exception.class.to_s,
44
+ message: exception.message,
45
+ backtrace: exception.backtrace&.take(5)
46
+ }
47
+ }
48
+ end
49
+
50
+ def set_request_id
51
+ return unless @current_job
52
+
53
+ context = ::Mongoid::Auditor::RequestContext.find_by(job_id: @current_job.job_id)
54
+ context&.request_id || "#{@current_job.class.name}-#{@current_job.job_id}"
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mongoid
4
+ module Auditor
5
+ module InitJobAuditLog
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ include AuditableJobError
10
+
11
+ before_enqueue do |job|
12
+ store_request_context(job)
13
+ end
14
+
15
+ before_perform do |job|
16
+ ::Mongoid::Auditor::Current.reset_all
17
+
18
+ request_id_from_context(job)
19
+ init_job_audit_store(job) unless job.class.skip_audit
20
+ end
21
+
22
+ around_perform do |job, block|
23
+ audit_job_exceptions(job, &block)
24
+ clear_request_context(job)
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def store_request_context(job)
31
+ return if job.class.skip_audit
32
+
33
+ request_id = ::Mongoid::Auditor::Current.request_id
34
+ ::Mongoid::Auditor::RequestContext.create!(job_id: job.job_id, request_id: request_id)
35
+ rescue StandardError => e
36
+ Rails.logger&.error("Failed to store request context: #{e.class} #{e.message}")
37
+ end
38
+
39
+ def request_id_from_context(job)
40
+ context = ::Mongoid::Auditor::RequestContext.find_by(job_id: job.job_id)
41
+ ::Mongoid::Auditor::Current.request_id = context&.request_id
42
+ end
43
+
44
+ def init_job_audit_store(job)
45
+ set_current(:request_id, ::Mongoid::Auditor::Current.request_id || "Job #{job.class.name}-#{job.job_id}")
46
+ set_current(:actor_id, 'Background Job')
47
+
48
+ set_current(:path, "Job #{job.class.name}")
49
+ set_current(:request_params, job.arguments || [])
50
+
51
+ set_current(:audit_logs, [])
52
+ set_current(:audit_sequence, 0)
53
+ end
54
+
55
+ def set_current(key, value)
56
+ ::Mongoid::Auditor::Current.public_send("#{key}=", value)
57
+ end
58
+
59
+ def clear_request_context(job)
60
+ return if job.nil?
61
+
62
+ ::Mongoid::Auditor::RequestContext.where(job_id: job.job_id).delete_all
63
+ rescue StandardError => e
64
+ Rails.logger&.error("Failed to clear request context: #{e.class} #{e.message}")
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mongoid
4
+ module Auditor
5
+ class AuditLog
6
+ include Mongoid::Document
7
+ include Mongoid::Timestamps
8
+ include Mongoid::Auditor::AuditLogHelpers
9
+
10
+ field :request_id, type: String
11
+ field :actor, type: String
12
+
13
+ field :path, type: String
14
+ field :params, type: Hash, default: {}
15
+
16
+ field :model_type, type: String
17
+ field :model_id, type: String
18
+ field :has_error, type: Boolean, default: false
19
+ field :event, type: String
20
+ field :dirties, type: Hash, default: {}
21
+
22
+ # optional ordering/consistency helpers
23
+ field :sequence, type: Integer
24
+
25
+ field :error, type: Hash, default: nil
26
+ field :model_error, type: Hash, default: nil
27
+
28
+ # Indexes for performance
29
+ index({ request_id: 1 })
30
+ index({ model_type: 1, model_id: 1 })
31
+ index({ model_type: 1, model_id: 1, event: 1 })
32
+ index({ actor: 1 })
33
+ index({ has_error: 1, created_at: -1 })
34
+ index({ created_at: -1 })
35
+ index({ request_id: 1, sequence: 1 })
36
+
37
+ # Validations
38
+ validates :request_id, presence: true
39
+ validates :event, presence: true, inclusion: { in: %w[create update destroy error] }
40
+ validates :sequence, presence: true, numericality: { only_integer: true }
41
+
42
+ def model
43
+ return if model_type.blank? || model_id.blank?
44
+
45
+ model_type.constantize.find(model_id)
46
+ rescue StandardError
47
+ nil
48
+ end
49
+
50
+ # Prevent destructive operations in production - don't allow erasing audit history
51
+ if defined?(Rails) && Rails.env.production?
52
+ %w[destroy destroy! delete delete!].each do |method_name|
53
+ define_method(method_name) do
54
+ raise 'AuditLog is immutable in production. You cannot rewrite history!'
55
+ end
56
+ end
57
+
58
+ def self.delete_all(*)
59
+ raise 'AuditLog is immutable in production. You cannot rewrite history!'
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mongoid
4
+ module Auditor
5
+ module AuditLogHelpers
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ # Ensure we're using the namespaced AuditLog
10
+ end
11
+
12
+ class_methods do
13
+ # All audit logs that represent errors
14
+ def errors
15
+ where(has_error: true)
16
+ end
17
+
18
+ def record_logs(request_id)
19
+ where(request_id: request_id)
20
+ end
21
+
22
+ def created_errors_today
23
+ event_errors('create')
24
+ end
25
+
26
+ def updated_errors_today
27
+ event_errors('update')
28
+ end
29
+
30
+ def deleted_errors_today
31
+ event_errors('destroy')
32
+ end
33
+
34
+ def event_errors(event)
35
+ where(created_at: Time.current.all_day, has_error: true, event: event)
36
+ end
37
+
38
+ # Errors created today
39
+ def errors_today
40
+ errors.where(created_at: Time.current.all_day)
41
+ end
42
+
43
+ # Errors created in last N days
44
+ def errors_last(days)
45
+ errors.where(:created_at.gte => days.days.ago)
46
+ end
47
+
48
+ # Errors by specific actor
49
+ def errors_by(actor_id)
50
+ errors.where(actor: actor_id)
51
+ end
52
+
53
+ # Quick summary: class names and counts
54
+ def error_summary
55
+ errors.group_by { |log| log.error&.dig('class') || 'Unknown' }.transform_values(&:count)
56
+ end
57
+
58
+ def error_summary_today
59
+ errors_today.group_by { |log| log.error&.dig('class') || 'Unknown' }.transform_values(&:count)
60
+ end
61
+
62
+ def error_summary_last(days)
63
+ errors_last(days).group_by { |log| log.error&.dig('class') || 'Unknown' }.transform_values(&:count)
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mongoid
4
+ module Auditor
5
+ module Auditable
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ include Mongoid::Auditor::ModelLogsHelper
10
+
11
+ after_create :audit_create
12
+ after_update :audit_update
13
+ before_destroy :audit_destroy
14
+ end
15
+
16
+ MODEL_AUDIT = :model
17
+
18
+ private
19
+
20
+ def audit_create
21
+ before_attrs = {}
22
+ after_attrs = attributes.deep_stringify_keys
23
+ append_audit_entry('create', before_attrs, after_attrs)
24
+ end
25
+
26
+ def audit_update
27
+ return if previous_changes.blank?
28
+
29
+ before_attrs = previous_changes.transform_values(&:first).deep_stringify_keys
30
+ after_attrs = previous_changes.transform_values(&:last).deep_stringify_keys
31
+ append_audit_entry('update', before_attrs, after_attrs)
32
+ end
33
+
34
+ def audit_destroy
35
+ before_attrs = attributes.deep_stringify_keys
36
+ after_attrs = nil
37
+ append_audit_entry('destroy', before_attrs, after_attrs)
38
+ end
39
+
40
+ def append_audit_entry(event, before_attrs, after_attrs)
41
+ return unless ::Mongoid::Auditor.config.enabled && ::Mongoid::Auditor.config.log_models
42
+ return if ::Mongoid::Auditor::Current.path.nil?
43
+
44
+ entry = build_entry(event, before_attrs, after_attrs)
45
+ ::Mongoid::Auditor::AuditorJob.perform_later(MODEL_AUDIT, nil, entry)
46
+ rescue StandardError => e
47
+ logger&.error("Auditable#append_audit_entry failed for #{self.class.name}(#{id}): #{e.class} #{e.message}")
48
+ end
49
+
50
+ def logger
51
+ defined?(Rails) ? Rails.logger : nil
52
+ end
53
+
54
+ def build_entry(event, before_attrs, after_attrs)
55
+ ::Mongoid::Auditor::Current.audit_sequence ||= 0
56
+ seq = ::Mongoid::Auditor::Current.audit_sequence + 1
57
+ ::Mongoid::Auditor::Current.audit_sequence = seq
58
+
59
+ # Transform before/after attrs into { attribute: [before_value, after_value] } format
60
+ dirties = build_dirties_hash(before_attrs, after_attrs)
61
+
62
+ {
63
+ request_id: ::Mongoid::Auditor::Current.request_id,
64
+ actor: ::Mongoid::Auditor::Current.actor_id,
65
+ path: ::Mongoid::Auditor::Current.path,
66
+ params: ::Mongoid::Auditor::Current.request_params,
67
+ model_type: self.class.name,
68
+ model_id: id.to_s,
69
+ event: event,
70
+ dirties: dirties,
71
+ sequence: seq
72
+ }
73
+ end
74
+
75
+ def build_dirties_hash(before_attrs, after_attrs)
76
+ before_attrs ||= {}
77
+ after_attrs ||= {}
78
+
79
+ all_keys = (before_attrs.keys + after_attrs.keys).uniq
80
+
81
+ all_keys.each_with_object({}) do |key, result|
82
+ before_value = before_attrs[key]
83
+ after_value = after_attrs[key]
84
+ result[key] = [before_value, after_value]
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mongoid
4
+ module Auditor
5
+ module ModelLogsHelper
6
+ extend ActiveSupport::Concern
7
+
8
+ def logs
9
+ ::Mongoid::Auditor::AuditLog.where(model_id: id.to_s, model_type: self.class.name)
10
+ end
11
+
12
+ def create_errors_today
13
+ error_events('create')
14
+ end
15
+
16
+ def update_errors_today
17
+ error_events('update')
18
+ end
19
+
20
+ def destroy_errors_today
21
+ error_events('destroy')
22
+ end
23
+
24
+ def created_today
25
+ audit_events('create')
26
+ end
27
+
28
+ def updated_today
29
+ audit_events('update')
30
+ end
31
+
32
+ def deleted_today
33
+ audit_events('destroy')
34
+ end
35
+
36
+ def error_events(event)
37
+ ::Mongoid::Auditor::AuditLog.where(model_type: self.class.name, has_error: true, event: event, created_at: today_time)
38
+ end
39
+
40
+ def audit_events(event)
41
+ ::Mongoid::Auditor::AuditLog.where(model_type: self.class.name, event: event, created_at: today_time)
42
+ end
43
+
44
+ def error
45
+ ::Mongoid::Auditor::AuditLog.where(model_id: id.to_s, model_type: self.class.name, has_error: true)
46
+ end
47
+
48
+ def today_time
49
+ @today_time ||= Time.current.all_day
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mongoid
4
+ module Auditor
5
+ class Current < ActiveSupport::CurrentAttributes
6
+ attribute :request_id, :actor_id,
7
+ :path, :request_params, :audit_logs, :audit_sequence
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mongoid
4
+ module Auditor
5
+ class RequestContext
6
+ include Mongoid::Document
7
+ include Mongoid::Timestamps
8
+
9
+ field :job_id, type: String
10
+ field :request_id, type: String
11
+
12
+ index({ job_id: 1 }, { unique: true })
13
+ index({ request_id: 1 })
14
+ index({ created_at: 1 }, { expire_after_seconds: 86_400 }) # Auto-delete after 24 hours
15
+
16
+ validates :request_id, presence: true
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mongoid
4
+ module Auditor
5
+ class Railtie < Rails::Railtie
6
+ initializer 'mongoid_auditor.load_app' do
7
+ require 'mongoid/auditor/generators/install_generator' if defined?(Rails::Generators)
8
+ # Load controller concerns
9
+ ActiveSupport.on_load(:action_controller) do
10
+ require File.expand_path('../../../lib/mongoid/auditor/helpers/controller_helpers/init_audit_log', __dir__)
11
+ require File.expand_path('../../../lib/mongoid/auditor/helpers/controller_helpers/audit_controller_error_log', __dir__)
12
+ require File.expand_path('../../../lib/mongoid/auditor/helpers/controller_helpers/controller_audit_helper', __dir__)
13
+ end
14
+
15
+ # Load job concerns
16
+ ActiveSupport.on_load(:active_job) do
17
+ require File.expand_path('../../../lib/mongoid/auditor/helpers/jobs/concerns/init_job_audit_log', __dir__)
18
+ require File.expand_path('../../../lib/mongoid/auditor/helpers/jobs/concerns/auditable_job_error', __dir__)
19
+ require File.expand_path('../../../lib/mongoid/auditor/helpers/jobs/auditor_job', __dir__)
20
+ end
21
+
22
+ # Load models and model concerns
23
+ ActiveSupport.on_load(:mongoid) do
24
+ require File.expand_path('../../../lib/mongoid/auditor/helpers/models/concerns/audit_log_helpers', __dir__)
25
+ require File.expand_path('../../../lib/mongoid/auditor/helpers/models/concerns/auditable', __dir__)
26
+ require File.expand_path('../../../lib/mongoid/auditor/helpers/models/concerns/model_logs_helper', __dir__)
27
+ require File.expand_path('../../../lib/mongoid/auditor/helpers/models/audit_log', __dir__)
28
+ require File.expand_path('../../../lib/mongoid/auditor/helpers/models/request_context', __dir__)
29
+ require File.expand_path('../../../lib/mongoid/auditor/helpers/models/current', __dir__)
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mongoid
4
+ module Auditor
5
+ VERSION = '0.1.0'
6
+ end
7
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mongoid/auditor/version'
4
+ require 'mongoid/auditor/configuration'
5
+
6
+ require 'active_support/current_attributes'
7
+ require 'mongoid/auditor/audit'
8
+ require 'mongoid/auditor/railtie' if defined?(Rails)
9
+
10
+ module Mongoid
11
+ module Auditor
12
+ class << self
13
+ attr_accessor :config
14
+
15
+ def configure
16
+ self.config ||= Configuration.new
17
+ yield(config)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,6 @@
1
+ module Mongoid
2
+ module Auditor
3
+ VERSION: String
4
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
5
+ end
6
+ end