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
@@ -3,12 +3,8 @@
3
3
  <% content_for :header_actions do %>
4
4
  <%= link_to "← Back to Banned IPs", banned_ips_path, class: "btn btn-secondary" %>
5
5
  <%= link_to "Edit", edit_banned_ip_path(@banned_ip), class: "btn btn-secondary" %>
6
- <%= link_to "Unban", banned_ip_path(@banned_ip),
7
- data: {
8
- "turbo-method": "delete",
9
- "turbo-confirm": "Are you sure you want to unban #{@banned_ip.ip_address}?"
10
- },
11
- class: "btn btn-danger" %>
6
+ <%= link_to "Action History", administrative_actions_path(target_id: @banned_ip.id), class: "btn btn-secondary" %>
7
+ <%= link_to "Unban", review_banned_ip_path(@banned_ip, operation: "unban"), class: "btn btn-danger" %>
12
8
  <% end %>
13
9
 
14
10
  <!-- Ban Overview -->
@@ -143,7 +139,7 @@
143
139
  </div>
144
140
 
145
141
  <div style="text-align: center; padding: 1rem; background: #FAFBFC; border-radius: 6px;">
146
- <div style="font-size: 24px; font-weight: 600; color: <%= @stats[:avg_risk_score] > 70 ? '#E91E63' : '#1A1F36' %>;">
142
+ <div style="font-size: 24px; font-weight: 600; color: <%= risk_level_color(@stats[:avg_risk_score]) %>;">
147
143
  <%= @stats[:avg_risk_score] %>
148
144
  </div>
149
145
  <div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; color: #697386; margin-top: 0.25rem;">
@@ -152,7 +148,7 @@
152
148
  </div>
153
149
 
154
150
  <div style="text-align: center; padding: 1rem; background: #FAFBFC; border-radius: 6px;">
155
- <div style="font-size: 24px; font-weight: 600; color: <%= @stats[:max_risk_score] > 85 ? '#D32F2F' : '#1A1F36' %>;">
151
+ <div style="font-size: 24px; font-weight: 600; color: <%= risk_level_color(@stats[:max_risk_score]) %>;">
156
152
  <%= @stats[:max_risk_score] %>
157
153
  </div>
158
154
  <div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; color: #697386; margin-top: 0.25rem;">
@@ -239,11 +235,11 @@
239
235
  <td>
240
236
  <% if event.user %>
241
237
  <div style="font-size: 13px;">
242
- <%= event.user.try(:email) || "User ##{event.user_id}" %>
238
+ <%= audit_user_label(event) %>
243
239
  </div>
244
240
  <% elsif event.attempted_email %>
245
241
  <div style="font-size: 13px; color: #697386;">
246
- <%= event.attempted_email %>
242
+ <%= audit_user_label(event) %>
247
243
  </div>
248
244
  <% else %>
249
245
  <span style="color: #C1C7D0;">-</span>
@@ -295,16 +291,14 @@
295
291
  ['Make Permanent', 'permanent']
296
292
  ]),
297
293
  style: "padding: 0.5rem 0.75rem; font-size: 14px;" %>
294
+ <%= label_tag :audit_reason, "Administrative reason" %>
295
+ <%= text_field_tag :audit_reason, nil, required: true, maxlength: 1000 %>
298
296
  <%= submit_tag "Extend Ban", class: "btn btn-warning" %>
299
297
  <% end %>
300
298
  </div>
301
299
  <% end %>
302
300
 
303
- <%= link_to "Unban IP", banned_ip_path(@banned_ip),
304
- data: {
305
- "turbo-method": "delete",
306
- "turbo-confirm": "Are you sure you want to unban #{@banned_ip.ip_address}?"
307
- },
301
+ <%= link_to "Unban IP", review_banned_ip_path(@banned_ip, operation: "unban"),
308
302
  class: "btn btn-danger",
309
303
  style: "margin-left: #{ @banned_ip.active? && !@banned_ip.permanent? ? '0' : 'auto' };" %>
