beskar 0.0.2 → 0.2.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.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +274 -0
  3. data/README.md +412 -204
  4. data/app/channels/concerns/beskar/channels/session_security.rb +46 -0
  5. data/app/controllers/beskar/administrative_actions_controller.rb +16 -0
  6. data/app/controllers/beskar/application_controller.rb +214 -0
  7. data/app/controllers/beskar/banned_ips_controller.rb +255 -0
  8. data/app/controllers/beskar/dashboard_controller.rb +62 -0
  9. data/app/controllers/beskar/security_events_controller.rb +164 -0
  10. data/app/controllers/concerns/beskar/controllers/audit_export.rb +54 -0
  11. data/app/controllers/concerns/beskar/controllers/security_tracking.rb +76 -48
  12. data/app/controllers/concerns/beskar/controllers/session_security.rb +29 -0
  13. data/app/jobs/beskar/notification_job.rb +33 -0
  14. data/app/mailers/beskar/security_mailer.rb +59 -0
  15. data/app/models/beskar/administrative_action.rb +41 -0
  16. data/app/models/beskar/banned_ip.rb +105 -105
  17. data/app/models/beskar/security_event.rb +51 -4
  18. data/app/models/beskar/security_state.rb +58 -0
  19. data/app/services/beskar/banned_ip_manager.rb +88 -0
  20. data/app/views/beskar/administrative_actions/index.html.erb +33 -0
  21. data/app/views/beskar/administrative_actions/show.html.erb +21 -0
  22. data/app/views/beskar/banned_ips/edit.html.erb +195 -0
  23. data/app/views/beskar/banned_ips/index.html.erb +319 -0
  24. data/app/views/beskar/banned_ips/new.html.erb +190 -0
  25. data/app/views/beskar/banned_ips/review.html.erb +24 -0
  26. data/app/views/beskar/banned_ips/show.html.erb +304 -0
  27. data/app/views/beskar/dashboard/index.html.erb +280 -0
  28. data/app/views/beskar/security_events/index.html.erb +302 -0
  29. data/app/views/beskar/security_events/show.html.erb +293 -0
  30. data/app/views/beskar/shared/_export_form.html.erb +10 -0
  31. data/app/views/layouts/beskar/_behavior.html.erb +121 -0
  32. data/app/views/layouts/beskar/application.html.erb +581 -6
  33. data/config/routes.rb +30 -0
  34. data/db/migrate/20251016000001_create_beskar_security_events.rb +3 -3
  35. data/db/migrate/20260910000001_create_beskar_security_states.rb +14 -0
  36. data/db/migrate/20260911000001_create_beskar_administrative_actions.rb +22 -0
  37. data/db/migrate/20260911000002_expand_administrative_action_targets.rb +6 -0
  38. data/docs/README.md +73 -0
  39. data/docs/archive/project-documentation.md +659 -0
  40. data/docs/audits/project-review.md +437 -0
  41. data/docs/audits/repair-status.md +216 -0
  42. data/docs/guides/audit-and-waf.md +175 -0
  43. data/docs/guides/audit-lifecycle.md +172 -0
  44. data/docs/guides/authentication.md +213 -0
  45. data/docs/guides/configuration.md +182 -0
  46. data/docs/guides/dashboard-and-search.md +251 -0
  47. data/docs/guides/notifications-and-recovery.md +157 -0
  48. data/docs/guides/risk-scoring.md +116 -0
  49. data/docs/operations/monitor-only-mode.md +85 -0
  50. data/docs/operations/security-hardening.md +167 -0
  51. data/docs/operations/state-storage.md +144 -0
  52. data/docs/research/rust-performance-assessment.md +69 -0
  53. data/lib/beskar/configuration.rb +105 -20
  54. data/lib/beskar/configuration_validator.rb +188 -0
  55. data/lib/beskar/devise_authentication.rb +24 -0
  56. data/lib/beskar/engine.rb +21 -88
  57. data/lib/beskar/logger.rb +288 -0
  58. data/lib/beskar/middleware/request_analyzer.rb +133 -99
  59. data/lib/beskar/models/security_trackable_authenticable.rb +76 -97
  60. data/lib/beskar/models/security_trackable_devise.rb +34 -25
  61. data/lib/beskar/models/security_trackable_generic.rb +171 -214
  62. data/lib/beskar/risk_level.rb +22 -0
  63. data/lib/beskar/services/account_locker.rb +90 -81
  64. data/lib/beskar/services/administrative_audit.rb +36 -0
  65. data/lib/beskar/services/administrative_bans.rb +104 -0
  66. data/lib/beskar/services/audit_data.rb +72 -0
  67. data/lib/beskar/services/authentication.rb +31 -0
  68. data/lib/beskar/services/authentication_attempt.rb +141 -0
  69. data/lib/beskar/services/ban_expiry.rb +28 -0
  70. data/lib/beskar/services/device_detector.rb +32 -41
  71. data/lib/beskar/services/event_search.rb +58 -0
  72. data/lib/beskar/services/geolocation_service.rb +83 -114
  73. data/lib/beskar/services/ip_whitelist.rb +31 -40
  74. data/lib/beskar/services/location_assessment.rb +109 -0
  75. data/lib/beskar/services/native_account_lock.rb +82 -0
  76. data/lib/beskar/services/notifications.rb +46 -0
  77. data/lib/beskar/services/rate_limiter.rb +99 -125
  78. data/lib/beskar/services/request_context.rb +64 -0
  79. data/lib/beskar/services/risk_assessment.rb +58 -0
  80. data/lib/beskar/services/session_revocation.rb +62 -0
  81. data/lib/beskar/services/waf.rb +311 -198
  82. data/lib/beskar/services/waf_request.rb +60 -0
  83. data/lib/beskar/version.rb +1 -1
  84. data/lib/beskar/warden_authentication.rb +53 -0
  85. data/lib/beskar.rb +54 -4
  86. data/lib/generators/beskar/install/install_generator.rb +158 -0
  87. data/lib/generators/beskar/install/templates/initializer.rb.tt +261 -0
  88. data/lib/tasks/beskar_tasks.rake +25 -20
  89. metadata +93 -12
  90. data/lib/beskar/templates/beskar_initializer.rb +0 -107
