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
@@ -0,0 +1,213 @@
1
+ # Authentication admission and account locks
2
+
3
+ Authentication enforcement is independent of audit persistence. Disabling
4
+ `security_tracking`, or a failed optional audit write, does not turn off rate
5
+ limits or risk-based locking. A request-local attempt ID correlates the decision
6
+ with any audit records that were successfully written.
7
+
8
+ ## Dashboard access
9
+
10
+ The initializer generated by `bin/rails generate beskar:install` contains complete
11
+ dashboard examples for Rails built-in authentication and Devise. Uncomment one
12
+ example's three callbacks: `authenticate_admin`, `authorize_admin`, and
13
+ `audit_actor`. Adapt the example's `admin?` and `beskar_permissions` methods to
14
+ your application; neither Rails nor Devise provides those role/permission methods.
15
+
16
+ Beskar's dashboard controller inherits from `ActionController::Base`, not your
17
+ application controller. Your host's `Authentication` concern does not run there,
18
+ so `Current.session`, `Current.user`, or `current_user` cannot be assumed available.
19
+ The Rails example resolves `::Session` using `cookies.signed[:session_id]`, checks
20
+ `SessionRevocation.native_session_allowed?`, and shares the resolved user between
21
+ the three callbacks through a controller instance variable. It requires the
22
+ Rails-native model integration below. Adapt the lookup if your host changed the
23
+ generated cookie or session model.
24
+
25
+ If the session guard raises `NoMethodError` for `beskar_access_allowed?`, add
26
+ `include Beskar::Models::SecurityTrackableAuthenticable` to the host user model.
27
+ The similarly named `Beskar::Models::SecurityTrackable` concern is Devise-specific
28
+ and does not supply this method. Keep the revocation check in place so locked
29
+ accounts cannot access the dashboard through an existing session.
30
+
31
+ Devise uses Warden and does not require a Rails-native `Session` model. Its example
32
+ uses `request.env['warden'].authenticate(scope: :user)` to resume a session or run
33
+ configured strategies, then `.user(scope: :user)` to read that identity in the
34
+ permission and audit callbacks. The scope identifies a Devise mapping, not a role;
35
+ use `:admin` only for an app with that mapping. See the
36
+ [Warden authentication API](https://github.com/wardencommunity/warden/blob/master/lib/warden/proxy.rb).
37
+
38
+ Authentication alone does not grant dashboard permissions. `authorize_admin`
39
+ must return `true` for each allowed operation (`read`, `manage_bans`, `export`,
40
+ `read_audit`). Writes and exports additionally require a trusted `audit_actor`;
41
+ see [Audit lifecycle](audit-lifecycle.md).
42
+
43
+ ## Devise
44
+
45
+ Include `Beskar::Models::SecurityTrackable` on each protected model. The engine
46
+ wraps Devise's database-password strategy: it identifies the target account and
47
+ reserves IP/account (and opt-in global) capacity **before password verification**. This includes
48
+ Devise HTTP Basic password authentication. Unknown accounts receive hashed,
49
+ normalized identity keys; password values are not used as counter keys.
50
+
51
+ The Warden outcome hook reuses the same admission instead of counting twice.
52
+ Protected-page visits without credentials and session fetches do not manufacture
53
+ failed logins. Devise scope aliases are resolved through `Devise.mappings`.
54
+
55
+ Risk-based Devise locking requires `:lockable`. A confirmed lock always rejects
56
+ the current attempt; `immediate_signout` defaults true and legacy false no longer
57
+ bypasses a lock. Normal persisted `locked_at` changes rotate a durable account
58
+ generation, invalidating all existing Devise sessions and remember-me cookies.
59
+ Unlock never restores an older generation. This includes Devise's own lockable
60
+ locks and manual model updates, not bulk SQL bypasses. Unrelated accounts/scopes
61
+ remain signed in. Generation reads bypass Active Record's query cache.
62
+
63
+ **Upgrade:** the new session/remember-cookie salt format signs out existing
64
+ Devise clients once. Drain old workers; mixed versions cannot safely enforce the
65
+ new credentials. All authentication models and state must use one writer pool.
66
+
67
+ Devise owns its own `unlock_strategy` and `unlock_in`. Beskar's `auto_unlock_time`
68
+ does **not** override Devise's class-level unlock policy. Configure it in Devise.
69
+ Standard custom Warden strategies now reserve IP capacity before `_run!`
70
+ verification; invalid tokens count. The account is charged once when an opaque
71
+ strategy identifies it. Warden `set_user` is guarded even with
72
+ `run_callbacks: false`, covering OAuth-style manual sign-in and stateless Warden.
73
+ Session fetch checks locks without counting a login. A custom strategy overriding
74
+ Warden's dispatcher, model serialization, or credential verifier needs its own
75
+ review. Beskar cannot revoke a host bearer token that omits the generation check.
76
+ See [Security hardening and rollout](../operations/security-hardening.md) for API and WebSocket adapters.
77
+
78
+ ## Rails-native authentication: required integration
79
+
80
+ The old logging-only controller calls are insufficient. Upgrade the login
81
+ controller to reserve admission before `authenticate_by`, then guard session
82
+ creation:
83
+
84
+ ```ruby
85
+ class User < ApplicationRecord
86
+ has_secure_password
87
+ has_many :sessions, dependent: :destroy
88
+ normalizes :email_address, with: ->(email) { email.strip.downcase }
89
+ include Beskar::Models::SecurityTrackableAuthenticable
90
+ end
91
+
92
+ class SessionsController < ApplicationController
93
+ include Authentication
94
+ include Beskar::Controllers::SecurityTracking
95
+
96
+ allow_unauthenticated_access only: %i[new create]
97
+ before_action -> { admit_authentication_attempt(User, :user) }, only: :create
98
+
99
+ def create
100
+ if (user = User.authenticate_by(params.permit(:email_address, :password)))
101
+ return unless complete_authentication(user) { start_new_session_for(user) }
102
+ redirect_to after_authentication_url
103
+ else
104
+ track_authentication_failure(User, :user)
105
+ redirect_to new_session_path, alert: "Try another email address or password."
106
+ end
107
+ end
108
+ end
109
+ ```
110
+
111
+ For custom identity fields, pass an explicit identity-only hash through
112
+ `admit_authentication_attempt(User, :user, credentials: {...})`. Match the identity
113
+ lookup used by your authentication code. Never include passwords in that hash.
114
+
115
+ The session-creation block runs in a retryable database transaction; restrict it
116
+ to session creation and response-cookie assignment, not external notifications or
117
+ other non-idempotent external actions. Users, sessions, and Beskar state must use
118
+ the same writer connection pool for these transactions to be atomic. Sharded or
119
+ cross-database authentication models need a separately designed adapter.
120
+
121
+ Existing-session readers must also honor the persistent lock. In Rails'
122
+ `Authentication` concern, adapt `resume_session` along these lines:
123
+
124
+ ```ruby
125
+ def resume_session
126
+ Current.session ||= find_session_by_cookie
127
+ if Current.session && !Beskar::Services::SessionRevocation.native_session_allowed?(Current.session, request: request)
128
+ Current.session = nil
129
+ cookies.delete(:session_id)
130
+ end
131
+ Current.session
132
+ end
133
+ ```
134
+
135
+ The dummy application contains the exercised integration. Include the check on
136
+ every other path that resumes a native session (including custom API or websocket
137
+ authentication). In-flight requests cannot be retroactively canceled by a lock.
138
+
139
+ Native locks use `beskar_security_states`; no lock columns on the user table are
140
+ required. The default strategy selects native locking for native models. A lock
141
+ revokes all database sessions, never compares their IDs to the unrelated Rack
142
+ session ID, and serializes with guarded new-session creation. If a host session
143
+ destruction callback fails or aborts, the persistent lock remains authoritative,
144
+ access is denied by the reader guard, and physical cleanup failure is logged.
145
+ That lock becomes manual-only so expiry cannot reactivate old sessions. Explicit
146
+ unlock retries cleanup and only clears the lock if every session was removed.
147
+
148
+ `auto_unlock_time` controls native lock duration. Set it to `nil` for manual-only
149
+ unlocking. To explicitly unlock:
150
+
151
+ ```ruby
152
+ Beskar::Services::AccountLocker.new(user, risk_score: 0).unlock!
153
+ ```
154
+
155
+ Native lock rows retain their deadline inside their data; periodic expired-state
156
+ cleanup does not erase manual locks. Account-lifecycle cleanup remains separate.
157
+
158
+ ## Policy and failure boundaries
159
+
160
+ - Monitor mode and whitelisted IPs suppress Beskar automatic account locks,
161
+ sign-outs, and emergency password resets. Observed authentication counters do
162
+ not consume enforced global/account capacity.
163
+ - Devise's own failed-attempt Lockable policy and host Rails rate limiters are
164
+ independent. Beskar does not disable those host policies in monitor mode.
165
+ - Admission denials return HTTP 429 with a retry deadline. Native lock denials
166
+ return HTTP 403. Required authentication-state or enforced risk-assessment
167
+ failures reject authentication with HTTP 503. Optional audit/enrichment failures
168
+ are logged and do not reject an otherwise allowed attempt.
169
+ - Direct model tracking methods preserve their optional `SecurityEvent` return
170
+ contract; that return is **not** an admission decision. Native controllers should
171
+ use `complete_authentication`, not infer authorization from whether an event
172
+ saved. `track_authentication_success` now returns a boolean for custom callers;
173
+ ignoring it is unsafe and does not provide the concurrent session guard.
174
+
175
+ ## Audit and recovery
176
+
177
+ Known failed targets are associated with their user record for account-history
178
+ analysis. Rejected admissions/sessions use `authentication_blocked`, not
179
+ `login_success`. `metadata["authentication"]` records the attempt ID, scope,
180
+ admission outcome, and actual lock result. Lock events reference that same attempt
181
+ ID; the login audit row may not yet exist or may be disabled.
182
+
183
+ Deleting an account retains its linked events unchanged, including the original
184
+ `user_type` and `user_id`. This is not anonymization; no event expiry or automatic
185
+ purge is added. See [Audit lifecycle](audit-lifecycle.md) for retention, missing-user
186
+ presentation, and the separate required journal for dashboard ban changes.
187
+
188
+ Authentication context no longer stores Rack session IDs. Referrers are restricted
189
+ to HTTP(S), with credentials, queries, and fragments removed. Other WAF/export
190
+ privacy findings are still tracked separately; this is not a complete audit-data
191
+ redaction policy.
192
+
193
+ Emergency reset remains opt-in. Thresholds count confirmed account-lock evidence,
194
+ not strings containing a false travel/device flag or duplicate success events.
195
+ Password invalidation, session revocation, and the mandatory recovery audit commit
196
+ together; failure rolls them back. `require_manual_unlock: true` removes the native
197
+ automatic-unlock deadline. Notification hooks run after all enclosing transactions
198
+ commit.
199
+
200
+ Account-lock and native emergency-reset notifications now use opt-in post-commit
201
+ Action Mailer jobs. The three notification flags default to false; configure a
202
+ sender, HTTPS recovery entry page, and any security-team recipients before enabling
203
+ them. See [Notifications and recovery](notifications-and-recovery.md) for retries,
204
+ legacy hook overrides, delivery/privacy limits, and the tested native recovery
205
+ handoff. A successful password reset does not bypass a Beskar manual lock. Real
206
+ production delivery and host-specific recovery verification remain necessary.
207
+ Normalized risk evidence and removal of unsafe automatic trust discounts are
208
+ documented in [Risk scoring](risk-scoring.md).
209
+
210
+ See [Configuration](configuration.md) for validated lock strategies and the
211
+ optional host-owned, post-commit analysis hook. The old `:custom` placeholder is
212
+ rejected; use a supported strategy or explicit `:none`. No built-in background
213
+ analyzer is supplied; analysis and notification delivery are separate job paths.
@@ -0,0 +1,182 @@
1
+ # Configuration and supported capabilities
2
+
3
+ Beskar validates configuration during Rails startup, after main autoloader setup
4
+ and host `after_initialize` callbacks registered by configuration files. Invalid settings raise
5
+ `Beskar::Configuration::Error` and stop boot. Errors identify known setting paths
6
+ without interpolating supplied values or unknown keys.
7
+
8
+ ## Changes and defaults
9
+
10
+ Prefer `Beskar.configure` in `config/initializers/beskar.rb`:
11
+
12
+ ```ruby
13
+ Beskar.configure do |config|
14
+ config.monitor_only = true
15
+ config.rate_limiting = {ip_attempts: {limit: 20}}
16
+ end
17
+ ```
18
+
19
+ Whole-section assignment overlays **library defaults**, including nested defaults.
20
+ The example keeps the default IP period and account/global limits. It also resets
21
+ any previous customizations in that section. To retain existing customizations,
22
+ edit individual entries inside the configure block:
23
+
24
+ ```ruby
25
+ Beskar.configure do |config|
26
+ config.rate_limiting[:ip_attempts][:limit] = 20
27
+ end
28
+ ```
29
+
30
+ `configure` copies the current settings, yields the copy, validates it, and
31
+ publishes another copy. A raised exception or validation failure leaves the
32
+ active configuration unchanged. Retaining the block's nested hashes/arrays does
33
+ not provide a reference to the published values. Proc callbacks and classes
34
+ remain trusted host objects; their behavior is not sandboxed or deep-copied.
35
+
36
+ Configuration is sealed after startup validation. Direct/nested mutation and
37
+ wholesale replacement are then rejected. Prefer initializer changes plus a
38
+ coordinated restart. Runtime `Beskar.configure(actor:, reason:, request_id:)`
39
+ requires an explicit `authorize_configuration` grant, validates a detached copy,
40
+ and records a mandatory filtered before/after journal before publishing it.
41
+ Missing authorization, invalid settings, open database transactions, or failed
42
+ journaling cannot publish. Publication is serialized within the Ruby process.
43
+ This is not a distributed configuration store or a per-request snapshot; runtime
44
+ changes affect only this process. The journal records authorized publication
45
+ intent, not proof every worker adopted it. A process crash after journaling can
46
+ prevent publication. Boot/deployment edits and changes inside trusted callback
47
+ implementations must be audited by the host deployment/source-control workflow.
48
+ See [Security hardening and rollout](../operations/security-hardening.md) for the complete contract.
49
+
50
+ During initialization, `configure` defers resolution of named host jobs until the
51
+ final startup check, when `app/jobs` can be autoloaded. Runtime `configure` and an
52
+ explicit `validate!` resolve enabled jobs immediately.
53
+
54
+ ## What is validated
55
+
56
+ - Known symbol-keyed section schemas; unknown, misspelled, string, and removed
57
+ keys are rejected. Partial section assignments fill missing defaults; deleting
58
+ required entries in place is invalid.
59
+ - Actual booleans, not strings such as `"false"`; dashboard authorization and
60
+ `audit_actor` callbacks must each be a Proc or nil. Nil authorization denies all
61
+ dashboard access; nil `audit_actor` permits authenticated reads but rejects writes.
62
+ Validation does not execute callbacks, grant access, or verify their decisions.
63
+ - Finite positive windows, cache lifetimes, decay half-lives, block durations,
64
+ and thresholds; integer attempt/history/emergency counts. Authentication risk
65
+ thresholds must be between 0 and 100. Native `auto_unlock_time` and WAF
66
+ `permanent_block_after` accept nil for manual-only unlock/no permanent escalation.
67
+ - Valid IP/CIDR whitelist strings; WAF exception policies, nonempty block-duration
68
+ arrays, regexp exclusions, and method/category names.
69
+ - Supported lock strategies and geolocation providers; configured authentication
70
+ scope-name syntax. MaxMind requires an existing readable database path.
71
+ - An explicit Active Job subclass when automatic analysis is active.
72
+ - Sender/recipient mailbox syntax and an explicit HTTPS recovery entry page when
73
+ user notifications are enabled; a nonempty recipient list for enabled team alerts.
74
+
75
+ Validation does not query application tables or `Rails.cache`. Any Rails.cache
76
+ backend remains supported; coordinated enforcement still uses the database
77
+ contract in [State storage](../operations/state-storage.md). Validation does not prove host
78
+ model/adapter readiness, MaxMind file integrity, queue availability, or production
79
+ database correctness. Runtime enrichment failure handling remains separate.
80
+
81
+ ## Supported capabilities
82
+
83
+ | Area | Contract |
84
+ | --- | --- |
85
+ | Account locks | `:devise_lockable`, `:rails_auth`, or explicit `:none`. The placeholder `:custom` and unknown values are rejected. Host adapter requirements remain in [Authentication](authentication.md). |
86
+ | Geolocation | `:mock` or `:maxmind`. IP2Location and unknown providers are rejected, including by direct service construction. Mock results do not supply geographic risk evidence. |
87
+ | Background analysis | Off by default. A host-owned Active Job can opt into the post-commit hook below; no built-in analyzer is supplied. |
88
+ | Administration API | No versioned API; authenticated dashboard resource exports remain available. |
89
+ | Administrative history | Dashboard ban changes require a trusted `audit_actor` and per-request `audit_reason`, with mandatory transactional history. See [Audit lifecycle](audit-lifecycle.md) for configuration and the new migration. |
90
+ | Notifications/recovery delivery | Opt-in Action Mailer notices for account locks and native emergency resets, with post-commit jobs and bounded retries. Explicit sender/recovery/team configuration is required. The host still owns password-reset/unlock flows; see [Notifications and recovery](notifications-and-recovery.md). |
91
+
92
+ Unsupported lock strategies also raise when read at runtime. With risk enforcement
93
+ enabled, authentication rejects this misconfiguration with 503 rather than
94
+ silently skipping the lock; both supported authentication paths are regression
95
+ tested. `:none` deliberately disables locking and is not a custom adapter hook.
96
+
97
+ ## Optional host background analysis
98
+
99
+ Provide a host job and explicitly enable it. Prefer a class-name string so each
100
+ invocation resolves the current Rails-autoloaded class:
101
+
102
+ ```ruby
103
+ # app/jobs/security_review_job.rb
104
+ class SecurityReviewJob < ApplicationJob
105
+ def perform(user_type:, user_id:, event_type:)
106
+ # Restrict supported identities explicitly; adapt for your host models.
107
+ return unless user_type == "User" && event_type == "login_success"
108
+ user = User.find_by(id: user_id)
109
+ return unless user
110
+
111
+ suspicious = user.suspicious_login_pattern?
112
+ Rails.logger.info("Security review user_id=#{user.id} suspicious=#{suspicious}")
113
+ end
114
+ end
115
+
116
+ # config/initializers/beskar.rb
117
+ Beskar.configure do |config|
118
+ config.security_tracking[:analysis_job] = "SecurityReviewJob"
119
+ config.security_tracking[:auto_analyze_patterns] = true
120
+ end
121
+ ```
122
+
123
+ Automatic invocation occurs on the successful-login tracking path after an
124
+ admitted attempt, with successful-login tracking enabled. It is optional analysis,
125
+ not asynchronous authentication enforcement. A failed optional audit write does
126
+ not by itself prevent invocation. The arguments contain only the model's base
127
+ class name, user ID, and `"login_success"`; no email, credential, session, raw
128
+ request context, or audit-event ID is passed. IDs still identify users and require
129
+ appropriate queue access/retention policy.
130
+
131
+ Monitor-only and whitelisted observations can invoke the hook too. Arguments do
132
+ not contain the policy mode; host jobs must not infer permission to lock, revoke,
133
+ or reset an account from receiving a job. Keep this hook read-only enrichment;
134
+ final admission and applicable policy remain synchronous.
135
+
136
+ Enqueueing waits for all enclosing Active Record transactions to commit. An outer
137
+ rollback or rolled-back savepoint cancels its callback. With no open transaction,
138
+ enqueueing runs immediately. Preparation errors, queue exceptions, and aborted
139
+ enqueue attempts are logged without raw exception messages and do not undo a
140
+ committed login/update or change admission.
141
+
142
+ This is **not a transactional outbox**: a process crash between commit and enqueue
143
+ can lose analysis. Backends/retries may duplicate work. Hosts own queue selection,
144
+ workers, retry/idempotence policy, and any eventual delivery. The local tests use
145
+ real database commits/rollbacks and the Active Job test adapter, not a production
146
+ worker or durable delivery service. The public `analyze_suspicious_patterns_async`
147
+ helper is also callable directly, but is not an admission/authorization check.
148
+
149
+ ## Upgrade notes
150
+
151
+ - Configure `authorize_admin(request, permission)` separately from authentication;
152
+ missing permissions deny dashboard access. Grants are `:read`, `:manage_bans`,
153
+ `:export`, and `:read_audit`. Exports now require actor/reason/history as well.
154
+ - Global login limits and request-wide authentication-quota blocking default off.
155
+ Explicitly opting in restores their distributed-denial/shared-NAT tradeoffs.
156
+ - A real lock always rejects the login and invalidates prior Devise credentials;
157
+ `immediate_signout: false` is a deprecated compatibility value, not a bypass.
158
+
159
+ - Dashboard mutations now require a server-derived `audit_actor` callback and
160
+ `audit_reason`. Deploy the administrative-action migration before new workers;
161
+ see [Audit lifecycle](audit-lifecycle.md). Missing actor configuration returns
162
+ 503 for writes, without disabling authenticated reads.
163
+ - Lock/user-reset/team notification flags now default to false. Their old true
164
+ defaults were log-only placeholders. Explicitly configure delivery before
165
+ enabling them; see [Notifications and recovery](notifications-and-recovery.md).
166
+ - Automatic analysis now defaults to false. Enabling it requires `analysis_job`;
167
+ the former silent discovery of a nonexistent `Beskar::SecurityAnalysisJob` is
168
+ removed. Adapt any existing custom job to the explicit keyword contract above.
169
+ - Replace `:custom`/IP2Location configuration with a supported capability; these
170
+ placeholders no longer silently do nothing.
171
+ - Remove obsolete `waf[:block_threshold]` and `waf[:monitor_only]`. Use cumulative
172
+ `waf[:score_threshold]` and top-level `monitor_only`, respectively. A violation
173
+ count is not interchangeable with a cumulative score.
174
+ - MaxMind configuration with a missing/unreadable path now stops startup. Supply
175
+ a readable database or explicitly choose `:mock` without geographic risk evidence.
176
+ - Review partial section assignments for the default-overlay behavior above.
177
+ The sixth batch required no new migration; the eighth batch adds the history table.
178
+
179
+ The Ruby 4.0.6 regressions exercise invalid/valid application boots, host job
180
+ autoloading, rejected configure-block publication, real commit/rollback behavior,
181
+ and optional queue failures. Production workers, actual class reloading, MaxMind
182
+ database reload/load behavior, and host-specific adapter readiness remain unverified.
@@ -0,0 +1,251 @@
1
+ # Dashboard and search contract
2
+
3
+ Reporting/search behavior introduced in the fifth repair batch, which required
4
+ no new migration. The eighth batch adds a history table and required actor/reason
5
+ for dashboard writes; see [Audit lifecycle](audit-lifecycle.md).
6
+ See [Audit data and WAF](audit-and-waf.md) for capture, redaction, export paging,
7
+ and privacy limitations, and [Repair status](../audits/repair-status.md) for open work.
8
+
9
+ ## One set of reporting bands
10
+
11
+ `Beskar::RiskLevel` supplies model predicates/scopes, dashboard counts, event
12
+ filters (including exports), badges, labels, colors, and filter option text:
13
+
14
+ | Band | Event score | Badge |
15
+ | --- | --- | --- |
16
+ | Low | 0–29 | success |
17
+ | Medium | 30–69 | warning |
18
+ | High | 70–89 | danger |
19
+ | Critical | 90–100 | critical |
20
+
21
+ The model's existing high/critical boundaries of 70 and 90 are authoritative.
22
+ `high_risk` and the dashboard's High Risk Events total include critical events;
23
+ the `risk_level=high` filter selects only 70–89. This distinguishes a minimum
24
+ threat threshold from an exclusive histogram bucket.
25
+
26
+ Invalid/missing/out-of-range scores are unknown, not critical. They receive a
27
+ neutral badge and are excluded from score-band counts, while still counting as
28
+ events in overall totals. Fractional aggregate scores use the same boundaries:
29
+ 69.9 remains medium, 89.9 remains high.
30
+
31
+ These are reporting bands, not a change to scoring weights, configured account
32
+ lock thresholds, WAF severity points, or cumulative WAF ban thresholds.
33
+ Older dashboard thresholds of 61/86 and the conflicting boundary at 30 are
34
+ removed. Review saved filters/report consumers that relied on those old ranges.
35
+
36
+ ## Statistics and user presentation
37
+
38
+ Overview event totals and failed-login counts reuse one grouped event-type
39
+ query. Risk-band and high/critical counts reuse one grouped score query. The
40
+ selected statistics period has both a lower bound and a current-time upper
41
+ bound, excluding future-dated records. The recent-activity list remains the
42
+ latest ten events across periods, as its own section describes.
43
+
44
+ These separate queries are not a transactional snapshot: concurrent writes can
45
+ still change results between queries. Grouped counts reduce repeated queries
46
+ but do not remove the cost of scanning a large selected period.
47
+
48
+ Ban details compute count, average, maximum, first seen, and last seen from all
49
+ events for that IP. The table still shows only the latest 20. Related-event
50
+ tables preload their user associations; lists with equal timestamps use IDs
51
+ as a descending tie-breaker. Pagination remains offset-based for HTML lists;
52
+ exports retain their separate descending-ID cursor contract.
53
+
54
+ Associated user labels support both Devise `email` and native `email_address`.
55
+ The same bounded, filtered presentation is used by dashboard/event/ban views
56
+ and exports. Native associated email addresses honor both `email_address` and
57
+ the common `email` filter name; this does not rewrite the host user record.
58
+ Attempted-email and metadata fields retain their own audit-key filtering
59
+ contract. Configure a broad `:email` filter to cover those email-bearing keys.
60
+ Where no address is available, views use an attempted email or user-ID fallback.
61
+
62
+ ## Search semantics and database support
63
+
64
+ Event index and both export formats use `Services::EventSearch`:
65
+
66
+ - General search covers IP, User-Agent, attempted email, event type, and a text
67
+ representation of metadata on all three database families below.
68
+ - The email filter checks the attempted-email column, falling back only when
69
+ that column is null to the top-level `metadata.attempted_email` value.
70
+ Unrelated metadata prose and the user's current email are not email matches.
71
+ - Terms are bounded to 256 characters, lowercased for ASCII-insensitive matching,
72
+ and passed as SQL values. Percent, underscore, and the chosen escape character
73
+ (`!`) are literal text, not user-controlled LIKE wildcards.
74
+ - Missing/empty/non-text search values do not add a filter. Non-ASCII case folding,
75
+ collation behavior, and serialized-JSON escaping remain database-dependent.
76
+
77
+ JSON cannot be handled as an ordinary string column everywhere. PostgreSQL
78
+ extracts legacy email text with `->>`; SQLite uses `json_extract`; MySQL uses
79
+ `JSON_EXTRACT` plus `JSON_UNQUOTE`, preserving JSON-null behavior. The centralized
80
+ expressions follow the respective primary references:
81
+ [PostgreSQL JSON operators](https://www.postgresql.org/docs/current/functions-json.html),
82
+ [SQLite JSON functions](https://www.sqlite.org/json1.html), and
83
+ [MySQL JSON search functions](https://dev.mysql.com/doc/refman/8.4/en/json-search-functions.html).
84
+
85
+ General metadata search uses a TEXT cast on PostgreSQL/SQLite and a CHAR cast on
86
+ MySQL (Mysql2/Trilogy), instead of applying LIKE directly to a JSON column.
87
+ This is text search, not a structured JSON query language or a full-text index.
88
+
89
+ SQLite execution, request/export integration, literal-wildcard handling, and
90
+ PostgreSQL/MySQL SQL generation are tested locally. No live PostgreSQL/MySQL
91
+ server, production collation matrix, query plan, or throughput benchmark has
92
+ been exercised by this batch. Other adapters have no implemented legacy-email
93
+ extractor and fail explicitly when that filter is used.
94
+
95
+ Search operates on stored values, before read-time model filtering. It can
96
+ therefore match sensitive legacy values that are hidden when rendered, revealing
97
+ record membership to an authorized administrator. Read-time redaction is not
98
+ historical erasure; remediate legacy storage separately if that inference is
99
+ unacceptable. Searches over newly redacted values cannot recover the original.
100
+ Leading-substring and JSON-text search can still be expensive despite paging.
101
+
102
+ ## Public routes and remaining work
103
+
104
+ Removed the unimplemented `/beskar/api/v1/*` routes and their route helpers.
105
+ They previously pointed to missing controllers. Supported read exports remain
106
+ `/beskar/security_events/export.csv|json` and
107
+ `/beskar/banned_ips/export.csv|json`, behind dashboard authorization.
108
+ There is no versioned programmatic administration API.
109
+
110
+ The fifth batch verified rendered HTML and controllers. Batch nine additionally
111
+ exercises the forms in Chromium as described below. Administrative lifecycle and
112
+ opt-in notifications have their own contracts in [Audit lifecycle](audit-lifecycle.md)
113
+ and [Notifications and recovery](notifications-and-recovery.md).
114
+
115
+ ## Ban forms and timezones
116
+
117
+ New/edit expiry fields are explicitly **UTC**, independent of browser timezone and
118
+ the host application's `Time.zone`. A timezone-free dashboard value such as
119
+ `2030-11-03T01:30` means 01:30 UTC, not a DST-dependent local wall time. Scripted
120
+ dashboard requests may also supply ISO 8601 timestamps with `Z` or a numeric
121
+ `+HH:MM`/`-HH:MM` offset. These are normalized to the identified UTC instant.
122
+ The parser accepts valid calendar dates with four-digit positive years, hours
123
+ 0–23, optional seconds, and up to six fractional digits. Malformed dates, overflow
124
+ times, leap seconds, non-string shapes, and excessive precision return 422 rather
125
+ than normalizing to another date or silently selecting a default.
126
+
127
+ HTML datetime controls use millisecond precision. Edit forms mark that precision
128
+ with `expiry_precision=milliseconds`; submitting the unchanged displayed instant
129
+ preserves existing database microseconds. Scripted requests without that form
130
+ marker retain their explicit precision. This is not optimistic locking against
131
+ stale form edits. Report timestamps outside these inputs continue to use their
132
+ existing Rails/application-zone presentation; they are not reinterpreted as UTC
133
+ wall-clock input.
134
+
135
+ Creation presets are computed on the server, once: a positive integer number of
136
+ seconds, capped at 90 days. An explicit custom expiry takes precedence; absent/empty
137
+ duration and custom expiry use 24 hours. Invalid supplied presets and unknown ban
138
+ types return 422. Permanent creation ignores temporary duration/expiry values.
139
+ This restriction applies to dashboard creation/the manager, not a new global ban
140
+ duration cap. Direct `BannedIp.ban!` and administrative extension contracts remain
141
+ separate. Valid custom dates can be in the past, permitting an explicit expiry;
142
+ the database does not silently advance them.
143
+
144
+ Quick edit buttons add elapsed UTC hours to the later of the displayed expiry or
145
+ the browser's current time; they never convert a UTC string through the browser's
146
+ local timezone. They only edit the field until the operator submits. Switching a
147
+ permanent ban to temporary requires an expiry; JavaScript suggests 24 hours if the
148
+ field is empty. Switching to permanent disables temporary controls, and server
149
+ normalization clears expiry. Presets are not converted to client-clock timestamps
150
+ on submission. Create-form validation retries retain the selected duration rather
151
+ than turning it into a stale custom expiry. Existing custom reasons remain selectable,
152
+ and blank-expiry validation renders without a secondary view error.
153
+
154
+ ## Script policy and native navigation
155
+
156
+ One layout behavior script carries the host-generated nonce. Inline `onclick`,
157
+ `onchange`, and submission handlers are removed from the dashboard. Delegated
158
+ listeners are installed once per document; initial load and restored pages refresh
159
+ the control state without adding duplicate handlers. Validation errors and notices
160
+ are no longer automatically removed after five seconds.
161
+
162
+ The engine does not loosen the host's CSP. Hosts enforcing script CSP must provide
163
+ a nonce generator and allow that nonce in `script-src`, for example:
164
+
165
+ ```ruby
166
+ # Host CSP configuration: merge with your existing policy, do not discard it.
167
+ config.content_security_policy_nonce_generator = ->(_) { SecureRandom.base64(24) }
168
+ config.content_security_policy_nonce_directives = %w[script-src style-src]
169
+ ```
170
+
171
+ Local tests enforce `script-src 'self' 'nonce-…'`, `script-src-attr 'none'`, and no
172
+ script `unsafe-inline` or `unsafe-eval`. The layout's style block also receives a
173
+ nonce, but the existing views still contain **inline style attributes**. Tests
174
+ explicitly allow `style-src-attr 'unsafe-inline'`; hosts forbidding those attributes
175
+ will not get the intended styling. This is script-policy compatibility, not full
176
+ strict-style CSP support. Moving the remaining styles into classes/assets remains
177
+ open; hosts should not weaken script policy to accommodate dashboard controls.
178
+
179
+ The dashboard body explicitly opts out of Turbo Drive (`data-turbo="false"`).
180
+ Links and forms use native navigation even when the host has loaded Turbo. The
181
+ handwritten method-link/form synthesizer is removed; Rails forms carry their own
182
+ CSRF token and `_method` where needed. Beskar does not require Rails UJS, Turbo,
183
+ an import map, or a JavaScript bundler for its dashboard. Mutation redirects use
184
+ the existing Rails redirect flow; no new client-side submission protocol is added.
185
+
186
+ With JavaScript disabled or the behavior script blocked, normal new/edit/review
187
+ forms still work. Temporary fields and bulk controls remain available; server
188
+ authorization, reasons, validation, and CSRF remain mandatory. Quick edit buttons,
189
+ preview, selection helpers, automatic page-size submission, and bulk confirmation
190
+ dialogs are enhancements. A visible page-size submit button works without scripts.
191
+ Changing page size preserves filters, drops the old page number, and avoids duplicate
192
+ hidden `per_page` inputs. Row unban actions still require a separate review form;
193
+ bulk confirmation dialogs require JavaScript.
194
+
195
+ ## Browser verification and remaining limits
196
+
197
+ Browser tests use the standard [Rails system-test integration](https://api.rubyonrails.org/v8.0/classes/ActionDispatch/SystemTestCase.html)
198
+ with Capybara and Selenium, installed only in the development project's test bundle.
199
+ Turbo Rails is a test-only dependency used to load the real host library, not a new
200
+ runtime dependency of Beskar. Run separately from the ordinary suite:
201
+
202
+ ```sh
203
+ mise exec -- env PARALLEL_WORKERS=1 bin/rails test test/system/dashboard_test.rb
204
+ ```
205
+
206
+ Use an installed Chrome/Chromium and matching ChromeDriver. Optional
207
+ `BESKAR_BROWSER_BINARY` and `BESKAR_BROWSER_DRIVER` select their paths; otherwise the
208
+ harness discovers local binaries/driver, with Selenium's usual fallback when no
209
+ driver is installed. `BESKAR_BROWSER_LOG` enables verbose ChromeDriver logs at the
210
+ specified path, appended across tests. Each test gets a fresh browser process.
211
+ The tests do not use a production account, copy the project runtime, or disable
212
+ Chromium's sandbox. Failed startup does not trigger another browser launch for
213
+ screenshots or teardown.
214
+
215
+ Coverage includes UTC edits with multiple browser zones and DST-boundary dates,
216
+ microsecond preservation, server-relative presets with a skewed browser clock,
217
+ permanent/temporary toggles, persistent validation feedback/retry, bulk clear/cancel/
218
+ confirm, page-size filters, actual Turbo-loaded navigation, back navigation, no
219
+ duplicate audit operations, and JavaScript-disabled create/review/delete with real
220
+ CSRF protection. Browser console checks reject script/CSP errors (excluding a
221
+ missing favicon and the deliberately tested validation response's HTTP 422).
222
+ Screenshot inspection supplemented functional checks.
223
+
224
+ Native form submissions now wait for the destination URL before checking the
225
+ success notice. Scoped notice checks also wait for same-URL redirects, such as
226
+ bulk unban. This avoids reading the outgoing document during navigation: in
227
+ [CI run 35353219531](https://github.com/AuditBadger-com/beskar/actions/runs/35353219531),
228
+ ChromeDriver 153 reported `Node with given id does not belong to the document`
229
+ while the update itself succeeded. A delayed-submit regression with matching
230
+ text in the outgoing page fails without the destination wait and passes with it.
231
+ The export test similarly waits for the export URL before checking the response.
232
+ With Chrome/ChromeDriver 153.0.8010.52 and mise Ruby 4.0.7, all 11 browser tests
233
+ (118 assertions) pass with seeds `42702`, `101`, `202`, `303`, and `20260918`.
234
+
235
+ CI includes a separate Ubuntu 24.04 browser job with a
236
+ [matching Chrome/driver setup](https://github.com/browser-actions/setup-chrome)
237
+ and system dependencies. A per-binary AppArmor rule permits the downloaded
238
+ Chrome to use its sandbox without disabling global user-namespace restrictions.
239
+ Startup is verified by the Selenium suite itself. The standalone `--dump-dom`
240
+ preflight hung with Chrome for Testing 153.0.8010.52, reproduced locally even
241
+ though all ten browser tests passed with that exact browser/driver pair.
242
+ The `browser-diagnostics` artifact retains verbose driver logs (including Chrome
243
+ startup diagnostics) and failure screenshots for three days. Browser startup now
244
+ passes in hosted CI; the navigation-synchronization repair still needs hosted
245
+ confirmation. Other browsers, full strict-style CSP,
246
+ mobile/accessibility review, arbitrary host layouts/authentication integrations,
247
+ Turbo Frames/Streams, and transactional stale-form protection remain unverified
248
+ or out of scope for this batch. No new database migration is required for batch
249
+ nine; the preceding administrative-history migration and actor configuration still
250
+ apply. Update scripted callers that previously relied on local-zone timestamps or
251
+ loosely parsed duration strings before deploying.