310
304
  </div>
@@ -38,7 +38,7 @@
38
38
  <div class="stat-value" style="color: var(--primary);">
39
39
  <%= number_with_delimiter(@stats[:high_risk_events]) %>
40
40
  </div>
41
- <div class="stat-change">Score > 60</div>
41
+ <div class="stat-change">Score &ge; <%= Beskar::RiskLevel::RANGES[:high].begin %></div>
42
42
  </div>
43
43
 
44
44
  <div class="stat-card">
@@ -46,7 +46,7 @@
46
46
  <div class="stat-value" style="color: #D32F2F;">
47
47
  <%= number_with_delimiter(@stats[:critical_threats]) %>
48
48
  </div>
49
- <div class="stat-change">Score > 85</div>
49
+ <div class="stat-change">Score &ge; <%= Beskar::RiskLevel::RANGES[:critical].begin %></div>
50
50
  </div>
51
51
  </div>
52
52
 
@@ -172,11 +172,11 @@
172
172
  <td>
173
173
  <% if event.user %>
174
174
  <div style="font-size: 13px;">
175
- <%= event.user.try(:email) || "User ##{event.user_id}" %>
175
+ <%= audit_user_label(event) %>
176
176
  </div>
177
177
  <% elsif event.attempted_email %>
178
178
  <div style="font-size: 13px; color: #697386;">
179
- <%= event.attempted_email %>
179
+ <%= audit_user_label(event) %>
180
180
  </div>
181
181
  <% else %>
182
182
  <span style="color: #C1C7D0;">-</span>
@@ -2,10 +2,7 @@
2
2
 
3
3
  <% content_for :header_actions do %>
4
4
  <div style="display: flex; gap: 0.5rem;">
5
- <%= link_to "Export CSV", export_security_events_path(request.query_parameters.merge(format: :csv)),
6
- class: "btn btn-secondary" %>
7
- <%= link_to "Export JSON", export_security_events_path(request.query_parameters.merge(format: :json)),
8
- class: "btn btn-secondary" %>
5
+ <%= render "beskar/shared/export_form", export_url: export_security_events_path %>
9
6
  </div>
10
7
  <% end %>
11
8
 
@@ -46,12 +43,7 @@
46
43
  <div class="form-group">
47
44
  <label>Risk Level</label>
48
45
  <%= select_tag :risk_level,
49
- options_for_select([
50
- ['Low (0-30)', 'low'],
51
- ['Medium (31-60)', 'medium'],
52
- ['High (61-85)', 'high'],
53
- ['Critical (86-100)', 'critical']
54
- ], params[:risk_level]),
46
+ options_for_select(@risk_levels, params[:risk_level]),
55
47
  prompt: 'All risk levels',
56
48
  style: "width: 100%;" %>
57
49
  </div>
@@ -109,15 +101,16 @@
109
101
  </div>
110
102
 
111
103
  <div style="display: flex; align-items: center; gap: 0.5rem;">
112
- <label style="margin: 0; font-size: 13px; color: #697386;">Per page:</label>
104
+ <label for="per_page" style="margin: 0; font-size: 13px; color: #697386;">Per page:</label>
113
105
  <%= form_with url: security_events_path, method: :get, local: true, style: "display: inline;" do |f| %>
114
- <% request.query_parameters.except(:per_page).each do |key, value| %>
106
+ <% request.query_parameters.except("per_page", "page").each do |key, value| %>
115
107
  <%= hidden_field_tag key, value %>
116
108
  <% end %>
117
109
  <%= select_tag :per_page,
118
110
  options_for_select([10, 25, 50, 100], params[:per_page]&.to_i || 25),
119
- onchange: "this.form.submit();",
111
+ data: { beskar_auto_submit: true },
120
112
  style: "padding: 0.25rem 0.5rem; font-size: 13px;" %>
113
+ <%= submit_tag "Apply page size", class: "btn btn-sm btn-secondary" %>
121
114
  <% end %>
122
115
  </div>