@@ -0,0 +1,164 @@
1
+ require "csv"
2
+
3
+ module Beskar
4
+ class SecurityEventsController < ApplicationController
5
+ include Controllers::AuditExport
6
+
7
+ def index
8
+ @events = Beskar::SecurityEvent.preload(:user).order(created_at: :desc, id: :desc)
9
+
10
+ # Apply filters
11
+ apply_filters!
12
+
13
+ # Paginate results
14
+ @pagination = paginate(@events, per_page: params[:per_page]&.to_i || 25)
15
+ @events = @pagination[:records]
16
+
17
+ # Get filter options for dropdowns
18
+ @event_types = Beskar::SecurityEvent.distinct.pluck(:event_type).sort
19
+ @risk_levels = RiskLevel.filter_options
20
+ end
21
+
22
+ def show
23
+ @event = Beskar::SecurityEvent.find(params[:id])
24
+
25
+ # Find related events
26
+ @related_events = Beskar::SecurityEvent
27
+ .preload(:user)
28
+ .where.not(id: @event.id)
29
+ .where(ip_address: @event.ip_address)
30
+ .order(created_at: :desc, id: :desc)
31
+ .limit(10)
32
+
33
+ # Get user's recent events if user exists
34
+ if @event.user.present?
35
+ @user_events = @event.user.security_events
36
+ .where.not(id: @event.id)
37
+ .order(created_at: :desc)
38
+ .limit(10)
39
+ end
40
+
41
+ # Check if IP is banned
42
+ @ip_ban = Beskar::BannedIp.find_by(ip_address: @event.ip_address)
43
+ end
44
+
45
+ def export
46
+ @events = Beskar::SecurityEvent.preload(:user)
47
+
48
+ # Apply same filters as index
49
+ apply_filters!
50
+ records = export_records(@events)
51
+ return if performed?
52
+
53
+ respond_to do |format|
54
+ format.csv do
55
+ send_data generate_csv(records),
56
+ filename: "security-events-#{Date.current}.csv",
57
+ type: "text/csv"
58
+ end
59
+ format.json do
60
+ render json: records.map { |event| export_event(event) }
61
+ end
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ def export_event(event)
68
+ attributes = event.attributes.slice("id", "event_type", "ip_address", "user_id", "user_type",
69
+ "user_agent", "attempted_email", "risk_score", "metadata", "created_at")
70
+ if event.user
71
+ attributes["user"] = {id: event.user.id, email: Services::AuditData.user_email(event.user)}
72
+ end
73
+ attributes
74
+ end
75
+
76
+ def apply_filters!
77
+ # Filter by event type
78
+ if params[:event_type].present?
79
+ @events = @events.where(event_type: params[:event_type])
80
+ end
81
+
82
+ # Filter by risk level
83
+ if params[:risk_level].present?
84
+ @events = @events.with_risk_level(params[:risk_level])
85
+ end
86
+
87
+ # Filter by IP address
88
+ if params[:ip_address].present?
89
+ @events = @events.where("ip_address LIKE ?", "%#{params[:ip_address]}%")
90
+ end
91
+
92
+ # Filter by user email (if attempted_email is stored)
93
+ if params[:email].present?
94
+ @events = Services::EventSearch.new(@events).email(params[:email])
95
+ end
96
+
97
+ # Filter by date range
98
+ if params[:start_date].present?
99
+ begin
100
+ start_date = Date.parse(params[:start_date])
101
+ @events = @events.where("created_at >= ?", start_date.beginning_of_day)
102
+ rescue ArgumentError
103
+ # Invalid date, ignore filter
104
+ end
105
+ end
106
+
107
+ if params[:end_date].present?
108
+ begin
109
+ end_date = Date.parse(params[:end_date])
110
+ @events = @events.where("created_at <= ?", end_date.end_of_day)
111
+ rescue ArgumentError
112
+ # Invalid date, ignore filter
113
+ end
114
+ end
115
+
116
+ # Quick time range filters
117
+ if params[:time_range].present?
118
+ start_time = case params[:time_range]
119
+ when "last_hour"
120
+ 1.hour.ago
121
+ when "last_24h"
122
+ 24.hours.ago
123
+ when "last_7d"
124
+ 7.days.ago
125
+ when "last_30d"
126
+ 30.days.ago
127
+ end
128
+
129
+ @events = @events.where("created_at >= ?", start_time) if start_time
130
+ end
131
+
132
+ # Filter by threat level
133
+ if params[:threats_only] == "true"
134
+ @events = @events.high_risk
135
+ end
136
+
137
+ # Search in metadata
138
+ if params[:search].present?
139
+ @events = Services::EventSearch.new(@events).search(params[:search])
140
+ end
141
+ end
142
+
143
+ def generate_csv(events)
144
+ require "csv"
145
+
146
+ CSV.generate(headers: true, force_quotes: true) do |csv|
147
+ csv << ["ID", "Date/Time", "Event Type", "IP Address", "User", "Risk Score", "User Agent", "Details"]
148
+
149
+ events.each do |event|
150
+ csv << [
151
+ event.id,
152
+ event.created_at.strftime("%Y-%m-%d %H:%M:%S"),
153
+ event.event_type,
154
+ event.ip_address,
155
+ audit_user_label(event),
156
+ event.risk_score,
157
+ event.user_agent,
158
+ event.details || event.metadata&.dig("message") || "-"
159
+ ].map { |value| Services::AuditData.csv_cell(value) }
160
+ end
161
+ end
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,54 @@
1
+ module Beskar
2
+ module Controllers
3
+ module AuditExport
4
+ extend ActiveSupport::Concern
5
+
6
+ LIMIT = 1000
7
+
8
+ included do
9
+ rescue_from Services::AdministrativeAudit::Unavailable, ActiveRecord::ActiveRecordError do |error|
10
+ raise error unless action_name == "export"
11
+ Beskar::Logger.warn("Audit export unavailable (#{error.class})")
12
+ render plain: "Audit export unavailable", status: :service_unavailable
13
+ end
14
+ rescue_from Services::AdministrativeBans::InvalidInput do |error|
15
+ render plain: error.message, status: :unprocessable_content
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def export_records(relation)
22
+ response.set_header("Cache-Control", "private, no-store")
23
+ response.set_header("X-Content-Type-Options", "nosniff")
24
+ unless request.format.csv? || request.format.json?
25
+ head :not_acceptable
26
+ return
27
+ end
28
+ cursor = params[:before_id]
29
+ if cursor.present?
30
+ unless cursor.is_a?(String) && cursor.match?(/\A[1-9]\d{0,18}\z/) && cursor.to_i <= 9_223_372_036_854_775_807
31
+ render json: {error: "before_id must be a positive record ID"}, status: :unprocessable_content
32
+ return
33
+ end
34
+ relation = relation.where(id: ...cursor.to_i)
35
+ end
36
+ records = relation.reorder(id: :desc).limit(LIMIT + 1).to_a
37
+ truncated = records.length > LIMIT
38
+ records = records.first(LIMIT)
39
+ # Persist before disclosing any body. This records an authorized export
40
+ # preparation, not proof the client finished receiving the response.
41
+ Services::AdministrativeAudit.record!(actor: administrative_actor!,
42
+ reason: request.headers["X-Beskar-Audit-Reason"] || params[:audit_reason], request_id: request.request_id,
43
+ action: "audit_exported", target_type: relation.klass.name.demodulize,
44
+ after_state: {format: request.format.to_s, count: records.length, highest_id: records.first&.id,
45
+ lowest_id: records.last&.id, before_id: cursor, truncated: truncated,
46
+ filters: request.query_parameters.except("audit_reason", "controller", "action", "format")})
47
+ response.set_header("X-Beskar-Export-Limit", LIMIT.to_s)
48
+ response.set_header("X-Beskar-Export-Truncated", truncated.to_s)
49
+ response.set_header("X-Beskar-Next-Cursor", records.last.id.to_s) if truncated
50
+ records
51
+ end
52
+ end
53
+ end
54
+ end
@@ -1,69 +1,97 @@
1
1
  module Beskar
