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,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 :banned_at, presence: true
9
-
10
- # Ensure metadata is always a hash
11
- after_initialize do
12
- self.metadata ||= {}
13
- end
14
-
15
- # Cache management callbacks
16
- after_save :update_cache
17
- after_destroy :clear_cache
18
-
19
- scope :active, -> { where("expires_at IS NULL OR expires_at > ?", Time.current) }
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 IS NOT NULL AND expires_at <= ?", Time.current) }
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
- # Check if ban has expired
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
- self.violation_count += 1
38
-
39
- if permanent?
40
- # Already permanent, just increment violation count
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
- # Retry logic to handle race conditions when multiple requests try to ban the same IP
75
- retries = 0
76
- max_retries = 2
77
-
78
- begin
79
- banned_ip = find_or_initialize_by(ip_address: ip_address)
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
- # Existing ban - extend it
83
- banned_ip.extend_ban!(duration)
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
- # Deep stringify keys to avoid duplicate key issues
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
- banned_at: Time.current,
95
- expires_at: permanent ? nil : (Time.current + (duration || 1.hour)),
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
- # Check if an IP is banned (cache-first approach)
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
- # Check cache first for performance
125
- cache_key = "beskar:banned_ip:#{ip_address}"
126
- cached_result = Rails.cache.read(cache_key)
127
- return true if cached_result == true
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
- if banned_ip
149
- banned_ip.destroy
150
- # Clear cache
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
- # Load all active bans into cache (called on app startup)
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.destroy_all
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 update_cache
176
- return unless active?
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
- cache_key = "beskar:banned_ip:#{ip_address}"
179
- ttl = calculate_cache_ttl
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 clear_cache
184
- Rails.cache.delete("beskar:banned_ip:#{ip_address}")
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 calculate_cache_ttl
188
- return nil if permanent? || expires_at.nil?
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
- (expires_at - Time.current).to_i
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("risk_score >= ?", 70) }
14
- scope :critical_risk, -> { where("risk_score >= ?", 90) }
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
- risk_score >= 90
41
+ risk_level == :critical
18
42
  end
19
43
 
20
44
  def high_risk?
21
- risk_score >= 70
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] == 'permanent'
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
- return custom_expiry_time if custom_expiry_time.present?
57
- return preset_duration_expiry if preset_duration.present?
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>