123
116
  </div>
@@ -178,14 +171,14 @@
178
171
  <td>
179
172
  <% if event.user %>
180
173
  <div style="font-size: 13px;">
181
- <%= event.user.try(:email) || "User ##{event.user_id}" %>
174
+ <%= audit_user_label(event) %>
182
175
  </div>
183
176
  <div style="font-size: 11px; color: #697386;">
184
177
  ID: <%= event.user_id %>
185
178
  </div>
186
179
  <% elsif event.attempted_email %>
187
180
  <div style="font-size: 13px; color: #697386;">
188
- <%= event.attempted_email %>
181
+ <%= audit_user_label(event) %>
189
182
  </div>
190
183
  <div style="font-size: 11px; color: #C1C7D0;">
191
184
  (attempted)
@@ -35,25 +35,11 @@
35
35
  Risk Score
36
36
  </div>
37
37
  <div style="display: flex; align-items: center; gap: 0.75rem;">
38
- <span style="font-size: 24px; font-weight: 600; color: <%=
39
- case @event.risk_score
40
- when 0..30 then '#4CAF50'
41
- when 31..60 then '#FF9800'
42
- when 61..85 then '#E91E63'
43
- else '#D32F2F'
44
- end
45
- %>;">
38
+ <span style="font-size: 24px; font-weight: 600; color: <%= risk_level_color(@event.risk_score) %>;">
46
39
  <%= @event.risk_score %>
47
40
  </span>
48
41
  <span class="badge badge-<%= risk_level_class(@event.risk_score) %>">
49
- <%=
50
- case @event.risk_score
51
- when 0..30 then 'Low Risk'
52
- when 31..60 then 'Medium Risk'
53
- when 61..85 then 'High Risk'
54
- else 'Critical'
55
- end
56
- %>
42
+ <%= risk_level_label(@event.risk_score) %>
57
43
  </span>
58
44
  </div>
59
45
  </div>
@@ -101,14 +87,14 @@
101
87
  </div>
102
88
  <% if @event.user %>
103
89
  <div style="font-size: 14px; color: #1A1F36;">
104
- <%= @event.user.try(:email) || "User ##{@event.user_id}" %>
90
+ <%= audit_user_label(@event) %>
105
91
  </div>
106
92
  <div style="font-size: 13px; color: #697386; margin-top: 0.25rem;">
107
93
  ID: <%= @event.user_id %> • Type: <%= @event.user_type %>
108
94
  </div>
109
95
  <% elsif @event.attempted_email %>
110
96
  <div style="font-size: 14px; color: #697386;">
111
- <%= @event.attempted_email %> <span style="font-size: 12px;">(attempted)</span>
97
+ <%= audit_user_label(@event) %> <span style="font-size: 12px;">(attempted)</span>
112
98
  </div>
113
99
  <% else %>
114
100
  <div style="color: #C1C7D0;">No user associated</div>
@@ -214,9 +200,9 @@
214
200
  </td>
215
201
  <td>
216
202
  <% if event.user %>
217
- <%= event.user.try(:email) || "User ##{event.user_id}" %>
203
+ <%= audit_user_label(event) %>
218
204
  <% elsif event.attempted_email %>
219
- <span style="color: #697386;"><%= event.attempted_email %></span>
205
+ <span style="color: #697386;"><%= audit_user_label(event) %></span>
220
206
  <% else %>
221
207
  <span style="color: #C1C7D0;">-</span>
222
208
  <% end %>