2
2
  module Controllers
3
- # Controller concern for tracking Rails 8 authentication events
4
- #
5
- # Usage in SessionsController:
6
- # class SessionsController < ApplicationController
7
- # include Beskar::Controllers::SecurityTracking
8
- #
9
- # def create
10
- # if user = User.authenticate_by(params.permit(:email_address, :password))
11
- # track_authentication_success(user)
12
- # start_new_session_for user
13
- # redirect_to after_authentication_url
14
- # else
15
- # track_authentication_failure(User, :user)
16
- # redirect_to new_session_path, alert: "Try another email address or password."
17
- # end
18
- # end
19
- # end
3
+ # Rails-native integration:
4
+ # before_action -> { admit_authentication_attempt(User, :user) }, only: :create
5
+ # After User.authenticate_by succeeds:
6
+ # return unless complete_authentication(user) { start_new_session_for(user) }
7
+ # Existing-session readers must also call user.beskar_access_allowed?(request).
20
8
  module SecurityTracking
21
9
  extend ActiveSupport::Concern
22
10
 
23
11
  private
24
12
 
25
- # Track successful authentication for a user
26
- # This should be called after verifying credentials but before creating session
13
+ # Run before password verification. Credentials are identity fields only;
14
+ # passwords must never be included in a persistent counter key.
15
+ def admit_authentication_attempt(model, scope = :user, credentials: nil)
16
+ values = params[scope].is_a?(ActionController::Parameters) ? params[scope] : params
17
+ key = model.attribute_names.include?("email_address") ? :email_address : :email
18
+ credentials ||= {key => values[key]}
19
+ user = model.find_by(credentials)
20
+ @beskar_authentication_attempt = Services::AuthenticationAttempt.reserve(request,
21
+ model: model, scope: scope, user: user, credentials: credentials, cache: true)
22
+ return true if @beskar_authentication_attempt.allowed?
23
+
24
+ model.track_failed_authentication(request, scope, attempt: @beskar_authentication_attempt)
25
+ render_authentication_response(@beskar_authentication_attempt.response)
26
+ false
27
+ rescue Services::AuthenticationAttempt::Unavailable, ActiveRecord::ActiveRecordError
28
+ render_authentication_response(Services::AuthenticationAttempt.unavailable_response)
29
+ false
30
+ end
31
+
32
+ # The preferred Rails-native API. Final audit persistence happens after the
33
+ # session guard, so a denied session is never learned as a successful login.
34
+ def complete_authentication(user)
35
+ return false unless user
36
+ attempt = native_authentication_attempt(user)
37
+ user.track_authentication_event(request, :success, attempt: attempt, persist: false)
38
+ if attempt.allowed?
39
+ admitted = user.with_beskar_session(request, generation: attempt.session_token) { yield }
40
+ attempt.deny!(:account_locked) unless admitted
41
+ end
42
+ if attempt.event
43
+ attempt.event.event_type = "authentication_blocked" unless attempt.allowed?
44
+ attempt.event.metadata = (attempt.event.metadata || {}).merge("authentication" => attempt.metadata)
45
+ if Beskar.configuration.track_successful_logins?
46
+ user.send(:persist_authentication_audit, attempt.event)
47
+ user.analyze_suspicious_patterns_async if attempt.allowed? && Beskar.configuration.auto_analyze_patterns?
48
+ end
49
+ end
50
+ render_authentication_response(attempt.response) unless attempt.allowed?
51
+ attempt.allowed?
52
+ rescue Services::AuthenticationAttempt::Unavailable, ActiveRecord::ActiveRecordError
53
+ render_authentication_response(Services::AuthenticationAttempt.unavailable_response)
54
+ false
55
+ end
56
+
57
+ # Compatibility API for custom integrations; callers must honor the boolean
58
+ # result and guard session creation. Prefer complete_authentication above.
27
59
  def track_authentication_success(user)
