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
@@ -5,7 +5,7 @@ module Beskar
5
5
  # Service for locking user accounts based on risk scores
6
6
  #
7
7
  # This service provides a modular approach to account locking that can work
8
- # with Devise's lockable module or custom locking implementations. It keeps
8
+ # with Devise's lockable module or the Rails-native lock adapter. It keeps
9
9
  # Devise-specific code isolated for maintainability.
10
10
  #
11
11
  # @example Basic usage with Devise lockable
@@ -30,7 +30,7 @@ module Beskar
30
30
  @user = user
31
31
  @risk_score = risk_score
32
32
  @reason = reason
33
- @metadata = metadata
33
+ @metadata = metadata.deep_symbolize_keys
34
34
  end
35
35
 
36
36
  # Check if account should be locked based on configuration
@@ -39,6 +39,7 @@ module Beskar
39
39
  def should_lock?
40
40
  return false unless Beskar.configuration.risk_based_locking_enabled?
41
41
  return false unless user
42
+ return false unless RequestContext.enforce?(metadata[:ip_address])
42
43
  return false if user_already_locked?
43
44
 
44
45
  risk_score >= Beskar.configuration.risk_threshold
@@ -57,16 +58,19 @@ module Beskar
57
58
  # @return [Boolean] true if lock was successful, false otherwise
58
59
  def lock!
59
60
  return false unless user
61
+ return false unless RequestContext.enforce?(metadata[:ip_address])
60
62
 
61
- strategy = Beskar.configuration.lock_strategy
63
+ strategy = effective_strategy
62
64
 
63
65
  result = case strategy
64
66
  when :devise_lockable
65
67
  lock_with_devise_lockable
66
- when :custom
67
- lock_with_custom_strategy
68
+ when :rails_auth
69
+ NativeAccountLock.lock!(user, duration: Beskar.configuration.auto_unlock_time, metadata: metadata)
70
+ when :none
71
+ false
68
72
  else
69
- Rails.logger.warn "[Beskar::AccountLocker] Unknown lock strategy: #{strategy}"
73
+ Beskar::Logger.warn("Unknown lock strategy: #{strategy}", component: :AccountLocker)
70
74
  false
71
75
  end
72
76
 
@@ -89,18 +93,20 @@ module Beskar
89
93
  def unlock!
90
94
  return false unless user
91
95
 
92
- strategy = Beskar.configuration.lock_strategy
96
+ strategy = effective_strategy
93
97
 
94
98
  result = case strategy
95
99
  when :devise_lockable
96
100
  unlock_with_devise_lockable
97
- when :custom
98
- unlock_with_custom_strategy
101
+ when :rails_auth
102
+ NativeAccountLock.unlock!(user)
103
+ when :none
104
+ false
99
105
  else
100
106
  false
101
107
  end
102
108
 
103
- # Log unlock event for adaptive learning
109
+ # Log the administrative unlock; this does not establish trusted history.
104
110
  if result && Beskar.configuration.log_lock_events?
105
111
  log_unlock_event
106
112
  end
@@ -115,13 +121,32 @@ module Beskar
115
121
  user_already_locked?
116
122
  end
117
123
 
124
+ def supported?
125
+ case effective_strategy
126
+ when :devise_lockable then devise_lockable_available?
127
+ when :rails_auth then user.respond_to?(:beskar_access_locked?) && user.respond_to?(:sessions)
128
+ else false
129
+ end
130
+ end
131
+
118
132
  private
119
133
 
134
+ def effective_strategy
135
+ strategy = Beskar.configuration.lock_strategy
136
+ if strategy == :devise_lockable && user.respond_to?(:beskar_access_locked?)
137
+ :rails_auth
138
+ else
139
+ strategy
140
+ end
141
+ end
142
+
120
143
  # Check if user is already locked
121
144
  def user_already_locked?
122
145
  return false unless user
123
146
 