@@ -0,0 +1,10 @@
1
+ <%= form_with url: export_url, method: :get, local: true do %>
2
+ <% request.query_parameters.except("format", "audit_reason", "page", "per_page").each do |key, value| %>
3
+ <%= hidden_field_tag key, value if value.is_a?(String) %>
4
+ <% end %>
5
+ <%= label_tag :export_audit_reason, "Export reason" %>
6
+ <%= text_field_tag :audit_reason, nil, id: :export_audit_reason, required: true, maxlength: 1000 %>
7
+ <%= label_tag :export_format, "Export format" %>
8
+ <%= select_tag :format, options_for_select([["CSV", "csv"], ["JSON", "json"]]), id: :export_format %>
9
+ <%= submit_tag "Export", class: "btn btn-secondary" %>
10
+ <% end %>
@@ -0,0 +1,121 @@
1
+ <%= javascript_tag nonce: true do %>
2
+ (() => {
3
+ // One delegated installation per document, even if a host evaluates the
4
+ // layout again. Native navigation is the dashboard's supported boundary.
5
+ if (window.beskarDashboardBehaviorInstalled) return;
6
+ window.beskarDashboardBehaviorInstalled = true;
7
+
8
+ function isPermanent(form) {
9
+ return form.querySelector('[name="ban_type"]:checked')?.value === 'permanent' ||
10
+ form.querySelector('[name="banned_ip[permanent]"]:checked')?.value === 'true';
11
+ }
12
+
13
+ function updateBanForm(form) {
14
+ const permanent = isPermanent(form);
15
+ const options = form.querySelector('#temporary-options');
16
+ options.hidden = permanent;
17
+ options.querySelectorAll('input, select, button').forEach(input => { input.disabled = permanent; });
18
+ const expiry = form.querySelector('#banned_ip_expires_at');
19
+ expiry.required = !permanent && !!form.querySelector('[name="banned_ip[permanent]"]');
20
+ const preview = document.getElementById('ban-preview');
21
+ if (!preview) return;
22
+ const ip = form.querySelector('#banned_ip_ip_address');
23
+ const reason = form.querySelector('#banned_ip_reason');
24
+ preview.hidden = !(ip.value || reason.value);
25
+ document.getElementById('preview-ip').textContent = ip.value || '-';
26
+ document.getElementById('preview-reason').textContent = reason.selectedOptions[0]?.textContent || '-';
27
+ const duration = form.querySelector('[name="duration"]:checked');
28
+ document.getElementById('preview-duration').textContent = permanent ? 'Permanent' :
29
+ (expiry.value ? `${expiry.value.replace('T', ' ')} UTC` : (duration?.parentElement.textContent.trim() || '24 hours (server default)'));
30
+ const status = document.getElementById('preview-status');
31
+ status.textContent = permanent ? 'Permanent' : 'Temporary';
32
+ status.className = permanent ? 'badge badge-critical' : 'badge badge-danger';
33
+ }
34
+
35
+ function updateSelection(form) {
36
+ const boxes = Array.from(form.querySelectorAll('.ban-checkbox'));
37
+ const count = boxes.filter(box => box.checked).length;
38
+ form.querySelector('#selected-count').textContent = count;
39
+ form.querySelector('#bulk-actions-bar').hidden = count === 0;
40
+ const all = form.querySelector('#select-all');
41
+ all.checked = boxes.length > 0 && count === boxes.length;
42
+ all.indeterminate = count > 0 && count < boxes.length;
43
+ }
44
+
45
+ function initialize() {
46
+ if (!document.querySelector('[data-beskar-dashboard]')) return;
47
+ document.querySelectorAll('[data-beskar-ban-form]').forEach(updateBanForm);
48
+ document.querySelectorAll('#bulk-actions-form').forEach(updateSelection);
49
+ }
50
+
51
+ document.addEventListener('change', event => {
52
+ if (!event.target.closest('[data-beskar-dashboard]')) return;
53
+ if (event.target.matches('[data-beskar-auto-submit]')) event.target.form.requestSubmit();
54
+ const form = event.target.closest('[data-beskar-ban-form]');
55
+ if (form) {
56
+ const expiry = form.querySelector('#banned_ip_expires_at');
57
+ if (event.target.name === 'duration') expiry.value = '';
58
+ if (event.target.name === 'banned_ip[permanent]' && !isPermanent(form) && !expiry.value) {
59
+ expiry.value = new Date(Date.now() + 86400000).toISOString().slice(0, -1);
60
+ }
61
+ updateBanForm(form);
62
+ }
63
+ const bulk = event.target.closest('#bulk-actions-form');
64
+ if (bulk) {
65
+ if (event.target.id === 'select-all') {
66
+ bulk.querySelectorAll('.ban-checkbox').forEach(box => { box.checked = event.target.checked; });
67
+ }
68
+ updateSelection(bulk);
69
+ }
70
+ });
71
+
72
+ document.addEventListener('input', event => {
73
+ const form = event.target.closest('[data-beskar-ban-form]');
74
+ if (!form) return;
75
+ if (event.target.id === 'banned_ip_expires_at' && event.target.value) {
76
+ form.querySelectorAll('[name="duration"]').forEach(input => { input.checked = false; });
77
+ }
78
+ updateBanForm(form);
79
+ });
80
+
81
+ document.addEventListener('click', event => {
82
+ if (!event.target.closest('[data-beskar-dashboard]')) return;
83
+ const clear = event.target.closest('[data-beskar-clear-selection]');
84
+ if (clear) {
85
+ const form = clear.closest('form');
86
+ form.querySelectorAll('.ban-checkbox').forEach(box => { box.checked = false; });
87
+ updateSelection(form);
88
+ }
89
+ const extend = event.target.closest('[data-beskar-extend-hours]');
90
+ if (extend) {
91
+ const form = extend.closest('form');
92
+ const expiry = form.querySelector('#banned_ip_expires_at');
93
+ // The displayed wall-clock value is UTC, never browser-local time.
94
+ const base = expiry.value ? Date.parse(`${expiry.value}Z`) : Date.now();
95
+ if (Number.isFinite(base)) {
96
+ expiry.value = new Date(Math.max(base, Date.now()) + Number(extend.dataset.beskarExtendHours) * 3600000).toISOString().slice(0, -1);
97
+ updateBanForm(form);
98
+ }
99
+ }
100
+ const permanent = event.target.closest('[data-beskar-make-permanent]');
101
+ if (permanent) {
102
+ const form = permanent.closest('form');
103
+ form.querySelector('[name="banned_ip[permanent]"][value="true"]').checked = true;
104
+ updateBanForm(form);
105
+ }
106
+ });
107
+
108
+ document.addEventListener('submit', event => {
109
+ if (!event.target.closest('[data-beskar-dashboard]')) return;
110
+ const message = event.submitter?.dataset.beskarConfirm;
111
+ if (message && !window.confirm(message)) event.preventDefault();
112
+ // Do not synthesize forms, bypass validation, or convert duration presets
113
+ // to client-clock timestamps. Rails forms carry CSRF and method fields.
114
+ });
115
+
116
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', initialize, { once: true });
117
+ else initialize();
118
+ document.addEventListener('pageshow', initialize);
119
+ document.addEventListener('turbo:load', initialize);
120
+ })();
121
+ <% end %>
@@ -8,7 +8,7 @@
8
8
  <%= csp_meta_tag %>