28
- return unless user
29
- return unless Beskar.configuration.track_successful_logins?
60
+ return false unless user
61
+ attempt = native_authentication_attempt(user)
62
+ user.track_authentication_event(request, :success, attempt: attempt)
63
+ render_authentication_response(attempt.response) unless attempt.allowed?
64
+ attempt.allowed?
65
+ rescue Services::AuthenticationAttempt::Unavailable
66
+ render_authentication_response(Services::AuthenticationAttempt.unavailable_response)
67
+ false
68
+ end
30
69
 
31
- user.track_authentication_event(request, :success)
32
- Rails.logger.info "[Beskar] Tracked successful authentication for user #{user.id}"
33
- rescue => e
34
- Rails.logger.error "[Beskar] Failed to track authentication success: #{e.message}"
70
+ def native_authentication_attempt(user)
71
+ @beskar_authentication_attempt ||= Services::AuthenticationAttempt.reserve(request,
72
+ model: user.class, scope: user.class.name.underscore, user: user, cache: true)
35
73
  end
36
74
 
37
- # Track failed authentication attempt
38
- # This should be called when authentication fails
39
75
  def track_authentication_failure(model_class, scope = :user)
40
- return unless Beskar.configuration.track_failed_logins?
76
+ model_class.track_failed_authentication(request, scope, attempt: @beskar_authentication_attempt)
77
+ end
41
78
 
