beskar 0.1.0 → 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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +150 -19
  3. data/README.md +142 -122
  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 +69 -25
  7. data/app/controllers/beskar/banned_ips_controller.rb +116 -141
  8. data/app/controllers/beskar/dashboard_controller.rb +20 -28
  9. data/app/controllers/beskar/security_events_controller.rb +37 -55
  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 +91 -132
  17. data/app/models/beskar/security_event.rb +37 -4
  18. data/app/models/beskar/security_state.rb +58 -0
  19. data/app/services/beskar/banned_ip_manager.rb +16 -6
  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 +25 -89
  23. data/app/views/beskar/banned_ips/index.html.erb +20 -62
  24. data/app/views/beskar/banned_ips/new.html.erb +18 -138
  25. data/app/views/beskar/banned_ips/review.html.erb +24 -0
  26. data/app/views/beskar/banned_ips/show.html.erb +9 -15
  27. data/app/views/beskar/dashboard/index.html.erb +4 -4
  28. data/app/views/beskar/security_events/index.html.erb +8 -15
  29. data/app/views/beskar/security_events/show.html.erb +6 -20
  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 +9 -76
  33. data/config/routes.rb +10 -21
  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 +84 -13
  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 +30 -35
  58. data/lib/beskar/middleware/request_analyzer.rb +41 -82
  59. data/lib/beskar/models/security_trackable_authenticable.rb +72 -93
  60. data/lib/beskar/models/security_trackable_devise.rb +32 -23
  61. data/lib/beskar/models/security_trackable_generic.rb +169 -212
  62. data/lib/beskar/risk_level.rb +22 -0
  63. data/lib/beskar/services/account_locker.rb +85 -76
  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 +164 -280
  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 +53 -4
  86. data/lib/generators/beskar/install/install_generator.rb +36 -36
  87. data/lib/generators/beskar/install/templates/initializer.rb.tt +104 -20
  88. data/lib/tasks/beskar_tasks.rake +15 -19
  89. metadata +60 -8
@@ -1,9 +1,11 @@
1
- require 'csv'
1
+ require "csv"
2
2
 
3
3
  module Beskar
4
4
  class SecurityEventsController < ApplicationController
5
+ include Controllers::AuditExport
6
+
5
7
  def index
6
- @events = Beskar::SecurityEvent.preload(:user).order(created_at: :desc)
8
+ @events = Beskar::SecurityEvent.preload(:user).order(created_at: :desc, id: :desc)
7
9
 
8
10
  # Apply filters
9
11
  apply_filters!
@@ -14,7 +16,7 @@ module Beskar
14
16
 
15
17
  # Get filter options for dropdowns
16
18
  @event_types = Beskar::SecurityEvent.distinct.pluck(:event_type).sort
17
- @risk_levels = ['low', 'medium', 'high', 'critical']
19
+ @risk_levels = RiskLevel.filter_options
18
20
  end
19
21
 
20
22
  def show
@@ -22,9 +24,10 @@ module Beskar
22
24
 
23
25
  # Find related events
24
26
  @related_events = Beskar::SecurityEvent
27
+ .preload(:user)
25
28
  .where.not(id: @event.id)
26
29
  .where(ip_address: @event.ip_address)
27
- .order(created_at: :desc)
30
+ .order(created_at: :desc, id: :desc)
28
31
  .limit(10)
29
32
 
30
33
  # Get user's recent events if user exists
@@ -44,24 +47,32 @@ module Beskar
44
47
 
45
48
  # Apply same filters as index
46
49
  apply_filters!
50
+ records = export_records(@events)
51
+ return if performed?
47
52
 
48
53
  respond_to do |format|
49
54
  format.csv do
50
- send_data generate_csv(@events),
55
+ send_data generate_csv(records),
51
56
  filename: "security-events-#{Date.current}.csv",
52
- type: 'text/csv'
57
+ type: "text/csv"
53
58
  end
54
59
  format.json do
55
- render json: @events.as_json(
56
- include: { user: { only: [:id, :email] } },
57
- except: [:updated_at]
58
- )
60
+ render json: records.map { |event| export_event(event) }
59
61
  end
60
62
  end
61
63
  end
62
64
 
63
65
  private
64
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
+
65
76
  def apply_filters!
66
77
  # Filter by event type
67
78
  if params[:event_type].present?
@@ -70,18 +81,7 @@ module Beskar
70
81
 
71
82
  # Filter by risk level
72
83
  if params[:risk_level].present?
