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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +150 -19
- data/README.md +142 -122
- 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 +69 -25
- data/app/controllers/beskar/banned_ips_controller.rb +116 -141
- data/app/controllers/beskar/dashboard_controller.rb +20 -28
- data/app/controllers/beskar/security_events_controller.rb +37 -55
- 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 +91 -132
- data/app/models/beskar/security_event.rb +37 -4
- data/app/models/beskar/security_state.rb +58 -0
- data/app/services/beskar/banned_ip_manager.rb +16 -6
- 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 +25 -89
- data/app/views/beskar/banned_ips/index.html.erb +20 -62
- data/app/views/beskar/banned_ips/new.html.erb +18 -138
- data/app/views/beskar/banned_ips/review.html.erb +24 -0
- data/app/views/beskar/banned_ips/show.html.erb +9 -15
- data/app/views/beskar/dashboard/index.html.erb +4 -4
- data/app/views/beskar/security_events/index.html.erb +8 -15
- data/app/views/beskar/security_events/show.html.erb +6 -20
- 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 +9 -76
- data/config/routes.rb +10 -21
- 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 +84 -13
- 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 +30 -35
- data/lib/beskar/middleware/request_analyzer.rb +41 -82
- data/lib/beskar/models/security_trackable_authenticable.rb +72 -93
- data/lib/beskar/models/security_trackable_devise.rb +32 -23
- data/lib/beskar/models/security_trackable_generic.rb +169 -212
- data/lib/beskar/risk_level.rb +22 -0
- data/lib/beskar/services/account_locker.rb +85 -76
- 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 +164 -280
- 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 +53 -4
- data/lib/generators/beskar/install/install_generator.rb +36 -36
- data/lib/generators/beskar/install/templates/initializer.rb.tt +104 -20
- data/lib/tasks/beskar_tasks.rake +15 -19
- metadata +60 -8
|
@@ -1,193 +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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
scope :active, -> { where("
|
|
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) }
|
|
20
21
|
scope :permanent, -> { where(permanent: true) }
|
|
21
22
|
scope :temporary, -> { where(permanent: false) }
|
|
22
|
-
scope :expired, -> { where("expires_at
|
|
23
|
+
scope :expired, -> { temporary.where("expires_at <= ?", Time.current) }
|
|
23
24
|
scope :by_reason, ->(reason) { where(reason: reason) }
|
|
24
25
|
|
|
25
|
-
# Check if a ban is currently active
|
|
26
26
|
def active?
|
|
27
27
|
permanent? || (expires_at.present? && expires_at > Time.current)
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
-
|
|
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
|
+
|
|
31
37
|
def expired?
|
|
32
38
|
!permanent? && expires_at.present? && expires_at <= Time.current
|
|
33
39
|
end
|
|
34
40
|
|
|
35
|
-
# Extend ban duration (for repeat offenders)
|
|
36
41
|
def extend_ban!(additional_time = nil)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
save!
|
|
42
|
-
elsif additional_time
|
|
43
|
-
self.expires_at = [expires_at || Time.current, Time.current].max + additional_time
|
|
44
|
-
save!
|
|
45
|
-
else
|
|
46
|
-
# Calculate exponential backoff based on violation count
|
|
47
|
-
# 1 hour, 6 hours, 24 hours, 7 days, permanent
|
|
48
|
-
duration = case violation_count
|
|
49
|
-
when 1 then 1.hour
|
|
50
|
-
when 2 then 6.hours
|
|
51
|
-
when 3 then 24.hours
|
|
52
|
-
when 4 then 7.days
|
|
53
|
-
else
|
|
54
|
-
self.permanent = true
|
|
55
|
-
nil
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
if duration
|
|
59
|
-
self.expires_at = Time.current + duration
|
|
60
|
-
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)
|
|
61
46
|
save!
|
|
62
47
|
end
|
|
63
48
|
end
|
|
64
49
|
|
|
65
|
-
# Unban an IP address
|
|
66
50
|
def unban!
|
|
67
|
-
destroy
|
|
51
|
+
destroy!
|
|
68
52
|
end
|
|
69
53
|
|
|
70
|
-
# Class methods for ban management
|
|
71
54
|
class << self
|
|
72
|
-
# Ban an IP address
|
|
73
55
|
def ban!(ip_address, reason:, duration: nil, permanent: false, details: nil, metadata: {})
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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)
|
|
81
65
|
if banned_ip.persisted?
|
|
82
|
-
|
|
83
|
-
banned_ip.
|
|
66
|
+
banned_ip.permanent = true if permanent
|
|
67
|
+
banned_ip.send(:apply_extension, duration)
|
|
68
|
+
banned_ip.reason = reason
|
|
84
69
|
banned_ip.details = details if details
|
|
85
|
-
|
|
86
|
-
if metadata.any?
|
|
87
|
-
banned_ip.metadata = banned_ip.metadata.deep_stringify_keys.merge(metadata.deep_stringify_keys)
|
|
88
|
-
end
|
|
89
|
-
banned_ip.save!
|
|
70
|
+
banned_ip.metadata = banned_ip.metadata.deep_stringify_keys.merge(metadata.deep_stringify_keys)
|
|
90
71
|
else
|
|
91
|
-
# New ban
|
|
92
72
|
banned_ip.assign_attributes(
|
|
93
|
-
reason: reason,
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
permanent: permanent,
|
|
97
|
-
details: details,
|
|
98
|
-
metadata: metadata
|
|
73
|
+
reason: reason, banned_at: Time.current, permanent: permanent,
|
|
74
|
+
expires_at: permanent ? nil : Time.current + (duration || 1.hour),
|
|
75
|
+
details: details, metadata: metadata
|
|
99
76
|
)
|
|
100
|
-
banned_ip.save!
|
|
101
77
|
end
|
|
102
|
-
|
|
103
|
-
# Update cache
|
|
104
|
-
cache_key = "beskar:banned_ip:#{ip_address}"
|
|
105
|
-
Rails.cache.write(cache_key, true, expires_in: permanent ? nil : (duration || 1.hour))
|
|
106
|
-
|
|
78
|
+
banned_ip.save!
|
|
107
79
|
banned_ip
|
|
108
|
-
rescue ActiveRecord::RecordInvalid => e
|
|
109
|
-
# Race condition: another request created the record between find and save
|
|
110
|
-
if e.message.include?("Ip address has already been taken") && retries < max_retries
|
|
111
|
-
retries += 1
|
|
112
|
-
# Small random delay to reduce contention (1-10ms)
|
|
113
|
-
sleep(rand(1..10) / 1000.0)
|
|
114
|
-
retry
|
|
115
|
-
else
|
|
116
|
-
# Re-raise if it's a different validation error or we've exceeded retries
|
|
117
|
-
raise
|
|
118
|
-
end
|
|
119
80
|
end
|
|
120
81
|
end
|
|
121
82
|
|
|
122
|
-
#
|
|
83
|
+
# Cache eviction, rollback, stale values, or process-local caches cannot
|
|
84
|
+
# change enforcement. All workers consult the same indexed database.
|
|
123
85
|
def banned?(ip_address)
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
return false if cached_result == false
|
|
129
|
-
|
|
130
|
-
# Check database
|
|
131
|
-
banned_record = active.find_by(ip_address: ip_address)
|
|
132
|
-
is_banned = banned_record&.active? || false
|
|
133
|
-
|
|
134
|
-
# Update cache
|
|
135
|
-
if is_banned && banned_record
|
|
136
|
-
ttl = banned_record.permanent? ? 30.days : (banned_record.expires_at - Time.current).to_i
|
|
137
|
-
Rails.cache.write(cache_key, true, expires_in: ttl)
|
|
138
|
-
else
|
|
139
|
-
Rails.cache.write(cache_key, false, expires_in: 5.minutes)
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
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
|
|
143
90
|
end
|
|
144
91
|
|
|
145
|
-
# Unban an IP address
|
|
146
92
|
def unban!(ip_address)
|
|
147
|
-
banned_ip = find_by(ip_address: ip_address)
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
Rails.cache.delete("beskar:banned_ip:#{ip_address}")
|
|
152
|
-
true
|
|
153
|
-
else
|
|
154
|
-
false
|
|
155
|
-
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
|
|
156
97
|
end
|
|
157
98
|
|
|
158
|
-
#
|
|
99
|
+
# Kept for compatibility; no enforcement state lives in Rails.cache.
|
|
159
100
|
def preload_cache!
|
|
160
|
-
active.find_each do |banned_ip|
|
|
161
|
-
cache_key = "beskar:banned_ip:#{banned_ip.ip_address}"
|
|
162
|
-
ttl = banned_ip.permanent? ? 30.days : [(banned_ip.expires_at - Time.current).to_i, 60].max
|
|
163
|
-
Rails.cache.write(cache_key, true, expires_in: ttl)
|
|
164
|
-
end
|
|
165
101
|
end
|
|
166
102
|
|
|
167
|
-
# Clean up expired bans
|
|
168
103
|
def cleanup_expired!
|
|
169
|
-
expired.
|
|
104
|
+
expired.find_each do |ban|
|
|
105
|
+
ban.with_lock { ban.destroy! if ban.expired? }
|
|
106
|
+
end
|
|
170
107
|
end
|
|
171
108
|
end
|
|
172
109
|
|
|
173
110
|
private
|
|
174
111
|
|
|
175
|
-
def
|
|
176
|
-
|
|
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
|
|
177
125
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
Rails.cache.write(cache_key, true, expires_in: ttl)
|
|
126
|
+
def validate_duration!(duration)
|
|
127
|
+
raise ArgumentError, "Ban duration must be positive" if duration && !duration.to_f.positive?
|
|
181
128
|
end
|
|
182
129
|
|
|
183
|
-
def
|
|
184
|
-
|
|
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.
|
|
185
135
|
end
|
|
186
136
|
|
|
187
|
-
def
|
|
188
|
-
|
|
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
|
|
189
143
|
|
|
190
|
-
|
|
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}")
|
|
147
|
+
end
|
|
148
|
+
rescue => error
|
|
149
|
+
Beskar::Logger.warn("Legacy ban cache invalidation failed (#{error.class})", component: :BannedIp)
|
|
191
150
|
end
|
|
192
151
|
end
|
|
193
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?
|
|
@@ -60,5 +84,14 @@ module Beskar
|
|
|
60
84
|
metadata["info"] ||
|
|
61
85
|
nil
|
|
62
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
|
|
63
96
|
end
|
|
64
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
|
|
@@ -9,10 +9,16 @@ module Beskar
|
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
def create
|
|
12
|
+
build.save
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def build
|
|
12
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
|
|
13
20
|
configure_ban_duration
|
|
14
|
-
|
|
15
|
-
@banned_ip.save
|
|
21
|
+
@banned_ip
|
|
16
22
|
end
|
|
17
23
|
|
|
18
24
|
def success?
|
|
@@ -39,7 +45,7 @@ module Beskar
|
|
|
39
45
|
end
|
|
40
46
|
|
|
41
47
|
def permanent_ban?
|
|
42
|
-
@params[:ban_type] ==
|
|
48
|
+
@params[:ban_type] == "permanent"
|
|
43
49
|
end
|
|
44
50
|
|
|
45
51
|
def set_permanent_ban
|
|
@@ -53,14 +59,15 @@ module Beskar
|
|
|
53
59
|
end
|
|
54
60
|
|
|
55
61
|
def calculate_expiry_time
|
|
56
|
-
|
|
57
|
-
return
|
|
62
|
+
custom_expiry = custom_expiry_time
|
|
63
|
+
return custom_expiry if custom_expiry
|
|
64
|
+
return preset_duration_expiry unless [nil, ""].include?(preset_duration)
|
|
58
65
|
|
|
59
66
|
default_expiry_time
|
|
60
67
|
end
|
|
61
68
|
|
|
62
69
|
def custom_expiry_time
|
|
63
|
-
@params[:expires_at]
|
|
70
|
+
Services::BanExpiry.parse(@params[:expires_at])
|
|
64
71
|
end
|
|
65
72
|
|
|
66
73
|
def preset_duration
|
|
@@ -68,6 +75,9 @@ module Beskar
|
|
|
68
75
|
end
|
|
69
76
|
|
|
70
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
|
|
71
81
|
Time.current + preset_duration.to_i.seconds
|
|
72
82
|
end
|
|
73
83
|
|
|
@@ -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>
|