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 +7 -0
- data/LICENSE +21 -0
- data/README.md +518 -0
- data/app/assets/stylesheets/user_pattern/dashboard.css +169 -0
- data/app/controllers/user_pattern/dashboard_controller.rb +58 -0
- data/app/models/user_pattern/request_event.rb +11 -0
- data/app/models/user_pattern/violation.rb +9 -0
- data/app/views/user_pattern/dashboard/index.html.erb +116 -0
- data/app/views/user_pattern/dashboard/violations.html.erb +79 -0
- data/config/routes.rb +7 -0
- data/lib/generators/userpattern/install_generator.rb +46 -0
- data/lib/generators/userpattern/templates/create_userpattern_request_events.rb.erb +23 -0
- data/lib/generators/userpattern/templates/create_userpattern_violations.rb.erb +22 -0
- data/lib/generators/userpattern/templates/initializer.rb +83 -0
- data/lib/tasks/userpattern.rake +9 -0
- data/lib/userpattern/anonymizer.rb +53 -0
- data/lib/userpattern/buffer.rb +70 -0
- data/lib/userpattern/configuration.rb +93 -0
- data/lib/userpattern/controller_tracking.rb +102 -0
- data/lib/userpattern/engine.rb +40 -0
- data/lib/userpattern/path_normalizer.rb +60 -0
- data/lib/userpattern/rate_limiter.rb +68 -0
- data/lib/userpattern/request_event_cleanup.rb +10 -0
- data/lib/userpattern/stats_calculator.rb +102 -0
- data/lib/userpattern/threshold_cache.rb +73 -0
- data/lib/userpattern/threshold_exceeded.rb +25 -0
- data/lib/userpattern/version.rb +5 -0
- data/lib/userpattern/violation_recorder.rb +28 -0
- data/lib/userpattern.rb +66 -0
- metadata +97 -0
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
|