73
- @events = case params[:risk_level]
74
- when 'low'
75
- @events.where("risk_score < 30")
76
- when 'medium'
77
- @events.where("risk_score BETWEEN 30 AND 60")
78
- when 'high'
79
- @events.where("risk_score BETWEEN 61 AND 85")
80
- when 'critical'
81
- @events.where("risk_score > 85")
82
- else
83
- @events
84
- end
84
+ @events = @events.with_risk_level(params[:risk_level])
85
85
  end
86
86
 
87
87
  # Filter by IP address
@@ -91,9 +91,7 @@ module Beskar
91
91
 
92
92
  # Filter by user email (if attempted_email is stored)
93
93
  if params[:email].present?
94
- # Search in attempted_email column and metadata (avoid joining polymorphic)
95
- @events = @events.where("attempted_email LIKE ? OR metadata LIKE ?",
96
- "%#{params[:email]}%", "%#{params[:email]}%")
94
+ @events = Services::EventSearch.new(@events).email(params[:email])
97
95
  end
98
96
 
99
97
  # Filter by date range
@@ -118,63 +116,47 @@ module Beskar
118
116
  # Quick time range filters
119
117
  if params[:time_range].present?
120
118
  start_time = case params[:time_range]
121
- when 'last_hour'
119
+ when "last_hour"
122
120
  1.hour.ago
123
- when 'last_24h'
121
+ when "last_24h"
124
122
  24.hours.ago
125
- when 'last_7d'
123
+ when "last_7d"
126
124
  7.days.ago
127
- when 'last_30d'
125
+ when "last_30d"
128
126
  30.days.ago
129
- else
130
- nil
131
127
  end
132
128
 
133
129
  @events = @events.where("created_at >= ?", start_time) if start_time
134
130
  end
135
131
 
136
132
  # Filter by threat level
137
- if params[:threats_only] == 'true'
133
+ if params[:threats_only] == "true"
138
134
  @events = @events.high_risk
139
135
  end
140
136
 
141
137
  # Search in metadata
142
138
  if params[:search].present?
143
- search_term = "%#{params[:search]}%"
144
- # Use database-agnostic approach for metadata search
145
- if ActiveRecord::Base.connection.adapter_name == 'PostgreSQL'
146
- @events = @events.where(
147
- "ip_address LIKE ? OR user_agent LIKE ? OR attempted_email LIKE ? OR metadata::text LIKE ?",
148
- search_term, search_term, search_term, search_term
149
- )
150
- else
151
- # For SQLite and other databases, search in regular columns
152
- # SQLite's JSON support varies by version, so we'll search in standard columns
153
- @events = @events.where(
154
- "ip_address LIKE ? OR user_agent LIKE ? OR attempted_email LIKE ? OR event_type LIKE ?",
155
- search_term, search_term, search_term, search_term
156
- )
157
- end
139
+ @events = Services::EventSearch.new(@events).search(params[:search])
158
140
  end
159
141
  end
160
142
 
161
143
  def generate_csv(events)
162
- require 'csv'
144
+ require "csv"
163
145
 
164
- CSV.generate(headers: true) do |csv|
165
- csv << ['ID', 'Date/Time', 'Event Type', 'IP Address', 'User', 'Risk Score', 'User Agent', 'Details']
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"]
166
148
 
167
- events.find_each do |event|
149
+ events.each do |event|
168
150
  csv << [
169
151
  event.id,
170
- event.created_at.strftime('%Y-%m-%d %H:%M:%S'),
152
+ event.created_at.strftime("%Y-%m-%d %H:%M:%S"),
171
153
  event.event_type,
172
154
  event.ip_address,
173
- event.user&.try(:email) || event.attempted_email || '-',
155
+ audit_user_label(event),
174
156
  event.risk_score,
175
157
  event.user_agent,
176
- event.details || event.metadata&.dig("message") || '-'
177
- ]
158
+ event.details || event.metadata&.dig("message") || "-"
159
+ ].map { |value| Services::AuditData.csv_cell(value) }
178
160
  end
179
161
  end
180
162
  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
- Beskar::Logger.info("Tracked successful authentication for user #{user.id}")
33
- rescue => e
34
- Beskar::Logger.error("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
- Beskar::Logger.info("Tracked failed authentication for scope #{scope}")
44
- rescue => e
45
- Beskar::Logger.error("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
- Beskar::Logger.info("Tracked logout for user #{user.id}")
65
- rescue => e
66
- Beskar::Logger.error("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