42
- model_class.track_failed_authentication(request, scope)
43
- Rails.logger.info "[Beskar] Tracked failed authentication for scope #{scope}"
44
- rescue => e
45
- Rails.logger.error "[Beskar] Failed to track authentication failure: #{e.message}"
79
+ def render_authentication_response(response)
80
+ return if performed?
81
+ status, headers, body = response
82
+ headers.each { |name, value| self.response.set_header(name, value) }
83
+ render body: body.join, status: status
46
84
  end
47
85
 
48
- # Track logout event
49
86
  def track_logout(user)
50
- return unless user
51
- return unless Beskar.configuration.security_tracking_enabled?
52
-
87
+ return unless user && Beskar.configuration.security_tracking_enabled?
53
88
  user.security_events.create!(
54
- event_type: "logout",
55
- ip_address: request.ip,
56
- user_agent: request.user_agent,
57
- metadata: {
58
- timestamp: Time.current.iso8601,
59
- session_id: request.session.id,
60
- request_path: request.path
61
- },
62
- risk_score: 0
89
+ event_type: "logout", ip_address: Services::RequestContext.ip(request),
90
+ user_agent: Services::RequestContext.text(request.user_agent), risk_score: 0,
91
+ metadata: {timestamp: Time.current.iso8601, request_path: Services::RequestContext.path(request)}
63
92
  )
