userpattern 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 87007c5d42186543e7d5b7805cf3709b79d101c88e6ec079225f05586816d3f7
4
+ data.tar.gz: 7be7e99f6bb7dcd67d6be5302aa86e76952d4e343a27b5d22b7cc4d53f225f80
5
+ SHA512:
6
+ metadata.gz: 3752d9b6f4ddc4d923aa211d354aed2edfce8ae9546e40015cf5c2c4a1387acc668efd3ea8f366c891ef3cfee1ec0bb65677f97e45d26484ecf08bc2c291a79b
7
+ data.tar.gz: 20a52bd89391aceca542d21ac253828e5b07523614c9c7ed1764218de6bd905123e84c660e8879df3ce4165c04039ad4f12d0ca77dd839e8c982868cacc874bc
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 UserPattern Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,518 @@
1
+ # UserPattern
2
+
3
+ Anonymized usage-pattern analysis for Rails applications.
4
+
5
+ UserPattern plugs into any Rails app as an engine. It intercepts requests from authenticated users, collects per-endpoint frequency statistics, and presents a sortable dashboard — all without ever storing a user identifier. In **alert mode**, it enforces rate limits derived from the observed data.
6
+
7
+ ## Features
8
+
9
+ - **Multi-model** — track `User`, `Admin`, or any authenticatable model.
10
+ - **Devise + JWT compatible** — auto-detects session cookies and `Authorization` headers.
11
+ - **Fully anonymized** — impossible to trace actions back to a specific user (daily-rotating HMAC salt).
12
+ - **Minimal performance impact** — in-memory buffer, async batch writes.
13
+ - **Built-in dashboard** — sortable HTML table, filterable by model type, with violations tab.
14
+ - **Automatic cleanup** — rake task to purge expired data.
15
+ - **Two modes** — collection (observe) and alert (enforce rate limits from observed data).
16
+ - **Secure by default** — dashboard requires authentication out of the box.
17
+
18
+ ## UserPattern vs Rack::Attack
19
+
20
+ Rack::Attack and UserPattern are **complementary**. Rack::Attack protects against known abuse patterns with manual rules. UserPattern learns what "normal authenticated usage" looks like and detects deviations automatically.
21
+
22
+ | Aspect | Rack::Attack | UserPattern |
23
+ |---|---|---|
24
+ | **Thresholds** | Static, manually configured | Dynamic, learned from observed usage data |
25
+ | **Target** | Any client (usually IP-based) | Authenticated users by model type (User, Admin) |
26
+ | **Awareness** | Rack-level, no access to `current_user` | Controller-level, resolves authenticated identity |
27
+ | **Analytics** | None (logging via ActiveSupport::Notifications) | Dashboard with per-endpoint, per-model-type stats |
28
+ | **Baseline** | Developer defines "normal" | System observes "normal" during collection |
29
+ | **URL handling** | Raw URLs | Auto-normalized (IDs, UUIDs, query params) |
30
+ | **Privacy** | N/A | Anonymized collection, no PII in DB |
31
+
32
+ **When to use Rack::Attack:** IP-level rate limiting, blocking known bad actors, unauthenticated abuse prevention, DDoS protection.
33
+
34
+ **When to use UserPattern:** Detecting authenticated users who deviate from normal behavior, understanding endpoint usage patterns, adaptive rate limiting without manual threshold tuning.
35
+
36
+ **Using both together:** Rack::Attack as the outer wall (IP-based, Rack middleware), UserPattern as the inner guard (identity-based, controller-level). UserPattern reuses the same `ActiveSupport::Cache::Store` interface as Rack::Attack for its rate limiter counters, so the two share a common cache infrastructure.
37
+
38
+ ## Installation
39
+
40
+ Add to your application's `Gemfile`:
41
+
42
+ ```ruby
43
+ gem "userpattern", path: "path/to/userpattern" # local development
44
+ # gem "userpattern", github: "your-org/userpattern" # via GitHub
45
+ ```
46
+
47
+ Run the install generator:
48
+
49
+ ```bash
50
+ bundle install
51
+ rails generate userpattern:install
52
+ rails db:migrate
53
+ ```
54
+
55
+ The generator creates:
56
+ 1. `config/initializers/userpattern.rb` — configuration file
57
+ 2. Migrations for `userpattern_request_events` and `userpattern_violations` tables
58
+ 3. A route mounting the dashboard at `/userpatterns`
59
+
60
+ ## Configuration
61
+
62
+ ```ruby
63
+ # config/initializers/userpattern.rb
64
+
65
+ UserPattern.configure do |config|
66
+ # Models to track. Each entry needs :name and optionally :current_method.
67
+ # If :current_method is omitted it defaults to :current_<underscored_name>.
68
+ config.tracked_models = [
69
+ { name: "User", current_method: :current_user },
70
+ { name: "Admin", current_method: :current_admin },
71
+ ]
72
+
73
+ # Session detection mode (see "Session detection" section below)
74
+ config.session_detection = :auto
75
+
76
+ # Buffer tuning
77
+ config.buffer_size = 100 # flush when buffer reaches this size
78
+ config.flush_interval = 30 # flush at least every N seconds
79
+
80
+ # Data retention (days). Old events are removed by `rake userpattern:cleanup`.
81
+ config.retention_period = 30
82
+
83
+ # Enable / disable tracking globally
84
+ config.enabled = true
85
+
86
+ # ─── Alert mode ──────────────────────────────────────────────────
87
+ config.mode = :collection # :collection or :alert
88
+ config.threshold_multiplier = 1.5 # limit = observed_max * multiplier
89
+ config.threshold_refresh_interval = 300 # reload limits from DB every N seconds
90
+ config.block_unknown_endpoints = false # allow endpoints not seen during collection
91
+
92
+ # Cache store for rate-limiter counters (defaults to Rails.cache)
93
+ # config.rate_limiter_store = ActiveSupport::Cache::RedisCacheStore.new(url: ENV["REDIS_URL"])
94
+
95
+ # Actions when a threshold is exceeded (:raise, :log, :record, :logout)
96
+ config.violation_actions = [:record, :log, :raise]
97
+
98
+ # Logout method (only used when :logout is in violation_actions)
99
+ # config.logout_method = ->(controller) { controller.sign_out(controller.current_user) }
100
+
101
+ # Optional callback for custom handling (Sentry, Slack, etc.)
102
+ # config.on_threshold_exceeded = ->(violation) {
103
+ # Sentry.capture_message("Rate limit: #{violation.message}")
104
+ # }
105
+ end
106
+ ```
107
+
108
+ ## Detecting the logged-in user
109
+
110
+ ### Default strategy: `current_user`
111
+
112
+ UserPattern hooks into controllers via an `after_action` callback. For each configured model it calls the specified method (defaults to `current_user`):
113
+
114
+ ```ruby
115
+ config.tracked_models = [
116
+ { name: "User" }, # calls current_user
117
+ { name: "Admin", current_method: :current_admin }, # calls current_admin
118
+ ]
119
+ ```
120
+
121
+ ### Devise + classic sessions
122
+
123
+ With Devise, `current_user` is available in every controller through the Warden helper. **No extra configuration needed.**
124
+
125
+ ### Devise + JWT (devise-jwt, devise-token-auth)
126
+
127
+ With `devise-jwt` or similar gems, Warden is configured to authenticate via the JWT token in the `Authorization` header. **`current_user` works out of the box for API requests too.**
128
+
129
+ The flow:
130
+ 1. Client sends `Authorization: Bearer <token>`
131
+ 2. Warden (via the JWT strategy) decodes the token and hydrates `current_user`
132
+ 3. UserPattern calls `current_user` in the `after_action` — the user is detected
133
+
134
+ ### Custom JWT (without Devise)
135
+
136
+ If you use a custom JWT system that does not populate `current_user`, either:
137
+
138
+ 1. Define a `current_user` method in your `ApplicationController` that decodes the JWT, or
139
+ 2. Point to your own method:
140
+
141
+ ```ruby
142
+ config.tracked_models = [
143
+ { name: "ApiClient", current_method: :current_api_client },
144
+ ]
145
+ ```
146
+
147
+ ### Multiple models
148
+
149
+ When a request matches several models (e.g. a user who is both `User` and `Admin` through Devise scopes), all matching models are tracked independently.
150
+
151
+ ## Anonymization
152
+
153
+ ### How it works
154
+
155
+ UserPattern **never stores a user identifier** (no id, email, or any PII). It derives an opaque session fingerprint:
156
+
157
+ ```
158
+ anonymous_session_id = HMAC-SHA256(
159
+ key: secret_key_base[0..31] + ":2026-04-08",
160
+ value: session_id | authorization_header
161
+ )[0..15]
162
+ ```
163
+
164
+ ### Security properties
165
+
166
+ | Property | Guarantee |
167
+ |---|---|
168
+ | **Irreversible** | HMAC is one-way — cannot recover the session ID or user |
169
+ | **Daily rotation** | Salt changes every day — cross-day correlation is impossible |
170
+ | **Truncation** | Only 16 hex chars kept (64 bits), further reducing entropy |
171
+ | **No user↔action link** | No user ID in the database. Even with full DB access you can only see aggregate stats |
172
+
173
+ ### URL and query string normalization
174
+
175
+ Endpoints are normalized **at collection time** so that URLs differing only by dynamic segments are aggregated into a single pattern. No raw URL ever reaches the database.
176
+
177
+ **Path segments** — numeric IDs, UUIDs, and long hex tokens are replaced with `:id`:
178
+
179
+ ```
180
+ /sinistres/2604921/member_ratio → /sinistres/:id/member_ratio
181
+ /sinistres/2605294/member_ratio → /sinistres/:id/member_ratio (same row)
182
+ /resources/84ef5373-0e95-4477-... → /resources/:id
183
+ /verify/a1b2c3d4e5f6a7b8c9d0 → /verify/:id
184
+ ```
185
+
186
+ **Query parameters** — values that look like IDs, UUIDs, or tokens are redacted with `:xxx`. Non-dynamic values (e.g. `status=active`) are preserved. Parameters are sorted so that different orderings map to the same endpoint:
187
+
188
+ ```
189
+ /admin?application_id=84ef5373-... → /admin?application_id=:xxx
190
+ /search?status=active → /search?status=active
191
+ /api?user_id=42&status=open&token=abc... → /api?status=open&token=:xxx&user_id=:xxx
192
+ ```
193
+
194
+ ### Session detection modes
195
+
196
+ The default `:auto` mode picks the best source automatically:
197
+ - **`Authorization` header present** → hash the header (JWT / API case)
198
+ - **Session cookie present** → hash the session ID (browser case)
199
+ - **Neither** → hash the remote IP (fallback)
200
+
201
+ You can force a mode or provide a custom Proc:
202
+
203
+ ```ruby
204
+ config.session_detection = :header # always use the Authorization header
205
+ config.session_detection = :session # always use the session cookie
206
+ config.session_detection = ->(request) { request.headers["X-Request-ID"] }
207
+ ```
208
+
209
+ ## Performance
210
+
211
+ UserPattern is designed to add negligible overhead to response times.
212
+
213
+ ### Buffer architecture (collection)
214
+
215
+ ```
216
+ HTTP request
217
+
218
+ after_action (< 0.1ms)
219
+ ↓ push
220
+ [Thread-safe in-memory buffer] ← Concurrent::Array
221
+ ↓ flush (async, every 30s or 100 events)
222
+ [Batch INSERT into DB] ← ActiveRecord insert_all
223
+ ```
224
+
225
+ - The `after_action` only pushes to a thread-safe array (~microseconds)
226
+ - Flushing happens in a separate thread — never blocks the request
227
+ - `insert_all` writes all buffered events in a single INSERT statement
228
+ - `buffer_size` and `flush_interval` are configurable
229
+
230
+ ### Alert mode overhead
231
+
232
+ | Operation | Cost |
233
+ |---|---|
234
+ | 3x cache `increment` (minute/hour/day) | ~0.1ms in-process, ~0.5ms with Redis |
235
+ | 1x `Hash` lookup in ThresholdCache (RAM) | ~0.1 microseconds |
236
+ | 3x integer comparisons | ~negligible |
237
+ | **Total per request (in-process cache)** | **< 0.5ms** |
238
+ | **Total per request (Redis)** | **< 2ms** |
239
+
240
+ ### Database indexes
241
+
242
+ Three indexes cover the dashboard queries:
243
+
244
+ - `(model_type, endpoint, recorded_at)` — time-bucketed aggregation
245
+ - `(model_type, endpoint, anonymous_session_id)` — distinct session counting
246
+ - `(recorded_at)` — expired event cleanup
247
+
248
+ ### Cleanup
249
+
250
+ To prevent the table from growing indefinitely:
251
+
252
+ ```bash
253
+ rails userpattern:cleanup
254
+ ```
255
+
256
+ Schedule as a daily cron job. Deletes events older than `retention_period` (30 days by default).
257
+
258
+ ## Dashboard
259
+
260
+ The dashboard is served at the engine mount path:
261
+
262
+ ```ruby
263
+ # config/routes.rb
264
+ mount UserPattern::Engine, at: "/userpatterns"
265
+ ```
266
+
267
+ ### Usage tab
268
+
269
+ Displays per model type:
270
+
271
+ | Column | Description |
272
+ |---|---|
273
+ | **Endpoint** | HTTP method + path (e.g. `GET /api/users`) |
274
+ | **Total Reqs** | Total recorded requests |
275
+ | **Sessions** | Distinct anonymized sessions |
276
+ | **Avg / Session** | Average requests per session |
277
+ | **Avg / Min** | Average frequency per minute |
278
+ | **Max / Min** | Peak frequency over any 1-minute window |
279
+ | **Max / Hour** | Peak frequency over any 1-hour window |
280
+ | **Max / Day** | Peak frequency over any 1-day window |
281
+
282
+ In alert mode, three additional columns show the computed limits (max × multiplier).
283
+
284
+ All columns are sortable (click the header).
285
+
286
+ ### Violations tab
287
+
288
+ When violations have been recorded (via `violation_actions: [:record, ...]`), the violations tab shows:
289
+
290
+ | Column | Description |
291
+ |---|---|
292
+ | **Endpoint** | The offending endpoint |
293
+ | **Model** | User, Admin, etc. |
294
+ | **Period** | minute, hour, or day |
295
+ | **Count** | Observed count that triggered the violation |
296
+ | **Limit** | The threshold that was exceeded |
297
+ | **User (hashed)** | Truncated HMAC hash (not the real user ID) |
298
+ | **Occurred At** | Timestamp |
299
+
300
+ ## Securing the dashboard
301
+
302
+ The dashboard is **secure by default**. If no custom authentication is configured, it uses HTTP Basic Auth from environment variables.
303
+
304
+ ### Default: environment variables
305
+
306
+ Set these two variables and the dashboard is protected:
307
+
308
+ ```bash
309
+ export USERPATTERN_DASHBOARD_USER=admin
310
+ export USERPATTERN_DASHBOARD_PASSWORD=your-secret-password
311
+ ```
312
+
313
+ If neither variable is set and no custom auth is configured, the dashboard returns **403 Forbidden** with setup instructions.
314
+
315
+ ### Custom authentication
316
+
317
+ Override the default with a Proc that runs in the controller context:
318
+
319
+ #### HTTP Basic Auth (custom credentials)
320
+
321
+ ```ruby
322
+ config.dashboard_auth = -> {
323
+ authenticate_or_request_with_http_basic("UserPattern") do |user, pass|
324
+ ActiveSupport::SecurityUtils.secure_compare(user, "admin") &
325
+ ActiveSupport::SecurityUtils.secure_compare(pass, ENV["USERPATTERN_PASSWORD"])
326
+ end
327
+ }
328
+ ```
329
+
330
+ #### Devise (admin-only)
331
+
332
+ ```ruby
333
+ config.dashboard_auth = -> {
334
+ redirect_to main_app.root_path, alert: "Access denied" unless current_user&.admin?
335
+ }
336
+ ```
337
+
338
+ #### Rails routing constraint
339
+
340
+ ```ruby
341
+ # config/routes.rb
342
+ authenticate :user, ->(u) { u.admin? } do
343
+ mount UserPattern::Engine, at: "/userpatterns"
344
+ end
345
+ ```
346
+
347
+ #### IP allowlist
348
+
349
+ ```ruby
350
+ config.dashboard_auth = -> {
351
+ head :forbidden unless request.remote_ip.in?(%w[127.0.0.1 ::1])
352
+ }
353
+ ```
354
+
355
+ ## Alert mode
356
+
357
+ Alert mode turns UserPattern from a passive observer into an active rate limiter. Thresholds are **not configured manually** — they are derived from the max frequencies observed during collection.
358
+
359
+ ### How it works
360
+
361
+ 1. **Collection phase** — run in `:collection` mode for days or weeks. UserPattern observes that `GET /api/users` has a max of 5/min, 30/hour, 100/day.
362
+ 2. **Switch to alert** — set `config.mode = :alert`. Those observed maximums (× `threshold_multiplier`) become the rate limits.
363
+ 3. **Enforcement** — a `before_action` checks every request against the limits. If a user exceeds them, the configured response actions are triggered.
364
+
365
+ ```
366
+ before_action (alert mode only)
367
+ ├─ Resolve current_user → user_id=42, model_type="User"
368
+ ├─ Normalize endpoint → "GET /api/sinistres/:id"
369
+ ├─ RateLimiter.check_and_increment!(42, "User", "GET /api/sinistres/:id")
370
+ │ ├─ Increment minute/hour/day counters via cache store
371
+ │ ├─ Fetch limits from ThresholdCache
372
+ │ ├─ Compare: count <= limit?
373
+ │ ├─ All OK → continue
374
+ │ └─ Any exceeded → trigger configured actions
375
+
376
+ after_action (always active — collection continues in alert mode)
377
+ └─ Buffer anonymized event as usual
378
+ ```
379
+
380
+ ### Enabling alert mode
381
+
382
+ ```ruby
383
+ UserPattern.configure do |config|
384
+ config.mode = :alert
385
+ config.threshold_multiplier = 1.5 # limit = observed_max * 1.5
386
+ config.threshold_refresh_interval = 300 # reload limits every 5 minutes
387
+ config.violation_actions = [:record, :log, :raise]
388
+ end
389
+ ```
390
+
391
+ ### Threshold calculation
392
+
393
+ Limits are computed as `ceil(observed_max * threshold_multiplier)`:
394
+
395
+ ```
396
+ Observed max_per_minute = 5, multiplier = 1.5 → limit = ceil(7.5) = 8
397
+ Observed max_per_hour = 30 → limit = ceil(45) = 45
398
+ Observed max_per_day = 100 → limit = ceil(150) = 150
399
+ ```
400
+
401
+ The ThresholdCache refreshes from the database every `threshold_refresh_interval` seconds (default: 300). As new data is collected, the thresholds evolve automatically.
402
+
403
+ ### Violation actions
404
+
405
+ Configure which actions to take when a threshold is exceeded:
406
+
407
+ | Action | Description |
408
+ |---|---|
409
+ | `:raise` | Raise `ThresholdExceeded`. Handle via `rescue_from` in your controller. |
410
+ | `:log` | Log the violation to `Rails.logger` at warn level. |
411
+ | `:record` | Persist to `userpattern_violations` table. Visible in the dashboard. |
412
+ | `:logout` | Call `config.logout_method` to terminate the session. |
413
+
414
+ Actions can be combined:
415
+
416
+ ```ruby
417
+ config.violation_actions = [:record, :log, :raise]
418
+ ```
419
+
420
+ Without `:raise`, the request **continues normally** (useful for shadow/monitoring mode).
421
+
422
+ ### ThresholdExceeded exception
423
+
424
+ When `:raise` is in `violation_actions`, a `UserPattern::ThresholdExceeded` error is raised. Handle it in your application controller:
425
+
426
+ ```ruby
427
+ class ApplicationController < ActionController::Base
428
+ rescue_from UserPattern::ThresholdExceeded do |e|
429
+ render json: {
430
+ error: "Too many requests",
431
+ endpoint: e.endpoint,
432
+ retry_after: 60
433
+ }, status: :too_many_requests
434
+ end
435
+ end
436
+ ```
437
+
438
+ The exception exposes: `endpoint`, `user_id`, `model_type`, `period`, `count`, `limit`.
439
+
440
+ ### Violation recording
441
+
442
+ When `:record` is in `violation_actions`, violations are persisted with an **anonymized user identifier** (HMAC hash, same approach as session anonymization). The raw user ID is never stored in the database.
443
+
444
+ ### Cache store
445
+
446
+ Rate limiter counters use `ActiveSupport::Cache::Store` — the same interface as Rack::Attack. This gives multi-process support via Redis or Memcached:
447
+
448
+ ```ruby
449
+ # Defaults to Rails.cache. For multi-process setups:
450
+ config.rate_limiter_store = ActiveSupport::Cache::RedisCacheStore.new(
451
+ url: ENV["REDIS_URL"]
452
+ )
453
+ ```
454
+
455
+ Counters expire automatically (`2.minutes`, `2.hours`, `2.days`) — no cleanup needed.
456
+
457
+ ### Edge cases
458
+
459
+ **Unknown endpoints** — if `POST /api/new_feature` was never seen during collection, the ThresholdCache has no entry. With `block_unknown_endpoints: false` (default), it passes through. With `true`, it is blocked.
460
+
461
+ **Empty collection data** — switching to alert mode with no collected data means all endpoints are unknown. With default settings, everything passes through.
462
+
463
+ **Multiplier of 1.0** — enforces the exact observed maximum. Use > 1.0 for tolerance.
464
+
465
+ ### Privacy in alert mode
466
+
467
+ Alert mode introduces two new locations where user-related data appears. Neither breaks the anonymization guarantee of the collection layer.
468
+
469
+ | Location | What is stored | Lifetime | Contains raw user ID? |
470
+ |---|---|---|---|
471
+ | `userpattern_request_events` (DB) | `anonymous_session_id` — HMAC hash of session/JWT | Retained until cleanup | No |
472
+ | `userpattern_violations` (DB) | `user_identifier` — HMAC hash of `"ModelType:user.id"` | Permanent | No |
473
+ | Cache store (Redis / memory) | Counter keys containing `user.id` | Expires automatically (2 min – 2 days) | Yes, but ephemeral |
474
+ | `ThresholdExceeded` exception | `user_id` attribute in the exception object | Request lifetime | Yes, in-memory only |
475
+ | `Rails.logger` | `user.id` in the log message (if `:log` action is enabled) | Depends on log retention | Yes |
476
+
477
+ **The database never contains a raw user ID.** Violations use a one-way HMAC hash (`user_identifier`), different from the `anonymous_session_id` used for collection, so the two cannot be correlated. Raw user IDs only exist in ephemeral contexts (cache keys, exceptions, logs) whose retention is controlled by the host application.
478
+
479
+
480
+ ## Gem structure
481
+
482
+ ```
483
+ userpattern/
484
+ ├── app/
485
+ │ ├── controllers/user_pattern/dashboard_controller.rb
486
+ │ ├── models/user_pattern/
487
+ │ │ ├── request_event.rb
488
+ │ │ └── violation.rb
489
+ │ └── views/user_pattern/dashboard/
490
+ │ ├── index.html.erb
491
+ │ └── violations.html.erb
492
+ ├── config/routes.rb
493
+ ├── lib/
494
+ │ ├── userpattern.rb
495
+ │ ├── userpattern/
496
+ │ │ ├── anonymizer.rb # HMAC anonymization
497
+ │ │ ├── buffer.rb # Thread-safe in-memory buffer
498
+ │ │ ├── configuration.rb # Configuration DSL
499
+ │ │ ├── controller_tracking.rb # before_action + after_action concern
500
+ │ │ ├── engine.rb # Rails Engine
501
+ │ │ ├── path_normalizer.rb # URL normalization
502
+ │ │ ├── rate_limiter.rb # Cache-backed rate limiting
503
+ │ │ ├── stats_calculator.rb # SQL-agnostic stats computation
504
+ │ │ ├── threshold_cache.rb # Periodic limit loader
505
+ │ │ ├── threshold_exceeded.rb # Custom exception
506
+ │ │ ├── violation_recorder.rb # Anonymized violation persistence
507
+ │ │ └── version.rb
508
+ │ ├── generators/userpattern/
509
+ │ │ ├── install_generator.rb
510
+ │ │ └── templates/
511
+ │ └── tasks/userpattern.rake
512
+ ├── userpattern.gemspec
513
+ └── README.md
514
+ ```
515
+
516
+ ## License
517
+
518
+ MIT