124
- if user.respond_to?(:access_locked?)
147
+ if user.respond_to?(:beskar_access_locked?)
148
+ user.beskar_access_locked?
149
+ elsif user.respond_to?(:access_locked?)
125
150
  user.access_locked?
126
151
  elsif user.respond_to?(:locked_at)
127
152
  user.locked_at.present?
@@ -133,61 +158,48 @@ module Beskar
133
158
  # Lock account using Devise's lockable module
134
159
  def lock_with_devise_lockable
135
160
  unless devise_lockable_available?
136
- Rails.logger.warn "[Beskar::AccountLocker] Devise lockable not available for #{user.class.name}"
161
+ Beskar::Logger.warn("Devise lockable not available for #{user.class.name}", component: :AccountLocker)
137
162
  return false
138
163
  end
139
164
 
140
165
  begin
141
166
  # Use Devise's lock_access! method
142
- user.lock_access!(send_instructions: false)
143
-
144
- # Set automatic unlock time if configured and supported
145
- if Beskar.configuration.auto_unlock_time && user.respond_to?(:locked_at=)
146
- user.update_column(:locked_at, Time.current)
167
+ unless user.lock_access!(send_instructions: false)
168
+ raise AuthenticationAttempt::Unavailable, "Account lock could not be persisted"
147
169
  end
148
170
 
149
- Rails.logger.info "[Beskar::AccountLocker] Locked account #{user.id} (#{user.class.name}) - Risk: #{risk_score}, Reason: #{reason}"
171
+ # Devise owns its unlock policy (unlock_strategy/unlock_in). Beskar's
172
+ # auto_unlock_time applies to Rails-native locks, not Devise columns.
173
+
174
+ Beskar::Logger.info("Locked account #{user.id} (#{user.class.name}) - Risk: #{risk_score}, Reason: #{reason}", component: :AccountLocker)
150
175
  true
176
+ rescue ActiveRecord::ActiveRecordError
177
+ raise AuthenticationAttempt::Unavailable, "Account lock could not be persisted"
178
+ rescue AuthenticationAttempt::Unavailable
179
+ raise
151
180
  rescue => e
152
- Rails.logger.error "[Beskar::AccountLocker] Failed to lock account: #{e.message}"
153
- false
181
+ Beskar::Logger.error("Failed to lock account (#{e.class})", component: :AccountLocker)
182
+ raise AuthenticationAttempt::Unavailable, "Account lock could not be persisted"
154
183
  end
155
184
  end
156
185
 
157
186
  # Unlock account using Devise's lockable module
158
187
  def unlock_with_devise_lockable
159
188
  unless devise_lockable_available?
160
- Rails.logger.warn "[Beskar::AccountLocker] Devise lockable not available for #{user.class.name}"
189
+ Beskar::Logger.warn("Devise lockable not available for #{user.class.name}", component: :AccountLocker)
161
190
  return false
162
191
  end
163
192
 
164
193
  begin
165
194
  user.unlock_access!
166
- Rails.logger.info "[Beskar::AccountLocker] Unlocked account #{user.id} (#{user.class.name})"
195
+ Beskar::Logger.info("Unlocked account #{user.id} (#{user.class.name})", component: :AccountLocker)
167
196
  true
168
197
  rescue => e
169
- Rails.logger.error "[Beskar::AccountLocker] Failed to unlock account: #{e.message}"
198
+ Beskar::Logger.error("Failed to unlock account: #{e.class}", component: :AccountLocker)
170
199
  false
171
200
  end
172
201
  end
173
202
 
174
- # Lock account using custom strategy (to be implemented by application)
175
- def lock_with_custom_strategy
176
- # Applications can implement this by:
177
- # 1. Adding a locked_by_beskar column to users table
178
- # 2. Checking this in authentication callbacks
179
- # 3. Implementing unlock logic
180
-
181
- Rails.logger.warn "[Beskar::AccountLocker] Custom lock strategy not implemented"
182
- false
183
- end
184
-
185
- # Unlock using custom strategy
186
- def unlock_with_custom_strategy
187
- Rails.logger.warn "[Beskar::AccountLocker] Custom unlock strategy not implemented"
188
- false
189
- end
190
-
191
203
  # Check if Devise lockable is available for this user