64
- Rails.logger.info "[Beskar] Tracked logout for user #{user.id}"
65
- rescue => e
66
- Rails.logger.error "[Beskar] Failed to track logout: #{e.message}"
93
+ rescue => error
94
+ Beskar::Logger.warn("Logout audit unavailable (#{error.class})")
67
95
  end
68
96
  end
69
97
  end
@@ -0,0 +1,29 @@
1
+ module Beskar
2
+ module Controllers
3
+ # Include in the host API base controller, after host credential resolution.
4
+ # The two readers are host methods; the generation must come from a verified
5
+ # credential, not a freshly queried value or an unsigned request parameter.
6
+ module SessionSecurity
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ before_action :require_beskar_session!
11
+ end
12
+
13
+ private
14
+
15
+ def require_beskar_session!
16
+ unless respond_to?(:beskar_authenticated_user, true) && respond_to?(:beskar_authenticated_generation, true)
17
+ raise Services::AuthenticationAttempt::Unavailable, "Session adapter is not configured"
18
+ end
19
+ unless Services::SessionRevocation.allowed?(beskar_authenticated_user, request: request, token: beskar_authenticated_generation)
20
+ response.headers["Cache-Control"] = "no-store"
21
+ head :unauthorized
22
+ end
23
+ rescue Services::AuthenticationAttempt::Unavailable
24
+ response.headers["Cache-Control"] = "no-store"
25
+ head :service_unavailable
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,33 @@
1
+ module Beskar
2
+ class NotificationJob < ApplicationJob
3
+ self.log_arguments = false
4
+ queue_as :beskar_notifications
5
+
6
+ retry_on Services::Notifications::DeliveryError, wait: :polynomially_longer, attempts: 5
7
+
8
+ def perform(user_type:, user_id:, kind:, recipient_index: nil)
9
+ return unless Services::Notifications.enabled?(kind)
10
+ ConfigurationValidator.new(Beskar.configuration).validate_notifications!
11
+ delivery = SecurityMailer.notification(user_type, user_id, kind, recipient_index)
12
+ message = delivery.message
13
+ # A deleted account or removed recipient has no message to deliver.
14
+ return unless message.to.present?
15
+ unless message.perform_deliveries && message.raise_delivery_errors
16
+ raise Services::Notifications::DeliveryError, "Security mail requires enabled deliveries and delivery errors"
17
+ end
18
+ raise Services::Notifications::DeliveryError, "Security mail delivery was aborted" unless delivery.deliver_now
19
+ rescue => error
20
+ # Prevent Active Job's failure/retry logs from dumping transport credentials
21
+ # or arbitrary host callback exception messages (including exception causes).
22
+ raise Services::Notifications::DeliveryError, "Security notification delivery failed (#{error.class})", cause: nil
23
+ end
24
+
25
+ def retry_job(options = {})
26
+ result = super
27
+ raise Services::Notifications::DeliveryError, "Security notification retry was not enqueued" unless result
28
+ result
29
+ rescue => error
30
+ raise Services::Notifications::DeliveryError, "Security notification retry failed (#{error.class})", cause: nil
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,59 @@
1
+ module Beskar
2
+ class SecurityMailer < ApplicationMailer
3
+ layout false
4
+
5
+ # Only scalar identity fields enter Action Mailer's process instrumentation.
6
+ def notification(user_type, user_id, kind, recipient_index = nil)
7
+ return unless Services::Notifications.enabled?(kind)
8
+ ConfigurationValidator.new(Beskar.configuration).validate_notifications!
9
+ model = user_type.safe_constantize if user_type.is_a?(String)
10
+ unless model.is_a?(Class) && model < ActiveRecord::Base && model.reflect_on_association(:security_events)
11
+ raise Configuration::Error, "Notification account model is unsupported"
12
+ end
13
+ user = model.find_by(id: user_id)
14
+ return unless user
15
+
16
+ settings = Beskar.configuration.notifications
17
+ if kind == "security_team_reset"
18
+ return unless recipient_index.is_a?(Integer) && recipient_index >= 0
19
+ recipient = settings[:security_team_recipients][recipient_index]
20
+ return unless recipient
21
+ subject = "Security notice: account password invalidated"
22
+ body = "A precautionary emergency password reset was performed for #{user.class.base_class.name} ID #{user.id}.\n" \
23
+ "Review the authenticated security dashboard and follow your incident response policy.\n" \
24
+ "This notification is not proof that the account was compromised.\n"
25
+ else
26
+ recipient = user.respond_to?(:email_address) ? user.email_address : user.email
27
+ unless ConfigurationValidator.mailbox?(recipient)
28
+ raise Configuration::Error, "Notification account requires one valid email address"
29
+ end
30
+ subject, explanation = if kind == "account_locked"
31
+ ["Security notice: account locked", "Your account was locked as a security precaution."]
32
+ else
33
+ ["Security notice: password reset required", "Your password was invalidated as a security precaution. Your previous password no longer works."]
34
+ end
35
+ body = "#{explanation}\n\nVisit the application's recovery page for help:\n#{settings[:recovery_url]}\n\n" \
36
+ "A password reset does not necessarily unlock your account. Contact your administrator if it remains locked.\n" \
37
+ "This notice does not contain a password or a sign-in link.\n"
38
+ end
39
+ mail(from: settings[:from], to: recipient, subject: subject, body: body, content_type: "text/plain")
40
+ rescue => error
41
+ raise Services::Notifications::DeliveryError, "Security mail preparation failed (#{error.class})", cause: nil
42
+ end
43
+
44
+ # Keep this mailer's delivery instrumentation free of addresses, message
45
+ # bodies, and raw transport errors. Do not change logging for host mailers.
46
+ def self.deliver_mail(message)
47
+ ActiveSupport::Notifications.instrument("deliver.beskar_notification", mailer: name) do
48
+ # Recheck after host interceptors; Mail otherwise silently skips delivery
49
+ # or swallows transport errors when these flags are disabled.
50
+ unless message.perform_deliveries && message.raise_delivery_errors
51
+ raise Services::Notifications::DeliveryError, "Security mail delivery is disabled"
52
+ end
53
+ yield
54
+ rescue => error
55
+ raise Services::Notifications::DeliveryError, "Security mail transport failed (#{error.class})", cause: nil
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,41 @@
1
+ module Beskar
2
+ class AdministrativeAction < ApplicationRecord
3
+ attribute :before_state, default: -> { {} }
4
+ attribute :after_state, default: -> { {} }
5
+
6
+ ACTIONS = %w[ban_created ban_updated ban_unbanned ban_extended ban_made_permanent audit_exported configuration_changed].freeze
7
+ before_validation :sanitize_audit_fields
8
+ after_find :sanitize_audit_fields
9
+
10
+ validates :actor, :operation_id, :request_id, :reason, presence: true
11
+ validates :actor, :request_id, length: {maximum: 200}
12
+ validates :reason, length: {maximum: 1000}
13
+ validates :action, inclusion: {in: ACTIONS}
14
+ validates :target_type, inclusion: {in: %w[BannedIp SecurityEvent Configuration]}
15
+ validates :target_id, numericality: {only_integer: true, greater_than: 0}, allow_nil: true
16
+ validates :target_id, presence: true, if: -> { action&.start_with?("ban_") }
17
+
18
+ # Reject ordinary instance saves/updates and destruction. Low-level counter,
19
+ # bulk and SQL APIs can bypass this; this is not tamper-proof storage.
20
+ def readonly?
21
+ persisted? || super
22
+ end
23
+
24
+ # Active Record deliberately bypasses readonly? in its instance delete API.
25
+ def delete
26
+ raise ActiveRecord::ReadOnlyRecord, "Administrative history is append-only" if persisted?
27
+ super
28
+ end
29
+
30
+ private
31
+
32
+ def sanitize_audit_fields
33
+ {actor: 200, request_id: 200, reason: 1000}.each do |field, limit|
34
+ self[field] = Services::AuditData.field(field, self[field], limit: limit) if has_attribute?(field)
35
+ end
36
+ %i[before_state after_state].each do |field|
37
+ self[field] = Services::AuditData.metadata(self[field]) if has_attribute?(field)
38
+ end
39
+ end
40
+ end
41
+ end