9
9
 
10
10
  <!-- Embedded Styles (No external dependencies) -->
11
- <style>
11
+ <style nonce="<%= content_security_policy_nonce %>">
12
12
  :root {
13
13
  --primary: #635BFF;
14
14
  --primary-dark: #5243CC;
@@ -384,6 +384,7 @@
384
384
  input[type="password"],
385
385
  input[type="number"],
386
386
  input[type="date"],
387
+ input[type="datetime-local"],
387
388
  select,
388
389
  textarea {
389
390
  width: 100%;
@@ -492,7 +493,7 @@
492
493
 
493
494
  <%= yield :head %>
494
495
  </head>
495
- <body>
496
+ <body data-beskar-dashboard data-turbo="false">
496
497
  <div class="dashboard-container">
497
498
  <!-- Sidebar -->
498
499
  <aside class="sidebar">
@@ -531,6 +532,11 @@
531
532
  <% end %>
532
533
  </div>
533
534
 
535
+ <div class="nav-section">
536
+ <%= link_to "Administrative Actions", administrative_actions_path,
537
+ class: "nav-link #{controller_name == 'administrative_actions' ? 'active' : ''}" %>
538
+ </div>
539
+
534
540
  <div class="nav-section">
535
541
  <div class="nav-section-title">Configuration</div>
536
542
  <% if Beskar.configuration.monitor_only? %>
@@ -581,79 +587,6 @@
581
587
  </main>
582
588
  </div>
583
589
 
584
- <%= javascript_tag nonce: true do %>
585
- // Add active state to current nav link
586
- document.addEventListener('DOMContentLoaded', function() {
587
- // Handle nav link clicks
588
- document.querySelectorAll('.nav-link').forEach(link => {
589
- if (link.href === window.location.href) {
590
- link.classList.add('active');
591
- }
592
- });
593
-
594
- // Auto-dismiss alerts after 5 seconds
595
- setTimeout(() => {
596
- document.querySelectorAll('.alert').forEach(alert => {
597
- alert.style.transition = 'opacity 0.5s';
598
- alert.style.opacity = '0';
599
- setTimeout(() => alert.remove(), 500);
600
- });
601
- }, 5000);
602
-
603
- // Handle data-turbo-method links (Rails UJS replacement)
604
- document.addEventListener('click', function(e) {
605
- const link = e.target.closest('a[data-turbo-method]');
606
- if (!link) return;
607
-
608
- e.preventDefault();
609
-
610
- const method = link.dataset.turboMethod || link.dataset['turbo-method'];
611
- const confirm = link.dataset.turboConfirm || link.dataset['turbo-confirm'];
612
-
613
- if (confirm && !window.confirm(confirm)) {
614
- return;
615
- }
616
-
617
- const form = document.createElement('form');
618
- form.method = 'POST';
619
- form.action = link.href;
620
- form.style.display = 'none';
621
-
622
- // Add CSRF token
623
- const csrfToken = document.querySelector('meta[name="csrf-token"]').content;
624
- const csrfParam = document.querySelector('meta[name="csrf-param"]').content;
625
-
626
- if (csrfToken && csrfParam) {
627
- const input = document.createElement('input');
628
- input.type = 'hidden';
629
- input.name = csrfParam;
630
- input.value = csrfToken;
631
- form.appendChild(input);
632
- }
633
-
634
- // Add method override for DELETE, PUT, PATCH
635
- if (method && method.toLowerCase() !== 'post') {
636
- const methodInput = document.createElement('input');
637
- methodInput.type = 'hidden';
638
- methodInput.name = '_method';
639
- methodInput.value = method;
640
- form.appendChild(methodInput);
641
- }
642
-
643
- // Add any URL parameters as form inputs
644
- const url = new URL(link.href);
645
- url.searchParams.forEach((value, key) => {
646
- const input = document.createElement('input');
647
- input.type = 'hidden';
648
- input.name = key;
649
- input.value = value;
650
- form.appendChild(input);
651
- });
652
-
653
- document.body.appendChild(form);
654
- form.submit();
655
- });
656
- });
657
- <% end %>
590
+ <%= render "layouts/beskar/behavior" %>
658
591
  </body>
659
592
  </html>
data/config/routes.rb CHANGED
@@ -1,43 +1,32 @@
1
1
  Beskar::Engine.routes.draw do
2
2
  # Root route - dashboard
3
- root to: 'dashboard#index'
3
+ root to: "dashboard#index"
4
4
 
5
5
  # Dashboard
6
- get 'dashboard', to: 'dashboard#index', as: :dashboard
6
+ get "dashboard", to: "dashboard#index", as: :dashboard
7
7
 
8
8
  # Security Events
9
9
  resources :security_events, only: [:index, :show] do
10
10
  collection do
11
- get 'export'
11
+ get "export"
12
12
  end
13
13
  end
14
14
 
15
15
  # Banned IPs
16
16
  resources :banned_ips do
17
17
  member do
18
- post 'extend'
18
+ post "extend"
19
+ get "review"
19
20
  end
20
21
 
21
22
  collection do
22
- post 'bulk_action'
23
- get 'export'
23
+ post "bulk_action"
24
+ get "export"
24
25
  end
25
26
  end
26
27
 
27
- # API endpoints (optional, for future AJAX calls)
28
- namespace :api do
29
- namespace :v1 do
30
- resources :security_events, only: [:index, :show] do
31
- collection do
32
- get 'stats'
33
- end
34
- end
28
+ resources :administrative_actions, only: [:index, :show]
35
29
 
36
- resources :banned_ips, only: [:index, :show, :create, :destroy] do
37
- member do
38
- post 'extend'
39
- end
40
- end
41
- end
42
- end
30
+ # No versioned JSON API is implemented. Authenticated CSV/JSON exports live
31
+ # on the resources above; do not expose routes to nonexistent controllers.
43
32
  end
@@ -8,7 +8,7 @@ class CreateBeskarSecurityEvents < ActiveRecord::Migration[8.0]
8
8
  t.string :ip_address
9
9
  t.string :attempted_email
10
10
  t.text :user_agent
11
- t.json :metadata, default: {}
11
+ t.json :metadata, default: -> { "('{}')" }
12
12
  t.integer :risk_score
13
13
 
14
14
  t.timestamps
@@ -19,7 +19,7 @@ class CreateBeskarSecurityEvents < ActiveRecord::Migration[8.0]
19
19
  add_index :beskar_security_events, :attempted_email
20
20
  add_index :beskar_security_events, :created_at
21
21
  add_index :beskar_security_events, :risk_score
22
- add_index :beskar_security_events, [:ip_address, :event_type, :created_at],
23
- name: 'index_security_events_on_ip_event_time'
22
+ add_index :beskar_security_events, [:ip_address, :event_type, :created_at],
23
+ name: "index_security_events_on_ip_event_time"
24
24
  end
25
25
  end
@@ -0,0 +1,14 @@
1
+ class CreateBeskarSecurityStates < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :beskar_security_states do |t|
4
+ t.string :key, null: false
5
+ t.json :data, null: false, default: -> { "('{}')" }
6
+ t.datetime :expires_at
7
+ t.integer :lock_version, null: false, default: 0
8
+ t.timestamps
9
+ end
10
+ add_index :beskar_security_states, :key, unique: true
11
+ add_index :beskar_security_states, :expires_at
12
+ add_index :beskar_security_events, [:user_type, :user_id, :event_type, :created_at], name: "index_beskar_events_on_user_event_time"
13
+ end
14
+ end
@@ -0,0 +1,22 @@
1
+ class CreateBeskarAdministrativeActions < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :beskar_administrative_actions do |t|
4
+ t.string :actor, null: false, limit: 200
5
+ t.string :action, null: false
6
+ t.bigint :target_id, null: false
7
+ t.string :operation_id, null: false, limit: 36
8
+ t.string :request_id, null: false, limit: 200
9
+ t.text :reason, null: false
10
+ # MySQL requires JSON defaults to be expressions, even for literals.
11
+ t.json :before_state, null: false, default: -> { "('{}')" }
12
+ t.json :after_state, null: false, default: -> { "('{}')" }
13
+ t.datetime :created_at, null: false
14
+ end
15
+ # No user/ban foreign key or cascading association: the action must survive
16
+ # deletion of its live target. Retention is a separate, explicit operation.
17
+ add_index :beskar_administrative_actions, [:operation_id, :target_id], unique: true,
18
+ name: "index_beskar_actions_on_operation_target"
19
+ add_index :beskar_administrative_actions, [:target_id, :id]
20
+ add_index :beskar_administrative_actions, :created_at
21
+ end
22
+ end
@@ -0,0 +1,6 @@
1
+ class ExpandAdministrativeActionTargets < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_column :beskar_administrative_actions, :target_type, :string, null: false, default: "BannedIp"
4
+ change_column_null :beskar_administrative_actions, :target_id, true
5
+ end
6
+ end
data/docs/README.md ADDED
@@ -0,0 +1,73 @@
1
+ # Beskar documentation
2
+
3
+ Start with the current guides and operational contracts below. The original
4
+ review and archived overview describe earlier code; they are not current setup
5
+ instructions. [Repair status](audits/repair-status.md) distinguishes verified
6
+ changes from open findings and production validation still required.
7
+
8
+ ## Start here
9
+
10
+ - **Installing or integrating:** follow the [project README](../README.md#installation),
11
+ then read [configuration](guides/configuration.md),
12
+ [authentication](guides/authentication.md), and [state storage](operations/state-storage.md).
13
+ - **Upgrading an existing installation:** use the
14
+ [security hardening and rollout checklist](operations/security-hardening.md#rollout).
15
+ - **Working on repairs:** consult [repair status](audits/repair-status.md) and the
16
+ [original findings](audits/project-review.md) before selecting work.
17
+
18
+ ## Guides
19
+
20
+ - [Configuration](guides/configuration.md) — validation, supported capabilities,
21
+ defaults, and audited runtime changes.
22
+ - [Authentication](guides/authentication.md) — Devise/Warden admission, native
23
+ session integration, account locks, and revocation boundaries.
24
+ - [Risk scoring](guides/risk-scoring.md) — factors, geographic evidence, and
25
+ heuristic limitations.
26
+ - [Audit data and WAF](guides/audit-and-waf.md) — filtering, exports, scanner
27
+ matching, and privacy boundaries.
28
+ - [Audit lifecycle](guides/audit-lifecycle.md) — unchanged event retention,
29
+ administrative permissions, actor/reason history, and migrations.
30
+ - [Dashboard and search](guides/dashboard-and-search.md) — reporting, search,
31
+ routes, forms, timezones, and browser/CSP behavior.
32
+ - [Notifications and recovery](guides/notifications-and-recovery.md) — opt-in
33
+ delivery jobs and host-owned recovery flows.
34
+
35
+ ## Operations
36
+
37
+ - [State storage](operations/state-storage.md) — database authority, cache
38
+ independence, concurrency, rate limits, cleanup, and rollout.
39
+ - [Monitor-only mode](operations/monitor-only-mode.md) — observation behavior and
40
+ the transition to enforcement.
41
+ - [Security hardening](operations/security-hardening.md) — authentication
42
+ coverage, deployment requirements, availability tradeoffs, and validation gates.
43
+
44
+ ## Audits and repair tracking
45
+
46
+ - [Repair status](audits/repair-status.md) — completed batches, test evidence,
47
+ unresolved findings, and next verification gates.
48
+ - [Original project review](audits/project-review.md) — the September 2026
49
+ diagnostic baseline and F01–F26 findings; preserved as historical evidence.
50
+
51
+ ## Research and historical reference
52
+
53
+ - [Rust performance assessment](research/rust-performance-assessment.md) — a
54
+ measured investigation and proposal, not an implemented feature or performance
55
+ guarantee.
56
+ - [Archived project overview](archive/project-documentation.md) — the
57
+ pre-remediation architecture and examples; do not use it as current guidance.
58
+ - [Changelog](../CHANGELOG.md) — historical release notes.
59
+
60
+ ## Maintaining these docs
61
+
62
+ Keep `README.md` and `CHANGELOG.md` at the repository root. Put current feature
63
+ guides in `guides/`, deployment/runtime contracts in `operations/`, findings and
64
+ progress in `audits/`, proposals in `research/`, and superseded references in
65
+ `archive/`. Use lowercase, hyphenated filenames and relative Markdown links.
66
+ Source paths mentioned in prose are relative to the repository root.
67
+
68
+ Add new documents to this index. The gem includes `docs/**/*.md`; link/index and
69
+ packaging regressions run with:
70
+
71
+ ```sh
72
+ mise exec -- env PARALLEL_WORKERS=1 bin/rails test test/documentation_test.rb
73
+ ```