192
204
  def devise_lockable_available?
193
205
  defined?(Devise) &&
@@ -202,61 +214,58 @@ module Beskar
202
214
  return unless user.respond_to?(:security_events)
203
215
 
204
216
  begin
205
- event_type = lock_succeeded ? 'account_locked' : 'lock_attempted'
206
-
207
- user.security_events.create!(
208
- event_type: event_type,
209
- ip_address: metadata[:ip_address] || 'system',
210
- user_agent: metadata[:user_agent] || 'beskar_system',
211
- risk_score: risk_score,
212
- metadata: {
213
- reason: reason,
214
- risk_threshold: Beskar.configuration.risk_threshold,
215
- lock_strategy: Beskar.configuration.lock_strategy,
216
- auto_unlock_time: Beskar.configuration.auto_unlock_time,
217
- locked_at: Time.current.iso8601,
218
- lock_succeeded: lock_succeeded,
219
- additional_context: metadata
220
- }
221
- )
217
+ event_type = lock_succeeded ? "account_locked" : "lock_attempted"
218
+
219
+ Beskar::SecurityEvent.transaction(requires_new: true) do
220
+ Beskar::SecurityEvent.create!(
221
+ user_type: user.class.polymorphic_name, user_id: user.id,
222
+ event_type: event_type,
223
+ ip_address: metadata[:ip_address] || "system",
224
+ user_agent: metadata[:user_agent] || "beskar_system",
225
+ risk_score: risk_score,
226
+ metadata: {
227
+ reason: reason,
228
+ risk_threshold: Beskar.configuration.risk_threshold,
229
+ lock_strategy: effective_strategy,
230
+ auto_unlock_time: Beskar.configuration.auto_unlock_time,
231
+ locked_at: Time.current.iso8601,
232
+ lock_succeeded: lock_succeeded,
233
+ additional_context: metadata
234
+ }
235
+ )
236
+ end
222
237
  rescue => e
223
- Rails.logger.warn "[Beskar::AccountLocker] Failed to log lock event: #{e.message}"
238
+ Beskar::Logger.warn("Failed to log lock event: #{e.class}", component: :AccountLocker)
224
239
  end
225
240
  end
226
241
 
227
- # Log unlock event for adaptive learning
228
- # This helps establish patterns - if user unlocks and logs in successfully,
229
- # that context becomes "established" and trusted
242
+ # Log the administrative action without granting IP/device trust.
230
243
  def log_unlock_event
231
244
  return unless user.respond_to?(:security_events)
232
245
 
233
246
  begin
234
- user.security_events.create!(
235
- event_type: 'account_unlocked',
236
- ip_address: metadata[:ip_address] || 'system',
237
- user_agent: metadata[:user_agent] || 'beskar_system',
238
- risk_score: 0, # Unlock has no risk
239
- metadata: {
240
- unlocked_at: Time.current.iso8601,
241
- unlock_method: 'manual',
242
- additional_context: metadata
243
- }
244
- )
247
+ Beskar::SecurityEvent.transaction(requires_new: true) do
248
+ Beskar::SecurityEvent.create!(
249
+ user_type: user.class.polymorphic_name, user_id: user.id,
250
+ event_type: "account_unlocked",
251
+ ip_address: metadata[:ip_address] || "system",
252
+ user_agent: metadata[:user_agent] || "beskar_system",
253
+ risk_score: 0, # Unlock has no risk
254
+ metadata: {
255
+ unlocked_at: Time.current.iso8601,
256
+ unlock_method: "manual",
257
+ additional_context: metadata
258
+ }
259
+ )
260
+ end
245
261
  rescue => e
