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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +274 -0
- data/README.md +412 -204
- data/app/channels/concerns/beskar/channels/session_security.rb +46 -0
- data/app/controllers/beskar/administrative_actions_controller.rb +16 -0
- data/app/controllers/beskar/application_controller.rb +214 -0
- data/app/controllers/beskar/banned_ips_controller.rb +255 -0
- data/app/controllers/beskar/dashboard_controller.rb +62 -0
- data/app/controllers/beskar/security_events_controller.rb +164 -0
- data/app/controllers/concerns/beskar/controllers/audit_export.rb +54 -0
- data/app/controllers/concerns/beskar/controllers/security_tracking.rb +76 -48
- data/app/controllers/concerns/beskar/controllers/session_security.rb +29 -0
- data/app/jobs/beskar/notification_job.rb +33 -0
- data/app/mailers/beskar/security_mailer.rb +59 -0
- data/app/models/beskar/administrative_action.rb +41 -0
- data/app/models/beskar/banned_ip.rb +105 -105
- data/app/models/beskar/security_event.rb +51 -4
- data/app/models/beskar/security_state.rb +58 -0
- data/app/services/beskar/banned_ip_manager.rb +88 -0
- data/app/views/beskar/administrative_actions/index.html.erb +33 -0
- data/app/views/beskar/administrative_actions/show.html.erb +21 -0
- data/app/views/beskar/banned_ips/edit.html.erb +195 -0
- data/app/views/beskar/banned_ips/index.html.erb +319 -0
- data/app/views/beskar/banned_ips/new.html.erb +190 -0
- data/app/views/beskar/banned_ips/review.html.erb +24 -0
- data/app/views/beskar/banned_ips/show.html.erb +304 -0
- data/app/views/beskar/dashboard/index.html.erb +280 -0
- data/app/views/beskar/security_events/index.html.erb +302 -0
- data/app/views/beskar/security_events/show.html.erb +293 -0
- data/app/views/beskar/shared/_export_form.html.erb +10 -0
- data/app/views/layouts/beskar/_behavior.html.erb +121 -0
- data/app/views/layouts/beskar/application.html.erb +581 -6
- data/config/routes.rb +30 -0
- data/db/migrate/20251016000001_create_beskar_security_events.rb +3 -3
- data/db/migrate/20260910000001_create_beskar_security_states.rb +14 -0
- data/db/migrate/20260911000001_create_beskar_administrative_actions.rb +22 -0
- data/db/migrate/20260911000002_expand_administrative_action_targets.rb +6 -0
- data/docs/README.md +73 -0
- data/docs/archive/project-documentation.md +659 -0
- data/docs/audits/project-review.md +437 -0
- data/docs/audits/repair-status.md +216 -0
- data/docs/guides/audit-and-waf.md +175 -0
- data/docs/guides/audit-lifecycle.md +172 -0
- data/docs/guides/authentication.md +213 -0
- data/docs/guides/configuration.md +182 -0
- data/docs/guides/dashboard-and-search.md +251 -0
- data/docs/guides/notifications-and-recovery.md +157 -0
- data/docs/guides/risk-scoring.md +116 -0
- data/docs/operations/monitor-only-mode.md +85 -0
- data/docs/operations/security-hardening.md +167 -0
- data/docs/operations/state-storage.md +144 -0
- data/docs/research/rust-performance-assessment.md +69 -0
- data/lib/beskar/configuration.rb +105 -20
- data/lib/beskar/configuration_validator.rb +188 -0
- data/lib/beskar/devise_authentication.rb +24 -0
- data/lib/beskar/engine.rb +21 -88
- data/lib/beskar/logger.rb +288 -0
- data/lib/beskar/middleware/request_analyzer.rb +133 -99
- data/lib/beskar/models/security_trackable_authenticable.rb +76 -97
- data/lib/beskar/models/security_trackable_devise.rb +34 -25
- data/lib/beskar/models/security_trackable_generic.rb +171 -214
- data/lib/beskar/risk_level.rb +22 -0
- data/lib/beskar/services/account_locker.rb +90 -81
- data/lib/beskar/services/administrative_audit.rb +36 -0
- data/lib/beskar/services/administrative_bans.rb +104 -0
- data/lib/beskar/services/audit_data.rb +72 -0
- data/lib/beskar/services/authentication.rb +31 -0
- data/lib/beskar/services/authentication_attempt.rb +141 -0
- data/lib/beskar/services/ban_expiry.rb +28 -0
- data/lib/beskar/services/device_detector.rb +32 -41
- data/lib/beskar/services/event_search.rb +58 -0
- data/lib/beskar/services/geolocation_service.rb +83 -114
- data/lib/beskar/services/ip_whitelist.rb +31 -40
- data/lib/beskar/services/location_assessment.rb +109 -0
- data/lib/beskar/services/native_account_lock.rb +82 -0
- data/lib/beskar/services/notifications.rb +46 -0
- data/lib/beskar/services/rate_limiter.rb +99 -125
- data/lib/beskar/services/request_context.rb +64 -0
- data/lib/beskar/services/risk_assessment.rb +58 -0
- data/lib/beskar/services/session_revocation.rb +62 -0
- data/lib/beskar/services/waf.rb +311 -198
- data/lib/beskar/services/waf_request.rb +60 -0
- data/lib/beskar/version.rb +1 -1
- data/lib/beskar/warden_authentication.rb +53 -0
- data/lib/beskar.rb +54 -4
- data/lib/generators/beskar/install/install_generator.rb +158 -0
- data/lib/generators/beskar/install/templates/initializer.rb.tt +261 -0
- data/lib/tasks/beskar_tasks.rake +25 -20
- metadata +93 -12
- data/lib/beskar/templates/beskar_initializer.rb +0 -107
|
@@ -1,152 +1,152 @@
|
|
|
1
|
+
require "ipaddr"
|
|
2
|
+
|
|
1
3
|
module Beskar
|
|
2
4
|
class BannedIp < ApplicationRecord
|
|
3
|
-
# Serialize metadata as JSON
|
|
4
5
|
serialize :metadata, coder: JSON
|
|
5
6
|
|
|
6
7
|
validates :ip_address, presence: true, uniqueness: true
|
|
7
|
-
validates :reason, presence: true
|
|
8
|
-
validates :
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
8
|
+
validates :reason, :banned_at, presence: true
|
|
9
|
+
validates :expires_at, presence: true, unless: :permanent?
|
|
10
|
+
validates :violation_count, numericality: {only_integer: true, greater_than: 0}
|
|
11
|
+
validate :valid_ip_address
|
|
12
|
+
|
|
13
|
+
after_initialize { self.metadata ||= {} if has_attribute?(:metadata) }
|
|
14
|
+
before_validation :normalize_attributes
|
|
15
|
+
before_validation :sanitize_audit_fields
|
|
16
|
+
after_find :sanitize_audit_fields
|
|
17
|
+
after_commit :clear_legacy_cache
|
|
18
|
+
|
|
19
|
+
# Permanent is authoritative even for legacy rows with an expired timestamp.
|
|
20
|
+
scope :active, -> { where("permanent = ? OR expires_at > ?", true, Time.current) }
|
|
16
21
|
scope :permanent, -> { where(permanent: true) }
|
|
17
22
|
scope :temporary, -> { where(permanent: false) }
|
|
18
|
-
scope :expired, -> { where("expires_at
|
|
23
|
+
scope :expired, -> { temporary.where("expires_at <= ?", Time.current) }
|
|
19
24
|
scope :by_reason, ->(reason) { where(reason: reason) }
|
|
20
25
|
|
|
21
|
-
# Check if a ban is currently active
|
|
22
26
|
def active?
|
|
23
27
|
permanent? || (expires_at.present? && expires_at > Time.current)
|
|
24
28
|
end
|
|
25
29
|
|
|
26
|
-
|
|
30
|
+
def sanitize_audit_fields
|
|
31
|
+
self.reason = Services::AuditData.field(:reason, reason, limit: 100) if has_attribute?(:reason)
|
|
32
|
+
self.details = Services::AuditData.field(:details, details) if has_attribute?(:details)
|
|
33
|
+
self.metadata = Services::AuditData.metadata(metadata) if has_attribute?(:metadata)
|
|
34
|
+
end
|
|
35
|
+
private :sanitize_audit_fields
|
|
36
|
+
|
|
27
37
|
def expired?
|
|
28
38
|
!permanent? && expires_at.present? && expires_at <= Time.current
|
|
29
39
|
end
|
|
30
40
|
|
|
31
|
-
# Extend ban duration (for repeat offenders)
|
|
32
41
|
def extend_ban!(additional_time = nil)
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
save!
|
|
38
|
-
elsif additional_time
|
|
39
|
-
self.expires_at = [expires_at || Time.current, Time.current].max + additional_time
|
|
40
|
-
save!
|
|
41
|
-
else
|
|
42
|
-
# Calculate exponential backoff based on violation count
|
|
43
|
-
# 1 hour, 6 hours, 24 hours, 7 days, permanent
|
|
44
|
-
duration = case violation_count
|
|
45
|
-
when 1 then 1.hour
|
|
46
|
-
when 2 then 6.hours
|
|
47
|
-
when 3 then 24.hours
|
|
48
|
-
when 4 then 7.days
|
|
49
|
-
else
|
|
50
|
-
self.permanent = true
|
|
51
|
-
nil
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
if duration
|
|
55
|
-
self.expires_at = Time.current + duration
|
|
56
|
-
end
|
|
42
|
+
validate_duration!(additional_time)
|
|
43
|
+
SecurityState.mutate("ban:#{ip_address}", ttl: 1.day) do
|
|
44
|
+
reload(lock: true)
|
|
45
|
+
apply_extension(additional_time)
|
|
57
46
|
save!
|
|
58
47
|
end
|
|
59
48
|
end
|
|
60
49
|
|
|
61
|
-
# Unban an IP address
|
|
62
50
|
def unban!
|
|
63
|
-
destroy
|
|
51
|
+
destroy!
|
|
64
52
|
end
|
|
65
53
|
|
|
66
|
-
# Class methods for ban management
|
|
67
54
|
class << self
|
|
68
|
-
# Ban an IP address
|
|
69
55
|
def ban!(ip_address, reason:, duration: nil, permanent: false, details: nil, metadata: {})
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
#
|
|
77
|
-
|
|
56
|
+
raise ArgumentError, "Ban duration must be positive" if duration && !duration.to_f.positive?
|
|
57
|
+
raise ArgumentError, "Ban requires a single IP address" if ip_address.to_s.include?("/")
|
|
58
|
+
ip_address = IPAddr.new(ip_address.to_s).to_s
|
|
59
|
+
|
|
60
|
+
SecurityState.mutate("ban:#{ip_address}", ttl: 1.day) do
|
|
61
|
+
# The coordination lookup may establish a MySQL REPEATABLE READ
|
|
62
|
+
# snapshot before another worker commits. Locking the state row does
|
|
63
|
+
# not refresh that snapshot: read the ban with a current lock too.
|
|
64
|
+
banned_ip = lock.find_or_initialize_by(ip_address: ip_address)
|
|
65
|
+
if banned_ip.persisted?
|
|
66
|
+
banned_ip.permanent = true if permanent
|
|
67
|
+
banned_ip.send(:apply_extension, duration)
|
|
68
|
+
banned_ip.reason = reason
|
|
69
|
+
banned_ip.details = details if details
|
|
78
70
|
banned_ip.metadata = banned_ip.metadata.deep_stringify_keys.merge(metadata.deep_stringify_keys)
|
|
71
|
+
else
|
|
72
|
+
banned_ip.assign_attributes(
|
|
73
|
+
reason: reason, banned_at: Time.current, permanent: permanent,
|
|
74
|
+
expires_at: permanent ? nil : Time.current + (duration || 1.hour),
|
|
75
|
+
details: details, metadata: metadata
|
|
76
|
+
)
|
|
79
77
|
end
|
|
80
78
|
banned_ip.save!
|
|
81
|
-
|
|
82
|
-
# New ban
|
|
83
|
-
banned_ip.assign_attributes(
|
|
84
|
-
reason: reason,
|
|
85
|
-
banned_at: Time.current,
|
|
86
|
-
expires_at: permanent ? nil : (Time.current + (duration || 1.hour)),
|
|
87
|
-
permanent: permanent,
|
|
88
|
-
details: details,
|
|
89
|
-
metadata: metadata
|
|
90
|
-
)
|
|
91
|
-
banned_ip.save!
|
|
79
|
+
banned_ip
|
|
92
80
|
end
|
|
93
|
-
|
|
94
|
-
# Update cache
|
|
95
|
-
cache_key = "beskar:banned_ip:#{ip_address}"
|
|
96
|
-
Rails.cache.write(cache_key, true, expires_in: permanent ? nil : (duration || 1.hour))
|
|
97
|
-
|
|
98
|
-
banned_ip
|
|
99
81
|
end
|
|
100
82
|
|
|
101
|
-
#
|
|
83
|
+
# Cache eviction, rollback, stale values, or process-local caches cannot
|
|
84
|
+
# change enforcement. All workers consult the same indexed database.
|
|
102
85
|
def banned?(ip_address)
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
return false if cached_result == false
|
|
108
|
-
|
|
109
|
-
# Check database
|
|
110
|
-
banned_record = active.find_by(ip_address: ip_address)
|
|
111
|
-
is_banned = banned_record&.active? || false
|
|
112
|
-
|
|
113
|
-
# Update cache
|
|
114
|
-
if is_banned && banned_record
|
|
115
|
-
ttl = banned_record.permanent? ? 30.days : (banned_record.expires_at - Time.current).to_i
|
|
116
|
-
Rails.cache.write(cache_key, true, expires_in: ttl)
|
|
117
|
-
else
|
|
118
|
-
Rails.cache.write(cache_key, false, expires_in: 5.minutes)
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
is_banned
|
|
86
|
+
return false if ip_address.to_s.include?("/")
|
|
87
|
+
active.exists?(ip_address: IPAddr.new(ip_address.to_s).to_s)
|
|
88
|
+
rescue IPAddr::InvalidAddressError
|
|
89
|
+
false
|
|
122
90
|
end
|
|
123
91
|
|
|
124
|
-
# Unban an IP address
|
|
125
92
|
def unban!(ip_address)
|
|
126
|
-
banned_ip = find_by(ip_address: ip_address)
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
Rails.cache.delete("beskar:banned_ip:#{ip_address}")
|
|
131
|
-
true
|
|
132
|
-
else
|
|
133
|
-
false
|
|
134
|
-
end
|
|
93
|
+
banned_ip = find_by(ip_address: IPAddr.new(ip_address.to_s).to_s)
|
|
94
|
+
return false unless banned_ip
|
|
95
|
+
banned_ip.destroy!
|
|
96
|
+
true
|
|
135
97
|
end
|
|
136
98
|
|
|
137
|
-
#
|
|
99
|
+
# Kept for compatibility; no enforcement state lives in Rails.cache.
|
|
138
100
|
def preload_cache!
|
|
139
|
-
active.find_each do |banned_ip|
|
|
140
|
-
cache_key = "beskar:banned_ip:#{banned_ip.ip_address}"
|
|
141
|
-
ttl = banned_ip.permanent? ? 30.days : [(banned_ip.expires_at - Time.current).to_i, 60].max
|
|
142
|
-
Rails.cache.write(cache_key, true, expires_in: ttl)
|
|
143
|
-
end
|
|
144
101
|
end
|
|
145
102
|
|
|
146
|
-
# Clean up expired bans
|
|
147
103
|
def cleanup_expired!
|
|
148
|
-
expired.
|
|
104
|
+
expired.find_each do |ban|
|
|
105
|
+
ban.with_lock { ban.destroy! if ban.expired? }
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
def apply_extension(additional_time)
|
|
113
|
+
self.violation_count += 1
|
|
114
|
+
if permanent?
|
|
115
|
+
self.expires_at = nil
|
|
116
|
+
elsif additional_time
|
|
117
|
+
self.expires_at = [expires_at || Time.current, Time.current].max + additional_time
|
|
118
|
+
elsif violation_count >= 5
|
|
119
|
+
self.permanent = true
|
|
120
|
+
self.expires_at = nil
|
|
121
|
+
else
|
|
122
|
+
self.expires_at = Time.current + [1.hour, 6.hours, 24.hours, 7.days].fetch(violation_count - 1)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def validate_duration!(duration)
|
|
127
|
+
raise ArgumentError, "Ban duration must be positive" if duration && !duration.to_f.positive?
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def normalize_attributes
|
|
131
|
+
self.expires_at = nil if permanent?
|
|
132
|
+
self.ip_address = IPAddr.new(ip_address.to_s).to_s if ip_address.present? && !ip_address.include?("/")
|
|
133
|
+
rescue IPAddr::InvalidAddressError
|
|
134
|
+
# Validation reports malformed addresses without raising.
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def valid_ip_address
|
|
138
|
+
raise IPAddr::InvalidAddressError if ip_address.to_s.include?("/")
|
|
139
|
+
IPAddr.new(ip_address.to_s)
|
|
140
|
+
rescue IPAddr::InvalidAddressError
|
|
141
|
+
errors.add(:ip_address, "must be a valid individual IP address")
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def clear_legacy_cache
|
|
145
|
+
[ip_address, previous_changes.dig("ip_address", 0)].compact.uniq.each do |ip|
|
|
146
|
+
Rails.cache.delete("beskar:banned_ip:#{ip}")
|
|
149
147
|
end
|
|
148
|
+
rescue => error
|
|
149
|
+
Beskar::Logger.warn("Legacy ban cache invalidation failed (#{error.class})", component: :BannedIp)
|
|
150
150
|
end
|
|
151
151
|
end
|
|
152
152
|
end
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
module Beskar
|
|
2
2
|
class SecurityEvent < ApplicationRecord
|
|
3
|
+
# MySQL reports expression defaults as functions, not Ruby attribute values.
|
|
4
|
+
attribute :metadata, default: -> { {} }
|
|
5
|
+
|
|
6
|
+
# Transient correlation; the audit row is not enforcement authority.
|
|
7
|
+
attr_accessor :beskar_attempt
|
|
3
8
|
belongs_to :user, polymorphic: true, optional: true
|
|
9
|
+
before_validation :sanitize_audit_fields
|
|
10
|
+
after_find :sanitize_audit_fields
|
|
4
11
|
|
|
5
12
|
validates :event_type, presence: true
|
|
6
13
|
validates :ip_address, presence: true
|
|
@@ -10,15 +17,32 @@ module Beskar
|
|
|
10
17
|
scope :login_successes, -> { where(event_type: "login_success") }
|
|
11
18
|
scope :recent, ->(time = 1.hour.ago) { where("created_at >= ?", time) }
|
|
12
19
|
scope :by_ip, ->(ip) { where(ip_address: ip) }
|
|
13
|
-
scope :high_risk, -> { where(
|
|
14
|
-
scope :critical_risk, -> { where(
|
|
20
|
+
scope :high_risk, -> { where(risk_score: RiskLevel::RANGES[:high].begin..100) }
|
|
21
|
+
scope :critical_risk, -> { where(risk_score: RiskLevel::RANGES[:critical]) }
|
|
22
|
+
scope :with_risk_level, ->(level) {
|
|
23
|
+
range = RiskLevel::RANGES.find { |name, _| name.to_s == level.to_s }&.last
|
|
24
|
+
range ? where(risk_score: range) : all
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
def readonly?
|
|
28
|
+
persisted? || super
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def delete
|
|
32
|
+
raise ActiveRecord::ReadOnlyRecord, "Security events are append-only" if persisted?
|
|
33
|
+
super
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def risk_level
|
|
37
|
+
RiskLevel.for(risk_score)
|
|
38
|
+
end
|
|
15
39
|
|
|
16
40
|
def critical_threat?
|
|
17
|
-
|
|
41
|
+
risk_level == :critical
|
|
18
42
|
end
|
|
19
43
|
|
|
20
44
|
def high_risk?
|
|
21
|
-
|
|
45
|
+
[:high, :critical].include?(risk_level)
|
|
22
46
|
end
|
|
23
47
|
|
|
24
48
|
def login_failure?
|
|
@@ -46,5 +70,28 @@ module Beskar
|
|
|
46
70
|
def geolocation
|
|
47
71
|
metadata&.dig("geolocation") || {}
|
|
48
72
|
end
|
|
73
|
+
|
|
74
|
+
def details
|
|
75
|
+
# Extract details from metadata if available
|
|
76
|
+
# Check multiple possible fields where details might be stored
|
|
77
|
+
return nil unless metadata.present?
|
|
78
|
+
|
|
79
|
+
metadata["details"] ||
|
|
80
|
+
metadata["description"] ||
|
|
81
|
+
metadata["message"] ||
|
|
82
|
+
metadata["reason"] ||
|
|
83
|
+
metadata["error"] ||
|
|
84
|
+
metadata["info"] ||
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def sanitize_audit_fields
|
|
91
|
+
self.metadata = Services::AuditData.metadata(metadata) if has_attribute?(:metadata)
|
|
92
|
+
{event_type: 100, ip_address: 64, user_agent: 500, attempted_email: 320}.each do |field, limit|
|
|
93
|
+
self[field] = Services::AuditData.field(field, self[field], limit: limit) if has_attribute?(field)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
49
96
|
end
|
|
50
97
|
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
module Beskar
|
|
2
|
+
# Persistent coordination; deliberately independent of Rails.cache capabilities.
|
|
3
|
+
class SecurityState < ApplicationRecord
|
|
4
|
+
class ConcurrentCleanup < StandardError; end
|
|
5
|
+
|
|
6
|
+
# Preserve defaults on unsaved records as well as database-created rows.
|
|
7
|
+
attribute :data, default: -> { {} }
|
|
8
|
+
|
|
9
|
+
validates :key, presence: true
|
|
10
|
+
|
|
11
|
+
def self.read(key)
|
|
12
|
+
row = find_by(key: key)
|
|
13
|
+
return {} if row.nil? || (row.expires_at && row.expires_at <= Time.current)
|
|
14
|
+
row.data.deep_dup
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.mutate(keys, ttl:)
|
|
18
|
+
keys = Array(keys).uniq.sort
|
|
19
|
+
retries = 0
|
|
20
|
+
begin
|
|
21
|
+
mutate_once(keys, ttl: ttl) { |state| yield state }
|
|
22
|
+
rescue ActiveRecord::StaleObjectError, ActiveRecord::Deadlocked, ActiveRecord::SerializationFailure, ActiveRecord::RecordNotUnique, ConcurrentCleanup
|
|
23
|
+
raise if (retries += 1) > 10
|
|
24
|
+
sleep(0.005 * retries)
|
|
25
|
+
retry
|
|
26
|
+
rescue ActiveRecord::StatementInvalid => error
|
|
27
|
+
# SQLite uses database-level write locking rather than SELECT FOR UPDATE.
|
|
28
|
+
raise unless error.cause&.class&.name == "SQLite3::BusyException"
|
|
29
|
+
raise if (retries += 1) > 10
|
|
30
|
+
sleep(0.005 * retries)
|
|
31
|
+
retry
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The block may be retried after a concurrency conflict. Keep its effects in
|
|
36
|
+
# this database transaction; do not send notifications or write to caches.
|
|
37
|
+
def self.mutate_once(keys, ttl:)
|
|
38
|
+
transaction(requires_new: true) do
|
|
39
|
+
keys.each { |key| find_by(key: key) || create_or_find_by!(key: key) }
|
|
40
|
+
rows = where(key: keys).order(:key).lock.to_a
|
|
41
|
+
raise ConcurrentCleanup unless rows.size == keys.size
|
|
42
|
+
state = rows.to_h do |row|
|
|
43
|
+
[row.key, (row.expires_at && row.expires_at <= Time.current) ? {} : row.data.deep_dup]
|
|
44
|
+
end
|
|
45
|
+
result = yield state
|
|
46
|
+
rows.each do |row|
|
|
47
|
+
row.update!(data: state.fetch(row.key), expires_at: ttl && Time.current + ttl)
|
|
48
|
+
end
|
|
49
|
+
result
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
private_class_method :mutate_once
|
|
53
|
+
|
|
54
|
+
def self.cleanup_expired!
|
|
55
|
+
where("expires_at <= ?", Time.current).in_batches.delete_all
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
module Beskar
|
|
2
|
+
class BannedIpManager
|
|
3
|
+
attr_reader :banned_ip, :errors
|
|
4
|
+
|
|
5
|
+
def initialize(params)
|
|
6
|
+
@params = params
|
|
7
|
+
@errors = []
|
|
8
|
+
@banned_ip = nil
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def create
|
|
12
|
+
build.save
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def build
|
|
16
|
+
@banned_ip = BannedIp.new(base_attributes)
|
|
17
|
+
unless [nil, "", "temporary", "permanent"].include?(@params[:ban_type])
|
|
18
|
+
raise Services::AdministrativeBans::InvalidInput, "Select a valid ban type"
|
|
19
|
+
end
|
|
20
|
+
configure_ban_duration
|
|
21
|
+
@banned_ip
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def success?
|
|
25
|
+
@banned_ip&.persisted?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def base_attributes
|
|
31
|
+
{
|
|
32
|
+
ip_address: @params[:ip_address],
|
|
33
|
+
reason: @params[:reason],
|
|
34
|
+
details: @params[:details],
|
|
35
|
+
violation_count: @params[:violation_count] || 1,
|
|
36
|
+
metadata: @params[:metadata] || {},
|
|
37
|
+
banned_at: Time.current
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def configure_ban_duration
|
|
42
|
+
return set_permanent_ban if permanent_ban?
|
|
43
|
+
|
|
44
|
+
set_temporary_ban
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def permanent_ban?
|
|
48
|
+
@params[:ban_type] == "permanent"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def set_permanent_ban
|
|
52
|
+
@banned_ip.permanent = true
|
|
53
|
+
@banned_ip.expires_at = nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def set_temporary_ban
|
|
57
|
+
@banned_ip.permanent = false
|
|
58
|
+
@banned_ip.expires_at = calculate_expiry_time
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def calculate_expiry_time
|
|
62
|
+
custom_expiry = custom_expiry_time
|
|
63
|
+
return custom_expiry if custom_expiry
|
|
64
|
+
return preset_duration_expiry unless [nil, ""].include?(preset_duration)
|
|
65
|
+
|
|
66
|
+
default_expiry_time
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def custom_expiry_time
|
|
70
|
+
Services::BanExpiry.parse(@params[:expires_at])
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def preset_duration
|
|
74
|
+
@params[:duration]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def preset_duration_expiry
|
|
78
|
+
unless preset_duration.to_s.match?(/\A[1-9]\d{0,6}\z/) && preset_duration.to_i <= 90.days.to_i
|
|
79
|
+
raise Services::AdministrativeBans::InvalidInput, "Duration must be a positive number of seconds, at most 90 days"
|
|
80
|
+
end
|
|
81
|
+
Time.current + preset_duration.to_i.seconds
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def default_expiry_time
|
|
85
|
+
24.hours.from_now
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<% content_for :page_title, "Administrative Action History" %>
|
|
2
|
+
<div class="card">
|
|
3
|
+
<div class="card-header">
|
|
4
|
+
<h3 class="card-title">Administrative actions</h3>
|
|
5
|
+
<p><%= @pagination[:total_count] %> actions<%= " for ban ##{params[:target_id]}" if params[:target_id].present? %>. History survives removal of the live ban.</p>
|
|
6
|
+
</div>
|
|
7
|
+
<div class="card-body">
|
|
8
|
+
<table>
|
|
9
|
+
<thead><tr><th>Time</th><th>Action</th><th>Target</th><th>Actor</th><th>Reason</th><th>Details</th></tr></thead>
|
|
10
|
+
<tbody>
|
|
11
|
+
<% @administrative_actions.each do |entry| %>
|
|
12
|
+
<tr>
|
|
13
|
+
<td><%= format_timestamp(entry.created_at) %></td>
|
|
14
|
+
<td><%= entry.action.humanize %></td>
|
|
15
|
+
<td><%= entry.target_type %><%= " ##{entry.target_id}" if entry.target_id %></td>
|
|
16
|
+
<td><%= entry.actor %></td>
|
|
17
|
+
<td><%= entry.reason %></td>
|
|
18
|
+
<td><%= link_to "View action", administrative_action_path(entry) %></td>
|
|
19
|
+
</tr>
|
|
20
|
+
<% end %>
|
|
21
|
+
</tbody>
|
|
22
|
+
</table>
|
|
23
|
+
<% if @administrative_actions.empty? %><p>No administrative actions recorded.</p><% end %>
|
|
24
|
+
<nav aria-label="Action history pages">
|
|
25
|
+
<% if @pagination[:has_previous] %>
|
|
26
|
+
<%= link_to "Previous", administrative_actions_path(target_id: params[:target_id], page: @pagination[:previous_page], per_page: @pagination[:per_page]) %>
|
|
27
|
+
<% end %>
|
|
28
|
+
<% if @pagination[:has_next] %>
|
|
29
|
+
<%= link_to "Next", administrative_actions_path(target_id: params[:target_id], page: @pagination[:next_page], per_page: @pagination[:per_page]) %>
|
|
30
|
+
<% end %>
|
|
31
|
+
</nav>
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<% content_for :page_title, "Administrative Action ##{@administrative_action.id}" %>
|
|
2
|
+
<% entry = @administrative_action %>
|
|
3
|
+
<div class="card">
|
|
4
|
+
<div class="card-header"><h3 class="card-title"><%= entry.action.humanize %></h3></div>
|
|
5
|
+
<div class="card-body">
|
|
6
|
+
<dl>
|
|
7
|
+
<dt>Actor</dt><dd><%= entry.actor %></dd>
|
|
8
|
+
<dt>Reason</dt><dd><%= entry.reason %></dd>
|
|
9
|
+
<dt>Time</dt><dd><%= format_timestamp(entry.created_at) %></dd>
|
|
10
|
+
<dt>Target</dt><dd><%= entry.target_type %><%= " ##{entry.target_id}" if entry.target_id %></dd>
|
|
11
|
+
<dt>Operation ID</dt><dd><%= entry.operation_id %></dd>
|
|
12
|
+
<dt>Request ID</dt><dd><%= entry.request_id %></dd>
|
|
13
|
+
</dl>
|
|
14
|
+
<h4>Before</h4><pre><%= JSON.pretty_generate(entry.before_state) %></pre>
|
|
15
|
+
<h4>After</h4><pre><%= JSON.pretty_generate(entry.after_state) %></pre>
|
|
16
|
+
<% if entry.target_id %>
|
|
17
|
+
<%= link_to "History for this ban", administrative_actions_path(target_id: entry.target_id), class: "btn btn-secondary" %>
|
|
18
|
+
<% end %>
|
|
19
|
+
<%= link_to "All actions", administrative_actions_path, class: "btn btn-secondary" %>
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|