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
@@ -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 :banned_at, presence: true
9
-
10
- # Ensure metadata is always a hash
11
- after_initialize do
12
- self.metadata ||= {}
13
- end
14
-
15
- 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) }
16
21
  scope :permanent, -> { where(permanent: true) }
17
22
  scope :temporary, -> { where(permanent: false) }
18
- scope :expired, -> { where("expires_at IS NOT NULL AND expires_at <= ?", Time.current) }
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
- # 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
+
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
- self.violation_count += 1
34
-
35
- if permanent?
36
- # Already permanent, just increment violation count
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
- banned_ip = find_or_initialize_by(ip_address: ip_address)
71
-
72
- if banned_ip.persisted?
73
- # Existing ban - extend it
74
- banned_ip.extend_ban!(duration)
75
- banned_ip.details = details if details
76
- # Deep stringify keys to avoid duplicate key issues
77
- if metadata.any?
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
- else
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
- # 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.
102
85
  def banned?(ip_address)
103
- # Check cache first for performance
104
- cache_key = "beskar:banned_ip:#{ip_address}"
105
- cached_result = Rails.cache.read(cache_key)
106
- return true if cached_result == true
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
- if banned_ip
128
- banned_ip.destroy
129
- # Clear cache
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
- # Load all active bans into cache (called on app startup)
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.destroy_all
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("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?
@@ -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>