246
- Rails.logger.warn "[Beskar::AccountLocker] Failed to log unlock event: #{e.message}"
262
+ Beskar::Logger.warn("Failed to log unlock event: #{e.class}", component: :AccountLocker)
247
263
  end
248
264
  end
249
265
 
250
266
  # Notify user about account lock
251
267
  def notify_user
252
- # This would integrate with ActionMailer or notification system
253
- # For now, just log it
254
- Rails.logger.info "[Beskar::AccountLocker] User #{user.id} should be notified of account lock"
255
-
256
- # Future implementation:
257
- # if defined?(Beskar::AccountLockMailer)
258
- # Beskar::AccountLockMailer.account_locked(user, risk_score, reason).deliver_later
259
- # end
268
+ Notifications.enqueue(user, "account_locked")
260
269
  end
261
270
  end
262
271
  end
@@ -0,0 +1,36 @@
1
+ module Beskar
2
+ module Services
3
+ class AdministrativeAudit
4
+ class Unavailable < StandardError; end
5
+
6
+ def self.record!(actor:, reason:, request_id:, action:, target_type:, before_state: {}, after_state: {})
7
+ raise AdministrativeBans::InvalidInput, "Administrative actor must be an opaque identifier" unless AdministrativeBans.valid_actor?(actor)
8
+ unless reason.is_a?(String) && reason.strip.present? && reason.length <= 1000
9
+ raise AdministrativeBans::InvalidInput, "An administrative reason of 1 to 1000 characters is required"
10
+ end
11
+ AdministrativeAction.create!(actor: actor, reason: reason.strip, request_id: request_id,
12
+ operation_id: SecureRandom.uuid, action: action, target_type: target_type,
13
+ before_state: before_state, after_state: after_state)
14
+ end
15
+
16
+ def self.configuration_snapshot(config)
17
+ values = (Configuration::SECTIONS + [:monitor_only, :ip_whitelist]).to_h { |name| [name, config.public_send(name)] }
18
+ %i[authenticate_admin audit_actor authorize_admin authorize_configuration].each do |name|
19
+ values[name] = config.public_send(name).present? ? "[CALLBACK]" : nil
20
+ end
21
+ AuditData.metadata(configuration_value(values))
22
+ end
23
+
24
+ def self.configuration_value(value)
25
+ case value
26
+ when Hash then value.transform_values { |item| configuration_value(item) }
27
+ when Array then value.map { |item| configuration_value(item) }
28
+ when ActiveSupport::Duration then value.to_f
29
+ when Regexp then {pattern: value.source, options: value.options}
30
+ when Class then value.name
31
+ else value
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,104 @@
1
+ module Beskar
2
+ module Services
3
+ # All selected changes and their required history share one writer transaction.
4
+ # The existing ban coordination keys also serialize with automatic escalation.
5
+ class AdministrativeBans
6
+ class InvalidInput < ArgumentError; end
7
+ MAX_BATCH = 100
8
+ DURATIONS = {"1h" => 1.hour, "6h" => 6.hours, "24h" => 24.hours,
9
+ "7d" => 7.days, "30d" => 30.days}.freeze
10
+ SNAPSHOT_FIELDS = %w[id ip_address reason details permanent banned_at expires_at violation_count metadata].freeze
11
+ ACTION_NAMES = {"update" => "ban_updated", "unban" => "ban_unbanned", "extend" => "ban_extended", "make_permanent" => "ban_made_permanent"}.freeze
12
+ UPDATE_FIELDS = %w[ip_address reason details permanent expires_at violation_count metadata].freeze
13
+
14
+ def self.valid_actor?(actor)
15
+ actor.is_a?(String) && actor.match?(/\A[a-zA-Z0-9][a-zA-Z0-9:_.\/-]{0,199}\z/)
16
+ end
17
+
18
+ def initialize(actor:, reason:, request_id:)
19
+ unless self.class.valid_actor?(actor)
20
+ raise InvalidInput, "Administrative actor must be an opaque identifier"
21
+ end
22
+ unless reason.is_a?(String) && reason.strip.present? && reason.length <= 1000
23
+ raise InvalidInput, "An administrative reason of 1 to 1000 characters is required"
24
+ end
25
+ @context = {actor: actor, reason: reason.strip, request_id: AuditData.field(:request_id, request_id, limit: 200),
26
+ operation_id: SecureRandom.uuid}
27
+ end
28
+
29
+ def create!(ban)
30
+ raise InvalidInput, "Create requires a new ban" unless ban.new_record?
31
+ raise ActiveRecord::RecordInvalid, ban unless ban.valid?
32
+ SecurityState.mutate("ban:#{ban.ip_address}", ttl: 1.day) do
33
+ ban.save!
34
+ record!(ban, "ban_created", {})
35
+ end
36
+ ban
37
+ end
38
+
39
+ def change!(ids, action:, attributes: {}, duration: nil)
40
+ unless %w[update unban extend make_permanent].include?(action)
41
+ raise InvalidInput, "Unknown administrative action"
42
+ end
43
+ unless attributes.is_a?(Hash) && (attributes.keys.map(&:to_s) - UPDATE_FIELDS).empty?
44
+ raise InvalidInput, "Unsupported ban attributes"
45
+ end
46
+ extension = DURATIONS[duration] if action == "extend"
47
+ raise InvalidInput, "Unsupported extension duration" if action == "extend" && !extension
48
+ ids = normalize_ids(ids)
49
+ targets = BannedIp.where(id: ids).order(:id).pluck(:id, :ip_address)
50
+ raise ActiveRecord::RecordNotFound unless targets.size == ids.size
51
+
52
+ changed = 0
53
+ SecurityState.mutate(targets.map { |_, ip| "ban:#{ip}" }, ttl: 1.day) do
54
+ changed = 0 # The block can be retried after an optimistic conflict.
55
+ bans = BannedIp.where(id: ids).order(:id).lock.to_a
56
+ raise ActiveRecord::RecordNotFound unless bans.size == ids.size
57
+ raise InvalidInput, "Ban identity changed; reload before retrying" unless bans.map { |ban| [ban.id, ban.ip_address] } == targets
58
+ bans.each do |ban|
59
+ before = snapshot(ban)
60
+ case action
61
+ when "unban" then ban.destroy!
62
+ when "make_permanent" then ban.update!(permanent: true, expires_at: nil)
63
+ when "extend"
64
+ raise InvalidInput, "Cannot extend a permanent ban" if ban.permanent?
65
+ ban.update!(expires_at: [ban.expires_at || Time.current, Time.current].max + extension)
66
+ when "update"
67
+ ban.assign_attributes(attributes)
68
+ if ban.ip_address_changed?
69
+ ban.errors.add(:ip_address, "cannot be changed")
70
+ raise ActiveRecord::RecordInvalid, ban
71
+ end
72
+ ban.save!
73
+ end
74
+ after = ban.destroyed? ? {} : snapshot(ban)
75
+ # Filtered/bounded snapshots can look identical even when stored
76
+ # fields changed (for example subsecond deadlines or redacted data).
77
+ next unless ban.destroyed? || ban.saved_changes.except("updated_at").present?
78
+ record!(ban, ACTION_NAMES.fetch(action), before, after)
79
+ changed += 1
80
+ end
81
+ end
82
+ changed
83
+ end
84
+
85
+ private
86
+
87
+ def normalize_ids(ids)
88
+ values = Array(ids)
89
+ unless values.size.between?(1, MAX_BATCH) && values.all? { |id| id.to_s.match?(/\A[1-9]\d{0,18}\z/) && id.to_s.to_i <= 9_223_372_036_854_775_807 }
90
+ raise InvalidInput, "Select 1 to 100 valid ban IDs"
91
+ end
92
+ values.map(&:to_i).uniq.sort
93
+ end
94
+
95
+ def snapshot(ban)
96
+ AuditData.metadata(ban.attributes.slice(*SNAPSHOT_FIELDS))
97
+ end
98
+
99
+ def record!(ban, action, before, after = snapshot(ban))
100
+ AdministrativeAction.create!(@context.merge(action: action, target_id: ban.id, before_state: before, after_state: after))
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,72 @@
1
+ require "active_support/parameter_filter"
2
+
3
+ module Beskar
4
+ module Services
5
+ # Defense at capture and export boundaries, including legacy records. This
6
+ # cannot identify arbitrary secrets embedded in otherwise legitimate prose.
7
+ module AuditData
8
+ FILTERED = "[FILTERED]"
9
+ MAX_DEPTH = 10
10
+ MAX_NODES = 512
11
+ MAX_BYTES = 65_536
12
+ SENSITIVE_KEYS = /password|secret|token|authorization|cookie|session_id|csrf|exception_message|fullpath|matched_path/i
13
+
14
+ module_function
15
+
16
+ def metadata(value)
17
+ bounded = bound(value.is_a?(Hash) ? value : {}, depth: 0, budget: [MAX_NODES])
18
+ filters = [SENSITIVE_KEYS] + Array(Rails.application.config.filter_parameters)
19
+ filtered = ActiveSupport::ParameterFilter.new(filters).filter(bounded)
20
+ filtered = bound(filtered, depth: 0, budget: [MAX_NODES])
21
+ (filtered.to_json.bytesize <= MAX_BYTES) ? filtered : {"_truncated" => true}
22
+ end
23
+
24
+ def text(value, limit: 2048)
25
+ RequestContext.text(value, limit: limit)
26
+ end
27
+
28
+ def field(name, value, limit: 2048)
29
+ metadata(name.to_s => text(value, limit: limit))[name.to_s]
30
+ end
31
+
32
+ def user_email(user)
33
+ return unless user
34
+ if user.try(:email).present?
35
+ field(:email, user.email, limit: 320)
36
+ elsif user.try(:email_address).present?
37
+ field(:email, field(:email_address, user.email_address, limit: 320), limit: 320)
38
+ end
39
+ end
40
+
41
+ def bound(value, depth:, budget:)
42
+ return "[TRUNCATED]" if depth > MAX_DEPTH || (budget[0] -= 1) < 0
43
+ case value
44
+ when Hash
45
+ value.first(64).each_with_object({}) do |(key, child), result|
46
+ next unless key.is_a?(String) || key.is_a?(Symbol)
47
+ key = key.to_s
48
+ next if key.bytesize > 128
49
+ result[key] = bound(child, depth: depth + 1, budget: budget)
50
+ end
51
+ when Array then value.first(50).map { |child| bound(child, depth: depth + 1, budget: budget) }
52
+ when String, Symbol then text(value)
53
+ when Float then value.finite? ? value : nil
54
+ when Integer, TrueClass, FalseClass, NilClass then value
55
+ when Time, DateTime, Date then value.iso8601
56
+ else "[UNSUPPORTED]"
57
+ end
58
+ end
59
+
60
+ # CSV quoting handles delimiters, but does not neutralize spreadsheet
61
+ # formulas. Prefix dangerous textual cells, including obscured/fullwidth
62
+ # prefixes. JSON exports preserve text without this spreadsheet marker.
63
+ def csv_cell(value)
64
+ return value if value.is_a?(Numeric) || value.nil?
65
+ string = text(value)
66
+ probe = string.unicode_normalize(:nfkc)
67
+ dangerous = probe.match?(/\A[\p{Space}\p{Cf}\p{Cc}]*[=+\-@]/)
68
+ dangerous ? "text: #{string}" : string
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,31 @@
1
+ module Beskar
2
+ module Services
3
+ # Framework-neutral admission for token issuance, OAuth and custom APIs.
4
+ # The host verifies credentials in the block and returns the authenticated
5
+ # model (or nil). This method never issues a credential or grants access.
6
+ class Authentication
7
+ def self.authenticate(request, model:, scope:, credentials: {})
8
+ user = model.find_by(credentials) unless credentials.empty?
9
+ attempt = AuthenticationAttempt.reserve(request, model: model, scope: scope,
10
+ user: user, credentials: credentials)
11
+ unless attempt.allowed?
12
+ model.track_failed_authentication(request, scope, attempt: attempt)
13
+ return attempt
14
+ end
15
+ resource = yield
16
+ if resource
17
+ raise AuthenticationAttempt::Unavailable, "Authentication identity mismatch" unless resource.is_a?(model) && resource.persisted?
18
+ resource.track_authentication_event(request, :success, attempt: attempt, persist: false)
19
+ attempt.verify_generation!
20
+ attempt.persist_outcome!
21
+ else
22
+ model.track_failed_authentication(request, scope, attempt: attempt)
23
+ attempt.deny!(:invalid_credentials)
24
+ end
25
+ attempt
26
+ rescue ActiveRecord::ActiveRecordError
27
+ raise AuthenticationAttempt::Unavailable, "Authentication temporarily unavailable"
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,141 @@
1
+ require "digest"
2
+
3
+ module Beskar
4
+ module Services
5
+ # Request-local decisions are independent of optional SecurityEvent writes.
6
+ # Adapters reserve before password verification and pass the same attempt to
7
+ # the outcome callback, so a single attempt cannot consume capacity twice.
8
+ class AuthenticationAttempt
9
+ class Unavailable < StandardError; end
10
+
11
+ attr_reader :id, :scope, :rate_limit, :ip_address, :attempted_email, :request_path
12
+ attr_accessor :user, :event, :locked_now, :completed
13
+
14
+ def self.current(request, scope)
15
+ request.env.dig("beskar.authentication_attempts", scope.to_s) if request.respond_to?(:env) && request.env
16
+ end
17
+
18
+ def self.reserve(request, model:, scope:, user: nil, credentials: {}, cache: false)
19
+ existing = current(request, scope) if cache
20
+ return existing if existing
21
+
22
+ attempt = new(request, scope: scope, user: user, model: model, credentials: credentials)
23
+ if cache && request.respond_to?(:env) && request.env
24
+ (request.env["beskar.authentication_attempts"] ||= {})[scope.to_s] = attempt
25
+ end
26
+ attempt
27
+ rescue ActiveRecord::ActiveRecordError => error
28
+ Beskar::Logger.error("Authentication state unavailable (#{error.class})")
29
+ raise Unavailable, "Authentication temporarily unavailable"
30
+ end
31
+
32
+ def initialize(request, scope:, user:, model:, credentials:)
33
+ @id = SecureRandom.uuid
34
+ @scope = scope.to_s
35
+ @ip_address = RequestContext.ip(request)
36
+ @request_path = RequestContext.path(request)
37
+ @user = user
38
+ @generation = SessionRevocation.token(user) if user
39
+ identity = credentials.to_h.stringify_keys
40
+ @attempted_email = identity["email"] || identity["email_address"]
41
+ @locked_now = false
42
+ @completed = false
43
+ @identity_reserved = user || credentials.present?
44
+ @rate_limit = RateLimiter.check_authentication_attempt(request, :attempt, user,
45
+ account_key: user ? nil : self.class.credential_key(model, credentials))
46
+ @allowed = !RequestContext.enforce?(ip_address) || rate_limit[:allowed]
47
+ @reason = :rate_limit_exceeded unless @allowed
48
+ if user&.respond_to?(:beskar_access_locked?) && RequestContext.enforce?(ip_address) && user.beskar_access_locked?
49
+ deny!(:account_locked)
50
+ end
51
+ end
52
+
53
+ def self.credential_key(model, credentials)
54
+ return if credentials.empty?
55
+ normalized = credentials.to_h.sort_by { |key, _| key.to_s }.map do |key, value|
56
+ value = value.to_s
57
+ value = value.strip if !model.respond_to?(:strip_whitespace_keys) || model.strip_whitespace_keys.map(&:to_s).include?(key.to_s)
58
+ value = value.downcase if !model.respond_to?(:case_insensitive_keys) || model.case_insensitive_keys.map(&:to_s).include?(key.to_s)
59
+ [key.to_s, value]
60
+ end
61
+ "#{model.name}:credentials:#{Digest::SHA256.hexdigest(normalized.to_json)}"
62
+ end
63
+
64
+ def allowed?
65
+ @allowed
66
+ end
67
+
68
+ def session_token
69
+ @generation
70
+ end
71
+
72
+ # Opaque strategies cannot identify an account until verification succeeds.
73
+ # Charge that account once, independently of the already reserved IP tier.
74
+ def bind_user!(resource)
75
+ if user && user != resource
76
+ deny!(:identity_changed)
77
+ return
78
+ end
79
+ unless user
80
+ @user = resource
81
+ @generation = SessionRevocation.token(resource)
82
+ account_result = RateLimiter.reserve_account(resource, ip_address: ip_address)
83
+ if RequestContext.enforce?(ip_address) && !account_result[:allowed]
84
+ @rate_limit = account_result
85
+ deny!(:rate_limit_exceeded)
86
+ end
87
+ end
88
+ verify_generation!
89
+ if RequestContext.enforce?(ip_address)
90
+ locked = resource.respond_to?(:beskar_access_locked?) ? resource.beskar_access_locked? : (resource.respond_to?(:access_locked?) && resource.reload.access_locked?)
91
+ deny!(:account_locked) if locked
92
+ end
93
+ end
94
+
95
+ def verify_generation!
96
+ deny!(:session_revoked) if user && @generation != SessionRevocation.token(user)
97
+ end
98
+
99
+ def bind_identity!(model, credentials)
100
+ return if user || @identity_reserved || credentials.empty?
101
+ @identity_reserved = true
102
+ result = RateLimiter.reserve_account(nil, ip_address: ip_address,
103
+ account_key: self.class.credential_key(model, credentials))
104
+ if RequestContext.enforce?(ip_address) && !result[:allowed]
105
+ @rate_limit = result
106
+ deny!(:rate_limit_exceeded)
107
+ end
108
+ end
109
+
110
+ def persist_outcome!
111
+ return unless event && !event.persisted? && Beskar.configuration.track_successful_logins?
112
+ event.event_type = "authentication_blocked" unless allowed?
113
+ event.metadata = (event.metadata || {}).merge("authentication" => metadata)
114
+ user.send(:persist_authentication_audit, event)
115
+ user.analyze_suspicious_patterns_async if allowed? && Beskar.configuration.auto_analyze_patterns?
116
+ end
117
+
118
+ def deny!(reason)
119
+ @allowed = false
120
+ @reason = reason
121
+ end
122
+
123
+ def metadata
124
+ {"attempt_id" => id, "scope" => scope, "allowed" => allowed?,
125
+ "reason" => @reason&.to_s, "rate_limit_allowed" => rate_limit[:allowed], "locked_now" => !!locked_now}
126
+ end
127
+
128
+ def response
129
+ status = (@reason == :rate_limit_exceeded) ? 429 : 403
130
+ headers = {"content-type" => "application/json", "cache-control" => "no-store"}
131
+ headers["retry-after"] = [rate_limit[:retry_after].to_i, 1].max.to_s if status == 429
132
+ [status, headers, [{error: (status == 429) ? "Too many authentication attempts" : "Authentication denied"}.to_json]]
133
+ end
134
+
135
+ def self.unavailable_response
136
+ [503, {"content-type" => "application/json", "cache-control" => "no-store", "retry-after" => "60"},
137
+ [{error: "Authentication temporarily unavailable"}.to_json]]
138
+ end
139
+ end
140
+ end
141
+ end