otto 2.0.0.pre8 → 2.0.0.pre9
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 +4 -4
- data/.github/workflows/ci.yml +1 -1
- data/.github/workflows/claude-code-review.yml +1 -1
- data/.github/workflows/claude.yml +1 -1
- data/.github/workflows/code-smells.yml +2 -2
- data/CHANGELOG.rst +54 -35
- data/Gemfile.lock +6 -6
- data/README.md +20 -0
- data/docs/.gitignore +2 -0
- data/docs/modern-authentication-authorization-landscape.md +558 -0
- data/docs/multi-strategy-authentication-design.md +1401 -0
- data/lib/otto/core/error_handler.rb +19 -8
- data/lib/otto/core/freezable.rb +0 -2
- data/lib/otto/core/middleware_stack.rb +12 -8
- data/lib/otto/core/router.rb +25 -31
- data/lib/otto/errors.rb +92 -0
- data/lib/otto/mcp/rate_limiting.rb +6 -2
- data/lib/otto/mcp/schema_validation.rb +1 -1
- data/lib/otto/response_handlers/json.rb +1 -3
- data/lib/otto/response_handlers/view.rb +1 -1
- data/lib/otto/route_handlers/base.rb +86 -1
- data/lib/otto/route_handlers/class_method.rb +9 -67
- data/lib/otto/route_handlers/instance_method.rb +10 -57
- data/lib/otto/route_handlers/logic_class.rb +85 -90
- data/lib/otto/security/authentication/auth_strategy.rb +2 -2
- data/lib/otto/security/authentication/strategy_result.rb +9 -9
- data/lib/otto/security/authorization_error.rb +1 -1
- data/lib/otto/security/config.rb +3 -3
- data/lib/otto/security/rate_limiter.rb +7 -3
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +47 -3
- metadata +4 -1
|
@@ -0,0 +1,1401 @@
|
|
|
1
|
+
# Multi-Strategy Authentication Design for Otto
|
|
2
|
+
|
|
3
|
+
**Date:** November 2025
|
|
4
|
+
**Status:** Design Document
|
|
5
|
+
**Related:** [Modern Authentication/Authorization Landscape](modern-authentication-authorization-landscape.md)
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Table of Contents
|
|
10
|
+
|
|
11
|
+
1. [Executive Summary](#executive-summary)
|
|
12
|
+
2. [Implementation Patterns from Other Frameworks](#implementation-patterns-from-other-frameworks)
|
|
13
|
+
3. [Otto-Specific Design Decisions](#otto-specific-design-decisions)
|
|
14
|
+
4. [Auditing and Observability](#auditing-and-observability)
|
|
15
|
+
5. [Authorization Design](#authorization-design)
|
|
16
|
+
6. [Implementation Roadmap](#implementation-roadmap)
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Executive Summary
|
|
21
|
+
|
|
22
|
+
This document defines Otto's approach to supporting multiple authentication strategies per route, building on industry-standard patterns from Warden, Django REST Framework, and Passport.js.
|
|
23
|
+
|
|
24
|
+
**Key Decisions:**
|
|
25
|
+
- ✅ Support comma-separated strategies: `auth=session,apikey,oauth`
|
|
26
|
+
- ✅ OR logic: First success wins, fail only if all fail
|
|
27
|
+
- ✅ Explicit auditing via structured logging (no hooks system needed)
|
|
28
|
+
- ✅ Two-layer authorization: Route-level (authentication) + Resource-level (Logic classes)
|
|
29
|
+
- ✅ Maintain Otto's philosophy: Simple, explicit, secure by default
|
|
30
|
+
|
|
31
|
+
**Estimated Implementation:** 6-8 hours total
|
|
32
|
+
- Core multi-strategy support: 3-4 hours
|
|
33
|
+
- Auditing enhancements: 1-2 hours
|
|
34
|
+
- Authorization documentation: 2-3 hours
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Implementation Patterns from Other Frameworks
|
|
39
|
+
|
|
40
|
+
### Analysis of Framework Patterns
|
|
41
|
+
|
|
42
|
+
Based on research of Warden (Ruby/Rack), Django REST Framework (Python), and Passport.js (Node.js), here's what fits Otto's design philosophy:
|
|
43
|
+
|
|
44
|
+
#### 1. Strategy Validation Pattern (from Warden)
|
|
45
|
+
|
|
46
|
+
**Warden's Approach:**
|
|
47
|
+
```ruby
|
|
48
|
+
# Warden checks if strategy is "valid" before attempting authentication
|
|
49
|
+
class Strategy
|
|
50
|
+
def valid?
|
|
51
|
+
# Check if this strategy should be attempted for this request
|
|
52
|
+
# E.g., check for Authorization header presence
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def authenticate!
|
|
56
|
+
# Only called if valid? returns true
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**Otto Fit:** ⚠️ **Partial** - Adds complexity
|
|
62
|
+
- **Pro:** Avoids unnecessary authentication attempts
|
|
63
|
+
- **Con:** Extra method to implement in every strategy
|
|
64
|
+
- **Decision:** Skip for v1, consider for v2 if performance issues arise
|
|
65
|
+
|
|
66
|
+
**Rationale:** Otto's strategies are already lightweight. Skipping invalid strategies adds minimal overhead compared to the complexity of implementing `valid?` checks.
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
#### 2. Strategy Ordering Pattern (from Django REST Framework)
|
|
71
|
+
|
|
72
|
+
**Django's Approach:**
|
|
73
|
+
```python
|
|
74
|
+
# authentication_classes tried in order, first success wins
|
|
75
|
+
authentication_classes = [SessionAuthentication, TokenAuthentication, BasicAuthentication]
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
**Otto Fit:** ✅ **EXCELLENT** - Direct match
|
|
79
|
+
- Aligns with Otto's route-based configuration
|
|
80
|
+
- Left-to-right order is intuitive
|
|
81
|
+
- No additional API needed
|
|
82
|
+
|
|
83
|
+
**Implementation:**
|
|
84
|
+
```ruby
|
|
85
|
+
# lib/otto/route_definition.rb
|
|
86
|
+
def auth_requirements
|
|
87
|
+
auth = option(:auth)
|
|
88
|
+
return [] unless auth
|
|
89
|
+
|
|
90
|
+
auth.split(',').map(&:strip) # "session,apikey" → ['session', 'apikey']
|
|
91
|
+
end
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
#### 3. Named Strategy Instances (from Passport.js)
|
|
97
|
+
|
|
98
|
+
**Passport's Approach:**
|
|
99
|
+
```javascript
|
|
100
|
+
// Create multiple instances of same strategy type with different configs
|
|
101
|
+
passport.use('user-local', new LocalStrategy(User.authenticate()));
|
|
102
|
+
passport.use('admin-local', new LocalStrategy(Admin.authenticate()));
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
**Otto Fit:** ✅ **ALREADY SUPPORTED** - No changes needed
|
|
106
|
+
- Otto already supports this via `add_auth_strategy(name, strategy)`
|
|
107
|
+
- Each strategy instance can have different configuration
|
|
108
|
+
|
|
109
|
+
**Example:**
|
|
110
|
+
```ruby
|
|
111
|
+
# Different session strategies for different user types
|
|
112
|
+
otto.add_auth_strategy('user_session', SessionStrategy.new(session_key: 'user_id'))
|
|
113
|
+
otto.add_auth_strategy('admin_session', SessionStrategy.new(session_key: 'admin_id'))
|
|
114
|
+
|
|
115
|
+
# Routes can choose which to use
|
|
116
|
+
GET /user/dashboard auth=user_session
|
|
117
|
+
GET /admin/dashboard auth=admin_session
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
#### 4. Session Control Pattern (from Passport.js)
|
|
123
|
+
|
|
124
|
+
**Passport's Approach:**
|
|
125
|
+
```javascript
|
|
126
|
+
// Control whether strategy creates a session
|
|
127
|
+
passport.authenticate('bearer', { session: false })
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
**Otto Fit:** ⚠️ **Not Applicable**
|
|
131
|
+
- Otto strategies return `StrategyResult` which contains session data
|
|
132
|
+
- Session management is handled by Rack session middleware
|
|
133
|
+
- Strategy doesn't control session creation
|
|
134
|
+
|
|
135
|
+
**Decision:** No changes needed - Otto's approach is architecturally superior (separation of concerns).
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
#### 5. Strategy Array Pattern (from Passport.js)
|
|
140
|
+
|
|
141
|
+
**Passport's Approach:**
|
|
142
|
+
```javascript
|
|
143
|
+
// Pass array of strategy names
|
|
144
|
+
app.post('/login', passport.authenticate(['local', 'bearer', 'oauth2']))
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
**Otto Fit:** ✅ **EXCELLENT** - Maps to comma-separated syntax
|
|
148
|
+
- Otto uses route file syntax, not code
|
|
149
|
+
- Comma separation is equivalent to array
|
|
150
|
+
|
|
151
|
+
**Otto Equivalent:**
|
|
152
|
+
```
|
|
153
|
+
POST /login LoginController#create auth=local,bearer,oauth2
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
### Patterns NOT Adopted
|
|
159
|
+
|
|
160
|
+
#### 1. Content Negotiation (Auto-Selection)
|
|
161
|
+
|
|
162
|
+
Some frameworks automatically reorder strategies based on request headers:
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
# If request has Authorization header, try token auth first
|
|
166
|
+
# If request has session cookie, try session auth first
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
**Decision:** ❌ **Skip** - Too magical
|
|
170
|
+
- Violates Otto's "explicit over implicit" philosophy
|
|
171
|
+
- Makes debugging harder
|
|
172
|
+
- Developer should specify order in routes file
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
#### 2. Middleware-Based Authentication
|
|
177
|
+
|
|
178
|
+
**Passport/Express Pattern:**
|
|
179
|
+
```javascript
|
|
180
|
+
app.use(passport.initialize());
|
|
181
|
+
app.use(passport.session());
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
**Decision:** ✅ **Already Avoided** - Otto's architecture is superior
|
|
185
|
+
- Otto uses `RouteAuthWrapper` at correct layer (after routing, before handler)
|
|
186
|
+
- Middleware runs before routing (can't see route requirements)
|
|
187
|
+
- Otto's approach is what Rails community learned after years of trial
|
|
188
|
+
|
|
189
|
+
---
|
|
190
|
+
|
|
191
|
+
## Otto-Specific Design Decisions
|
|
192
|
+
|
|
193
|
+
### Core Implementation
|
|
194
|
+
|
|
195
|
+
#### Route Definition Parsing
|
|
196
|
+
|
|
197
|
+
**File:** `lib/otto/route_definition.rb`
|
|
198
|
+
|
|
199
|
+
```ruby
|
|
200
|
+
# Add new method for multiple auth requirements
|
|
201
|
+
def auth_requirements
|
|
202
|
+
auth = option(:auth)
|
|
203
|
+
return [] unless auth
|
|
204
|
+
|
|
205
|
+
# Split on comma and strip whitespace
|
|
206
|
+
# "session, apikey" → ['session', 'apikey']
|
|
207
|
+
auth.split(',').map(&:strip)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Keep backward compatibility - returns first requirement or nil
|
|
211
|
+
def auth_requirement
|
|
212
|
+
reqs = auth_requirements
|
|
213
|
+
reqs.empty? ? nil : reqs.first
|
|
214
|
+
end
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
**Rationale:**
|
|
218
|
+
- Backward compatible (existing code using `auth_requirement` still works)
|
|
219
|
+
- Simple parsing (no regex, no complex grammar)
|
|
220
|
+
- Clear intent (comma = OR logic)
|
|
221
|
+
|
|
222
|
+
---
|
|
223
|
+
|
|
224
|
+
#### Authentication Execution Flow
|
|
225
|
+
|
|
226
|
+
**File:** `lib/otto/security/authentication/route_auth_wrapper.rb`
|
|
227
|
+
|
|
228
|
+
```ruby
|
|
229
|
+
def call(env, extra_params = {})
|
|
230
|
+
auth_requirements = route_definition.auth_requirements
|
|
231
|
+
|
|
232
|
+
# No auth requirement → anonymous access
|
|
233
|
+
if auth_requirements.empty?
|
|
234
|
+
result = StrategyResult.anonymous(metadata: { ip: env['REMOTE_ADDR'] })
|
|
235
|
+
env['otto.strategy_result'] = result
|
|
236
|
+
return wrapped_handler.call(env, extra_params)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Try each strategy in order (OR logic)
|
|
240
|
+
tried_strategies = []
|
|
241
|
+
|
|
242
|
+
auth_requirements.each do |requirement|
|
|
243
|
+
strategy, strategy_name = get_strategy(requirement)
|
|
244
|
+
|
|
245
|
+
# Skip if strategy not found (log warning but continue)
|
|
246
|
+
unless strategy
|
|
247
|
+
Otto.logger.warn "[RouteAuthWrapper] Strategy not found: #{requirement}"
|
|
248
|
+
next
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
tried_strategies << strategy_name
|
|
252
|
+
|
|
253
|
+
# Execute strategy
|
|
254
|
+
start_time = Otto::Utils.now_in_μs
|
|
255
|
+
result = strategy.authenticate(env, requirement)
|
|
256
|
+
duration = Otto::Utils.now_in_μs - start_time
|
|
257
|
+
|
|
258
|
+
# Inject strategy name into result
|
|
259
|
+
result = result.with(strategy_name: strategy_name) if result.is_a?(StrategyResult)
|
|
260
|
+
|
|
261
|
+
# SUCCESS: First success wins
|
|
262
|
+
if result.is_a?(StrategyResult) && result.authenticated?
|
|
263
|
+
Otto.structured_log(:info, "Authentication succeeded",
|
|
264
|
+
Otto::LoggingHelpers.request_context(env).merge(
|
|
265
|
+
strategy: strategy_name,
|
|
266
|
+
strategies_tried: tried_strategies,
|
|
267
|
+
succeeded_with: strategy_name,
|
|
268
|
+
duration: duration
|
|
269
|
+
)
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
env['otto.strategy_result'] = result
|
|
273
|
+
env['otto.user'] = result.user_context
|
|
274
|
+
env['rack.session'] = result.session if result.session
|
|
275
|
+
|
|
276
|
+
return wrapped_handler.call(env, extra_params)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# FAILURE: Log and continue to next strategy
|
|
280
|
+
Otto.structured_log(:debug, "Authentication failed",
|
|
281
|
+
Otto::LoggingHelpers.request_context(env).merge(
|
|
282
|
+
strategy: strategy_name,
|
|
283
|
+
failure_reason: result.is_a?(AuthFailure) ? result.failure_reason : 'Unknown',
|
|
284
|
+
duration: duration
|
|
285
|
+
)
|
|
286
|
+
)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# ALL STRATEGIES FAILED
|
|
290
|
+
Otto.structured_log(:warn, "All authentication strategies failed",
|
|
291
|
+
Otto::LoggingHelpers.request_context(env).merge(
|
|
292
|
+
strategies_tried: tried_strategies,
|
|
293
|
+
requirement: auth_requirements.join(',')
|
|
294
|
+
)
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
unauthorized_response(env, "Authentication required")
|
|
298
|
+
end
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
**Key Design Points:**
|
|
302
|
+
|
|
303
|
+
1. **Graceful Degradation:** If a strategy isn't registered, log warning and try next
|
|
304
|
+
2. **First Success Wins:** Stop on first successful authentication
|
|
305
|
+
3. **Comprehensive Logging:** Log each attempt with timing
|
|
306
|
+
4. **Fail Securely:** Return 401 only if ALL strategies fail
|
|
307
|
+
|
|
308
|
+
---
|
|
309
|
+
|
|
310
|
+
#### Error Handling
|
|
311
|
+
|
|
312
|
+
**Missing Strategy Behavior:**
|
|
313
|
+
|
|
314
|
+
```ruby
|
|
315
|
+
# Route: auth=session,unknown,apikey
|
|
316
|
+
# Behavior:
|
|
317
|
+
# 1. Try 'session' → fails (not authenticated)
|
|
318
|
+
# 2. Try 'unknown' → warn and skip (strategy not registered)
|
|
319
|
+
# 3. Try 'apikey' → succeeds
|
|
320
|
+
# Result: 200 OK (authenticated via apikey)
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
**All Strategies Fail:**
|
|
324
|
+
|
|
325
|
+
```ruby
|
|
326
|
+
# Route: auth=session,apikey
|
|
327
|
+
# Behavior:
|
|
328
|
+
# 1. Try 'session' → fails
|
|
329
|
+
# 2. Try 'apikey' → fails
|
|
330
|
+
# Result: 401 Unauthorized
|
|
331
|
+
# Log: strategies_tried: ['session', 'apikey']
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
---
|
|
335
|
+
|
|
336
|
+
### Testing Strategy
|
|
337
|
+
|
|
338
|
+
**File:** `spec/otto/security/route_auth_wrapper_spec.rb`
|
|
339
|
+
|
|
340
|
+
```ruby
|
|
341
|
+
describe 'multiple authentication strategies' do
|
|
342
|
+
let(:session_strategy) { double('SessionStrategy') }
|
|
343
|
+
let(:apikey_strategy) { double('APIKeyStrategy') }
|
|
344
|
+
|
|
345
|
+
before do
|
|
346
|
+
otto.add_auth_strategy('session', session_strategy)
|
|
347
|
+
otto.add_auth_strategy('apikey', apikey_strategy)
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
describe 'OR logic (first success wins)' do
|
|
351
|
+
it 'succeeds if first strategy succeeds' do
|
|
352
|
+
allow(session_strategy).to receive(:authenticate)
|
|
353
|
+
.and_return(StrategyResult.new(user: user, session: {}, auth_method: 'session'))
|
|
354
|
+
|
|
355
|
+
# Define route: auth=session,apikey
|
|
356
|
+
get '/protected', {}, { 'HTTP_COOKIE' => 'session_id=abc123' }
|
|
357
|
+
|
|
358
|
+
expect(last_response.status).to eq(200)
|
|
359
|
+
expect(apikey_strategy).not_to have_received(:authenticate) # Not called!
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
it 'tries second strategy if first fails' do
|
|
363
|
+
allow(session_strategy).to receive(:authenticate)
|
|
364
|
+
.and_return(AuthFailure.new(failure_reason: 'No session'))
|
|
365
|
+
allow(apikey_strategy).to receive(:authenticate)
|
|
366
|
+
.and_return(StrategyResult.new(user: user, session: {}, auth_method: 'apikey'))
|
|
367
|
+
|
|
368
|
+
get '/protected', {}, { 'HTTP_AUTHORIZATION' => 'Bearer token123' }
|
|
369
|
+
|
|
370
|
+
expect(last_response.status).to eq(200)
|
|
371
|
+
expect(session_strategy).to have_received(:authenticate)
|
|
372
|
+
expect(apikey_strategy).to have_received(:authenticate)
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
it 'fails if all strategies fail' do
|
|
376
|
+
allow(session_strategy).to receive(:authenticate)
|
|
377
|
+
.and_return(AuthFailure.new(failure_reason: 'No session'))
|
|
378
|
+
allow(apikey_strategy).to receive(:authenticate)
|
|
379
|
+
.and_return(AuthFailure.new(failure_reason: 'Invalid API key'))
|
|
380
|
+
|
|
381
|
+
get '/protected'
|
|
382
|
+
|
|
383
|
+
expect(last_response.status).to eq(401)
|
|
384
|
+
expect(logs).to include(match(/All authentication strategies failed/))
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
describe 'strategy order' do
|
|
389
|
+
it 'tries strategies left-to-right' do
|
|
390
|
+
execution_order = []
|
|
391
|
+
|
|
392
|
+
allow(session_strategy).to receive(:authenticate) do
|
|
393
|
+
execution_order << 'session'
|
|
394
|
+
AuthFailure.new(failure_reason: 'No session')
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
allow(apikey_strategy).to receive(:authenticate) do
|
|
398
|
+
execution_order << 'apikey'
|
|
399
|
+
StrategyResult.new(user: user, session: {}, auth_method: 'apikey')
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# Route: auth=session,apikey
|
|
403
|
+
get '/protected'
|
|
404
|
+
|
|
405
|
+
expect(execution_order).to eq(['session', 'apikey'])
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
describe 'missing strategies' do
|
|
410
|
+
it 'skips missing strategies and continues' do
|
|
411
|
+
allow(session_strategy).to receive(:authenticate)
|
|
412
|
+
.and_return(AuthFailure.new(failure_reason: 'No session'))
|
|
413
|
+
|
|
414
|
+
# Route: auth=session,unknown,apikey (unknown doesn't exist)
|
|
415
|
+
get '/protected'
|
|
416
|
+
|
|
417
|
+
# Should warn about 'unknown' but still try 'apikey'
|
|
418
|
+
expect(logs).to include(match(/Strategy not found: unknown/))
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
it 'fails if all valid strategies fail' do
|
|
422
|
+
# Route: auth=unknown1,unknown2 (neither exists)
|
|
423
|
+
get '/protected'
|
|
424
|
+
|
|
425
|
+
expect(last_response.status).to eq(401)
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
describe 'performance' do
|
|
430
|
+
it 'stops on first success (does not try remaining strategies)' do
|
|
431
|
+
expensive_strategy = double('ExpensiveStrategy')
|
|
432
|
+
|
|
433
|
+
allow(session_strategy).to receive(:authenticate)
|
|
434
|
+
.and_return(StrategyResult.new(user: user, session: {}, auth_method: 'session'))
|
|
435
|
+
|
|
436
|
+
# Route: auth=session,expensive
|
|
437
|
+
get '/protected'
|
|
438
|
+
|
|
439
|
+
expect(expensive_strategy).not_to have_received(:authenticate)
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
---
|
|
446
|
+
|
|
447
|
+
## Auditing and Observability
|
|
448
|
+
|
|
449
|
+
### Current State Analysis
|
|
450
|
+
|
|
451
|
+
Otto already has excellent audit logging foundation:
|
|
452
|
+
|
|
453
|
+
1. **Structured Logging:** `Otto.structured_log` with consistent format
|
|
454
|
+
2. **Request Context:** `LoggingHelpers.request_context(env)` extracts core fields
|
|
455
|
+
3. **Timing Data:** Microsecond-precision timing via `Otto::Utils.now_in_μs`
|
|
456
|
+
4. **Privacy-Aware:** IPs already masked by `IPPrivacyMiddleware`
|
|
457
|
+
|
|
458
|
+
**What Otto Has (Already):**
|
|
459
|
+
```ruby
|
|
460
|
+
# Every authentication attempt is logged
|
|
461
|
+
Otto.structured_log(:info, "Auth strategy result",
|
|
462
|
+
Otto::LoggingHelpers.request_context(env).merge(
|
|
463
|
+
strategy: 'session',
|
|
464
|
+
success: true,
|
|
465
|
+
user_id: result.user_id,
|
|
466
|
+
duration: 1234 # microseconds
|
|
467
|
+
)
|
|
468
|
+
)
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
---
|
|
472
|
+
|
|
473
|
+
### Comparison with Rodauth's Audit Logging
|
|
474
|
+
|
|
475
|
+
**Rodauth's Approach:**
|
|
476
|
+
```ruby
|
|
477
|
+
# Rodauth uses hooks system
|
|
478
|
+
after_login do
|
|
479
|
+
audit_log_action('login', user_id: account_id)
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
after_logout do
|
|
483
|
+
audit_log_action('logout', user_id: account_id)
|
|
484
|
+
end
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
**Otto's Approach (Current):**
|
|
488
|
+
```ruby
|
|
489
|
+
# Otto uses structured logging directly in RouteAuthWrapper
|
|
490
|
+
Otto.structured_log(:info, "Authentication succeeded", {
|
|
491
|
+
method: 'POST',
|
|
492
|
+
path: '/login',
|
|
493
|
+
strategy: 'session',
|
|
494
|
+
user_id: result.user_id
|
|
495
|
+
})
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
**Comparison:**
|
|
499
|
+
|
|
500
|
+
| Aspect | Rodauth (Hooks) | Otto (Structured Logs) |
|
|
501
|
+
|--------|-----------------|------------------------|
|
|
502
|
+
| **Complexity** | Medium (hooks system) | Low (direct logging) |
|
|
503
|
+
| **Flexibility** | High (custom hooks) | Medium (log collectors) |
|
|
504
|
+
| **Performance** | Fast (in-process) | Fast (in-process) |
|
|
505
|
+
| **Separation** | Clear (hooks separate) | Excellent (logging concern) |
|
|
506
|
+
| **Testability** | Hard (hooks fire in tests) | Easy (mock logger) |
|
|
507
|
+
| **Storage** | Database table | Log aggregation system |
|
|
508
|
+
|
|
509
|
+
**Decision:** ✅ **Continue with structured logging** - No hooks system needed
|
|
510
|
+
|
|
511
|
+
**Rationale:**
|
|
512
|
+
- Otto's structured logging is simpler and more flexible
|
|
513
|
+
- Modern log aggregation (Datadog, Elasticsearch, Loki) handles storage
|
|
514
|
+
- Hooks add complexity without proportional benefit
|
|
515
|
+
- Structured logs are easier to test (mock logger vs mock hooks)
|
|
516
|
+
|
|
517
|
+
---
|
|
518
|
+
|
|
519
|
+
### Enhanced Auditing for Multi-Strategy Authentication
|
|
520
|
+
|
|
521
|
+
#### What to Log
|
|
522
|
+
|
|
523
|
+
**Authentication Attempt (All Strategies):**
|
|
524
|
+
```ruby
|
|
525
|
+
{
|
|
526
|
+
event: "authentication_attempt",
|
|
527
|
+
method: "POST",
|
|
528
|
+
path: "/api/data",
|
|
529
|
+
ip: "192.0.2.0", # Already masked by IPPrivacyMiddleware
|
|
530
|
+
country: "US",
|
|
531
|
+
strategies_configured: ["session", "apikey", "oauth"],
|
|
532
|
+
timestamp: "2025-11-08T23:00:00Z"
|
|
533
|
+
}
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
**Strategy Execution (Each Strategy):**
|
|
537
|
+
```ruby
|
|
538
|
+
{
|
|
539
|
+
event: "strategy_executed",
|
|
540
|
+
strategy: "session",
|
|
541
|
+
success: false,
|
|
542
|
+
failure_reason: "No session cookie",
|
|
543
|
+
duration: 120, # microseconds
|
|
544
|
+
ip: "192.0.2.0",
|
|
545
|
+
timestamp: "2025-11-08T23:00:00.000120Z"
|
|
546
|
+
}
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
**Authentication Success:**
|
|
550
|
+
```ruby
|
|
551
|
+
{
|
|
552
|
+
event: "authentication_succeeded",
|
|
553
|
+
strategy: "apikey",
|
|
554
|
+
strategies_tried: ["session", "apikey"],
|
|
555
|
+
user_id: "user_12345",
|
|
556
|
+
duration_total: 1234, # Total time across all strategies
|
|
557
|
+
ip: "192.0.2.0",
|
|
558
|
+
country: "US",
|
|
559
|
+
timestamp: "2025-11-08T23:00:00.001234Z"
|
|
560
|
+
}
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
**Authentication Failure (All Failed):**
|
|
564
|
+
```ruby
|
|
565
|
+
{
|
|
566
|
+
event: "authentication_failed",
|
|
567
|
+
strategies_tried: ["session", "apikey", "oauth"],
|
|
568
|
+
failure_reasons: {
|
|
569
|
+
session: "No session cookie",
|
|
570
|
+
apikey: "Invalid API key",
|
|
571
|
+
oauth: "Token expired"
|
|
572
|
+
},
|
|
573
|
+
duration_total: 2345,
|
|
574
|
+
ip: "192.0.2.0",
|
|
575
|
+
country: "US",
|
|
576
|
+
timestamp: "2025-11-08T23:00:00.002345Z"
|
|
577
|
+
}
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
---
|
|
581
|
+
|
|
582
|
+
#### Implementation: Audit Log Aggregation
|
|
583
|
+
|
|
584
|
+
**File:** `lib/otto/security/authentication/audit_logger.rb` (NEW)
|
|
585
|
+
|
|
586
|
+
```ruby
|
|
587
|
+
# lib/otto/security/authentication/audit_logger.rb
|
|
588
|
+
#
|
|
589
|
+
# frozen_string_literal: true
|
|
590
|
+
|
|
591
|
+
class Otto
|
|
592
|
+
module Security
|
|
593
|
+
module Authentication
|
|
594
|
+
# Audit logger for authentication events
|
|
595
|
+
#
|
|
596
|
+
# Provides centralized audit logging for authentication attempts,
|
|
597
|
+
# successes, and failures. Integrates with Otto's structured logging.
|
|
598
|
+
#
|
|
599
|
+
# @example Enable detailed audit logging
|
|
600
|
+
# Otto.enable_auth_audit_logging!
|
|
601
|
+
#
|
|
602
|
+
class AuditLogger
|
|
603
|
+
class << self
|
|
604
|
+
# Log authentication attempt start
|
|
605
|
+
def log_attempt(env, strategies)
|
|
606
|
+
return unless Otto.auth_audit_logging_enabled?
|
|
607
|
+
|
|
608
|
+
Otto.structured_log(:info, "Authentication attempt",
|
|
609
|
+
Otto::LoggingHelpers.request_context(env).merge(
|
|
610
|
+
event: 'authentication_attempt',
|
|
611
|
+
strategies_configured: strategies
|
|
612
|
+
)
|
|
613
|
+
)
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
# Log individual strategy execution
|
|
617
|
+
def log_strategy_execution(env, strategy_name, result, duration)
|
|
618
|
+
return unless Otto.auth_audit_logging_enabled?
|
|
619
|
+
|
|
620
|
+
event_data = Otto::LoggingHelpers.request_context(env).merge(
|
|
621
|
+
event: 'strategy_executed',
|
|
622
|
+
strategy: strategy_name,
|
|
623
|
+
duration: duration
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
if result.is_a?(StrategyResult) && result.authenticated?
|
|
627
|
+
event_data.merge!(success: true, user_id: result.user_id)
|
|
628
|
+
else
|
|
629
|
+
event_data.merge!(
|
|
630
|
+
success: false,
|
|
631
|
+
failure_reason: result.is_a?(AuthFailure) ? result.failure_reason : 'Unknown'
|
|
632
|
+
)
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
Otto.structured_log(:info, "Strategy executed", event_data)
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
# Log authentication success
|
|
639
|
+
def log_success(env, strategy_name, strategies_tried, user_id, duration_total)
|
|
640
|
+
Otto.structured_log(:info, "Authentication succeeded",
|
|
641
|
+
Otto::LoggingHelpers.request_context(env).merge(
|
|
642
|
+
event: 'authentication_succeeded',
|
|
643
|
+
strategy: strategy_name,
|
|
644
|
+
strategies_tried: strategies_tried,
|
|
645
|
+
user_id: user_id,
|
|
646
|
+
duration_total: duration_total
|
|
647
|
+
)
|
|
648
|
+
)
|
|
649
|
+
end
|
|
650
|
+
|
|
651
|
+
# Log authentication failure
|
|
652
|
+
def log_failure(env, strategies_tried, failure_reasons, duration_total)
|
|
653
|
+
Otto.structured_log(:warn, "Authentication failed",
|
|
654
|
+
Otto::LoggingHelpers.request_context(env).merge(
|
|
655
|
+
event: 'authentication_failed',
|
|
656
|
+
strategies_tried: strategies_tried,
|
|
657
|
+
failure_reasons: failure_reasons,
|
|
658
|
+
duration_total: duration_total
|
|
659
|
+
)
|
|
660
|
+
)
|
|
661
|
+
end
|
|
662
|
+
end
|
|
663
|
+
end
|
|
664
|
+
end
|
|
665
|
+
end
|
|
666
|
+
end
|
|
667
|
+
```
|
|
668
|
+
|
|
669
|
+
**Configuration:**
|
|
670
|
+
|
|
671
|
+
```ruby
|
|
672
|
+
# lib/otto.rb
|
|
673
|
+
class Otto
|
|
674
|
+
class << self
|
|
675
|
+
attr_accessor :auth_audit_logging_enabled
|
|
676
|
+
|
|
677
|
+
def enable_auth_audit_logging!
|
|
678
|
+
@auth_audit_logging_enabled = true
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
def disable_auth_audit_logging!
|
|
682
|
+
@auth_audit_logging_enabled = false
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
def auth_audit_logging_enabled?
|
|
686
|
+
@auth_audit_logging_enabled ||= false
|
|
687
|
+
end
|
|
688
|
+
end
|
|
689
|
+
end
|
|
690
|
+
```
|
|
691
|
+
|
|
692
|
+
**Usage:**
|
|
693
|
+
|
|
694
|
+
```ruby
|
|
695
|
+
# In application initialization
|
|
696
|
+
Otto.enable_auth_audit_logging!
|
|
697
|
+
|
|
698
|
+
# Now all authentication attempts are logged with full details
|
|
699
|
+
# Logs go to Otto.logger (can be sent to Datadog, Elasticsearch, etc.)
|
|
700
|
+
```
|
|
701
|
+
|
|
702
|
+
---
|
|
703
|
+
|
|
704
|
+
#### Audit Log Analysis
|
|
705
|
+
|
|
706
|
+
**Example Log Aggregation Query (Elasticsearch/Datadog):**
|
|
707
|
+
|
|
708
|
+
```
|
|
709
|
+
# Failed login attempts by IP (potential brute force)
|
|
710
|
+
event:authentication_failed
|
|
711
|
+
| stats count by ip
|
|
712
|
+
| where count > 10
|
|
713
|
+
| sort -count
|
|
714
|
+
|
|
715
|
+
# Strategy effectiveness (which strategies succeed most)
|
|
716
|
+
event:authentication_succeeded
|
|
717
|
+
| stats count by strategy
|
|
718
|
+
| sort -count
|
|
719
|
+
|
|
720
|
+
# Authentication latency by strategy
|
|
721
|
+
event:strategy_executed AND success:true
|
|
722
|
+
| stats avg(duration) by strategy
|
|
723
|
+
|
|
724
|
+
# Geographic distribution of auth failures
|
|
725
|
+
event:authentication_failed
|
|
726
|
+
| stats count by country
|
|
727
|
+
| geotable
|
|
728
|
+
```
|
|
729
|
+
|
|
730
|
+
---
|
|
731
|
+
|
|
732
|
+
### Hooks vs Structured Logging: Decision Matrix
|
|
733
|
+
|
|
734
|
+
| Requirement | Hooks System | Structured Logging | Winner |
|
|
735
|
+
|-------------|--------------|-------------------|--------|
|
|
736
|
+
| **Audit trail** | ✅ Yes | ✅ Yes | **TIE** |
|
|
737
|
+
| **Custom actions** | ✅ Easy | ⚠️ Harder | Hooks |
|
|
738
|
+
| **Database storage** | ✅ Built-in | ❌ Manual | Hooks |
|
|
739
|
+
| **Log aggregation** | ❌ Manual | ✅ Built-in | **Logs** |
|
|
740
|
+
| **Simplicity** | ❌ Complex | ✅ Simple | **Logs** |
|
|
741
|
+
| **Testability** | ❌ Hard | ✅ Easy | **Logs** |
|
|
742
|
+
| **Performance** | ✅ Fast | ✅ Fast | **TIE** |
|
|
743
|
+
| **Compliance (GDPR)** | ⚠️ Careful | ✅ Easy | **Logs** |
|
|
744
|
+
|
|
745
|
+
**Decision:** ✅ **Structured Logging Wins** for Otto
|
|
746
|
+
|
|
747
|
+
**Rationale:**
|
|
748
|
+
1. Simpler to implement and maintain
|
|
749
|
+
2. Better testability (mock logger, not hooks)
|
|
750
|
+
3. Works with modern log aggregation (Datadog, Loki, Elasticsearch)
|
|
751
|
+
4. Privacy-aware by default (IPs already masked)
|
|
752
|
+
5. Hooks can be added later if needed (non-breaking)
|
|
753
|
+
|
|
754
|
+
**Optional Enhancement:** Provide example integration for log → database
|
|
755
|
+
```ruby
|
|
756
|
+
# examples/audit_logging_to_database.rb
|
|
757
|
+
# Shows how to consume Otto logs and store in database
|
|
758
|
+
```
|
|
759
|
+
|
|
760
|
+
---
|
|
761
|
+
|
|
762
|
+
## Authorization Design
|
|
763
|
+
|
|
764
|
+
### The Two-Layer Authorization Pattern
|
|
765
|
+
|
|
766
|
+
**Industry Best Practice:** Authorization requires TWO distinct layers:
|
|
767
|
+
|
|
768
|
+
#### Layer 1: Route-Level Authorization (Authentication)
|
|
769
|
+
- **Question:** Is user allowed to access THIS ROUTE?
|
|
770
|
+
- **Location:** `RouteAuthWrapper` (before handler execution)
|
|
771
|
+
- **Checks:** Authentication status, general roles/permissions
|
|
772
|
+
- **Speed:** Fast (no resource loading required)
|
|
773
|
+
- **Response:** 401/403 before handler runs
|
|
774
|
+
- **Examples:**
|
|
775
|
+
- "Must be authenticated"
|
|
776
|
+
- "Must have 'admin' role"
|
|
777
|
+
- "Must have 'write' permission"
|
|
778
|
+
|
|
779
|
+
#### Layer 2: Resource-Level Authorization
|
|
780
|
+
- **Question:** Is user allowed to access THIS SPECIFIC RESOURCE?
|
|
781
|
+
- **Location:** Logic classes (in `raise_concerns` method)
|
|
782
|
+
- **Checks:** Ownership, relationships, resource attributes
|
|
783
|
+
- **Speed:** Slower (requires loading resource from database)
|
|
784
|
+
- **Response:** Raise `AuthorizationError` → 403
|
|
785
|
+
- **Examples:**
|
|
786
|
+
- "User must own this post"
|
|
787
|
+
- "User must be member of this organization"
|
|
788
|
+
- "Post must not be archived"
|
|
789
|
+
|
|
790
|
+
---
|
|
791
|
+
|
|
792
|
+
### Current State: Otto Already Has This!
|
|
793
|
+
|
|
794
|
+
**Otto's architecture is already correct:**
|
|
795
|
+
|
|
796
|
+
```ruby
|
|
797
|
+
# Layer 1: Route-level (RouteAuthWrapper)
|
|
798
|
+
GET /posts/:id PostLogic#show auth=session
|
|
799
|
+
|
|
800
|
+
# Layer 2: Resource-level (Logic class)
|
|
801
|
+
class PostLogic
|
|
802
|
+
def raise_concerns
|
|
803
|
+
@post = Post.find(@params[:id])
|
|
804
|
+
|
|
805
|
+
# Route auth guarantees: @context.authenticated? == true
|
|
806
|
+
# Now check: can THIS user access THIS post?
|
|
807
|
+
unless @context.user_id == @post.user_id || @context.has_role?('admin')
|
|
808
|
+
raise Otto::AuthorizationError, "Cannot view another user's post"
|
|
809
|
+
end
|
|
810
|
+
end
|
|
811
|
+
|
|
812
|
+
def process
|
|
813
|
+
{ post: @post }
|
|
814
|
+
end
|
|
815
|
+
end
|
|
816
|
+
```
|
|
817
|
+
|
|
818
|
+
**What's Missing:** Documentation and `AuthorizationError` class
|
|
819
|
+
|
|
820
|
+
---
|
|
821
|
+
|
|
822
|
+
### Evaluating `role=` Syntax in Routes
|
|
823
|
+
|
|
824
|
+
#### Current Capability: Role-Based Authentication
|
|
825
|
+
|
|
826
|
+
Otto currently supports role checking via `RoleStrategy`:
|
|
827
|
+
|
|
828
|
+
```ruby
|
|
829
|
+
# Register role strategy
|
|
830
|
+
otto.add_auth_strategy('role', RoleStrategy.new(['admin', 'moderator']))
|
|
831
|
+
|
|
832
|
+
# Route with role requirement
|
|
833
|
+
GET /admin AdminPanel#index auth=role:admin
|
|
834
|
+
```
|
|
835
|
+
|
|
836
|
+
**How it works:**
|
|
837
|
+
1. `RoleStrategy` checks if user has required role in session
|
|
838
|
+
2. Returns `StrategyResult` if user has role
|
|
839
|
+
3. Returns `AuthFailure` if user lacks role
|
|
840
|
+
4. Same authentication flow as other strategies
|
|
841
|
+
|
|
842
|
+
---
|
|
843
|
+
|
|
844
|
+
#### Proposed: `role=` as Separate Route Parameter
|
|
845
|
+
|
|
846
|
+
**Syntax:**
|
|
847
|
+
```
|
|
848
|
+
GET /admin AdminPanel#index auth=session role=admin
|
|
849
|
+
```
|
|
850
|
+
|
|
851
|
+
**Pros:**
|
|
852
|
+
- ✅ Clearer separation (authentication vs authorization)
|
|
853
|
+
- ✅ Can combine with multi-strategy auth: `auth=session,apikey role=admin`
|
|
854
|
+
- ✅ More explicit (easier to audit routes file)
|
|
855
|
+
|
|
856
|
+
**Cons:**
|
|
857
|
+
- ❌ Requires new parsing logic in `RouteDefinition`
|
|
858
|
+
- ❌ Requires new enforcement logic in `RouteAuthWrapper`
|
|
859
|
+
- ❌ Can already be done with `auth=role:admin`
|
|
860
|
+
|
|
861
|
+
**Analysis:**
|
|
862
|
+
|
|
863
|
+
| Approach | Syntax | Separation | Implementation |
|
|
864
|
+
|----------|--------|------------|----------------|
|
|
865
|
+
| **Current** | `auth=role:admin` | ⚠️ Blurred | ✅ Already works |
|
|
866
|
+
| **Proposed** | `auth=session role=admin` | ✅ Clear | ❌ Needs work |
|
|
867
|
+
|
|
868
|
+
**Decision:** ⚠️ **DEFER** - Current approach works, new syntax is marginal improvement
|
|
869
|
+
|
|
870
|
+
**Rationale:**
|
|
871
|
+
1. Current `auth=role:admin` works fine
|
|
872
|
+
2. For complex authorization, use Logic classes (Layer 2)
|
|
873
|
+
3. Route-level authorization should be simple
|
|
874
|
+
4. Can add `role=` syntax later if demand emerges (non-breaking)
|
|
875
|
+
|
|
876
|
+
---
|
|
877
|
+
|
|
878
|
+
### Recommended Authorization Patterns
|
|
879
|
+
|
|
880
|
+
#### Pattern 1: Route Protection Only (Layer 1)
|
|
881
|
+
|
|
882
|
+
**Use Case:** Admin panel, no resource-specific checks
|
|
883
|
+
|
|
884
|
+
```ruby
|
|
885
|
+
# routes.txt
|
|
886
|
+
GET /admin/dashboard Admin::Dashboard#index auth=session,apikey role=admin
|
|
887
|
+
|
|
888
|
+
# Logic class (minimal)
|
|
889
|
+
class Admin::Dashboard
|
|
890
|
+
def raise_concerns
|
|
891
|
+
# No additional checks - route auth handles everything
|
|
892
|
+
end
|
|
893
|
+
|
|
894
|
+
def process
|
|
895
|
+
# Guaranteed: user is authenticated AND has admin role
|
|
896
|
+
{ stats: gather_stats }
|
|
897
|
+
end
|
|
898
|
+
end
|
|
899
|
+
```
|
|
900
|
+
|
|
901
|
+
---
|
|
902
|
+
|
|
903
|
+
#### Pattern 2: Ownership Check (Layer 2)
|
|
904
|
+
|
|
905
|
+
**Use Case:** User editing own profile/posts
|
|
906
|
+
|
|
907
|
+
```ruby
|
|
908
|
+
# routes.txt
|
|
909
|
+
PUT /posts/:id Post::Update#call auth=session,apikey
|
|
910
|
+
|
|
911
|
+
# Logic class
|
|
912
|
+
class Post::Update
|
|
913
|
+
def raise_concerns
|
|
914
|
+
@post = Post.find(@params[:id])
|
|
915
|
+
|
|
916
|
+
# Layer 1 guaranteed: user is authenticated
|
|
917
|
+
# Layer 2 check: does user own this post?
|
|
918
|
+
unless @context.user_id == @post.user_id
|
|
919
|
+
raise Otto::AuthorizationError, "Cannot edit another user's post"
|
|
920
|
+
end
|
|
921
|
+
end
|
|
922
|
+
|
|
923
|
+
def process
|
|
924
|
+
@post.update(title: @params[:title])
|
|
925
|
+
{ post: @post }
|
|
926
|
+
end
|
|
927
|
+
end
|
|
928
|
+
```
|
|
929
|
+
|
|
930
|
+
---
|
|
931
|
+
|
|
932
|
+
#### Pattern 3: Complex Multi-Condition Authorization
|
|
933
|
+
|
|
934
|
+
**Use Case:** Organization membership + role
|
|
935
|
+
|
|
936
|
+
```ruby
|
|
937
|
+
# routes.txt
|
|
938
|
+
DELETE /orgs/:org_id/members/:member_id Org::RemoveMember#call auth=session
|
|
939
|
+
|
|
940
|
+
# Logic class
|
|
941
|
+
class Org::RemoveMember
|
|
942
|
+
def raise_concerns
|
|
943
|
+
@org = Organization.find(@params[:org_id])
|
|
944
|
+
@member = User.find(@params[:member_id])
|
|
945
|
+
|
|
946
|
+
# Check 1: User must be org owner OR have 'admin' role
|
|
947
|
+
is_owner = @org.owner_id == @context.user_id
|
|
948
|
+
is_admin = @context.has_role?('admin')
|
|
949
|
+
|
|
950
|
+
unless is_owner || is_admin
|
|
951
|
+
raise Otto::AuthorizationError, "Must be organization owner or admin"
|
|
952
|
+
end
|
|
953
|
+
|
|
954
|
+
# Check 2: Cannot remove yourself
|
|
955
|
+
if @member.id == @context.user_id
|
|
956
|
+
raise Otto::AuthorizationError, "Cannot remove yourself from organization"
|
|
957
|
+
end
|
|
958
|
+
|
|
959
|
+
# Check 3: Cannot remove other admins unless you're owner
|
|
960
|
+
if @member.has_role?('admin') && !is_owner
|
|
961
|
+
raise Otto::AuthorizationError, "Only owner can remove admins"
|
|
962
|
+
end
|
|
963
|
+
end
|
|
964
|
+
|
|
965
|
+
def process
|
|
966
|
+
@org.remove_member(@member)
|
|
967
|
+
{ success: true }
|
|
968
|
+
end
|
|
969
|
+
end
|
|
970
|
+
```
|
|
971
|
+
|
|
972
|
+
---
|
|
973
|
+
|
|
974
|
+
#### Pattern 4: Scoped Resource Access
|
|
975
|
+
|
|
976
|
+
**Use Case:** List only user's own resources
|
|
977
|
+
|
|
978
|
+
```ruby
|
|
979
|
+
# routes.txt
|
|
980
|
+
GET /posts Post::List#call auth=session,apikey
|
|
981
|
+
|
|
982
|
+
# Logic class
|
|
983
|
+
class Post::List
|
|
984
|
+
def raise_concerns
|
|
985
|
+
# No authorization errors - we just scope the results
|
|
986
|
+
end
|
|
987
|
+
|
|
988
|
+
def process
|
|
989
|
+
# Return only posts user owns OR is public
|
|
990
|
+
posts = if @context.has_role?('admin')
|
|
991
|
+
Post.all # Admins see everything
|
|
992
|
+
else
|
|
993
|
+
Post.where(user_id: @context.user_id)
|
|
994
|
+
.or(Post.where(public: true))
|
|
995
|
+
end
|
|
996
|
+
|
|
997
|
+
{ posts: posts }
|
|
998
|
+
end
|
|
999
|
+
end
|
|
1000
|
+
```
|
|
1001
|
+
|
|
1002
|
+
---
|
|
1003
|
+
|
|
1004
|
+
### AuthorizationError Implementation
|
|
1005
|
+
|
|
1006
|
+
**File:** `lib/otto/security/authorization_error.rb` (NEW)
|
|
1007
|
+
|
|
1008
|
+
```ruby
|
|
1009
|
+
# lib/otto/security/authorization_error.rb
|
|
1010
|
+
#
|
|
1011
|
+
# frozen_string_literal: true
|
|
1012
|
+
|
|
1013
|
+
class Otto
|
|
1014
|
+
module Security
|
|
1015
|
+
# Raised when user is authenticated but lacks authorization for resource
|
|
1016
|
+
#
|
|
1017
|
+
# Use this in Logic classes to indicate authorization failures.
|
|
1018
|
+
# Otto automatically converts this to 403 Forbidden response.
|
|
1019
|
+
#
|
|
1020
|
+
# @example In Logic class
|
|
1021
|
+
# class Post::Update
|
|
1022
|
+
# def raise_concerns
|
|
1023
|
+
# unless @context.user_id == @post.user_id
|
|
1024
|
+
# raise Otto::Security::AuthorizationError, "Cannot edit another user's post"
|
|
1025
|
+
# end
|
|
1026
|
+
# end
|
|
1027
|
+
# end
|
|
1028
|
+
#
|
|
1029
|
+
class AuthorizationError < StandardError
|
|
1030
|
+
attr_reader :resource, :action, :user_id
|
|
1031
|
+
|
|
1032
|
+
def initialize(message, resource: nil, action: nil, user_id: nil)
|
|
1033
|
+
super(message)
|
|
1034
|
+
@resource = resource
|
|
1035
|
+
@action = action
|
|
1036
|
+
@user_id = user_id
|
|
1037
|
+
end
|
|
1038
|
+
end
|
|
1039
|
+
end
|
|
1040
|
+
end
|
|
1041
|
+
```
|
|
1042
|
+
|
|
1043
|
+
**Register Error Handler:**
|
|
1044
|
+
|
|
1045
|
+
```ruby
|
|
1046
|
+
# lib/otto.rb (in initialize)
|
|
1047
|
+
def initialize(routes_source = nil, base_path: Dir.pwd)
|
|
1048
|
+
# ... existing initialization ...
|
|
1049
|
+
|
|
1050
|
+
# Register authorization error handler
|
|
1051
|
+
register_error_handler(Otto::Security::AuthorizationError,
|
|
1052
|
+
status: 403,
|
|
1053
|
+
log_level: :warn) do |error, req|
|
|
1054
|
+
{
|
|
1055
|
+
error: 'Forbidden',
|
|
1056
|
+
message: error.message,
|
|
1057
|
+
resource: error.resource,
|
|
1058
|
+
action: error.action
|
|
1059
|
+
}
|
|
1060
|
+
end
|
|
1061
|
+
end
|
|
1062
|
+
```
|
|
1063
|
+
|
|
1064
|
+
---
|
|
1065
|
+
|
|
1066
|
+
### Authorization Anti-Patterns
|
|
1067
|
+
|
|
1068
|
+
#### ❌ Anti-Pattern 1: Authorization in Routes File
|
|
1069
|
+
|
|
1070
|
+
```ruby
|
|
1071
|
+
# BAD: Complex authorization in routes
|
|
1072
|
+
GET /posts/:id Post::Show#call auth=session,apikey role=admin,moderator owner_or_public=true
|
|
1073
|
+
|
|
1074
|
+
# This is too complex for routes - use Logic class instead
|
|
1075
|
+
```
|
|
1076
|
+
|
|
1077
|
+
**Why Bad:** Routes should declare simple requirements, not complex business logic.
|
|
1078
|
+
|
|
1079
|
+
---
|
|
1080
|
+
|
|
1081
|
+
#### ❌ Anti-Pattern 2: No Layer 2 Authorization
|
|
1082
|
+
|
|
1083
|
+
```ruby
|
|
1084
|
+
# BAD: Only route-level auth, no resource check
|
|
1085
|
+
GET /posts/:id Post::Show#call auth=session
|
|
1086
|
+
|
|
1087
|
+
class Post::Show
|
|
1088
|
+
def raise_concerns
|
|
1089
|
+
# MISSING: No check if user can view THIS post
|
|
1090
|
+
end
|
|
1091
|
+
|
|
1092
|
+
def process
|
|
1093
|
+
@post = Post.find(@params[:id]) # Any authenticated user can view any post!
|
|
1094
|
+
{ post: @post }
|
|
1095
|
+
end
|
|
1096
|
+
end
|
|
1097
|
+
```
|
|
1098
|
+
|
|
1099
|
+
**Why Bad:** Authenticated doesn't mean authorized for specific resource.
|
|
1100
|
+
|
|
1101
|
+
---
|
|
1102
|
+
|
|
1103
|
+
#### ❌ Anti-Pattern 3: Authorization Without Authentication
|
|
1104
|
+
|
|
1105
|
+
```ruby
|
|
1106
|
+
# BAD: Checking ownership without requiring authentication
|
|
1107
|
+
GET /posts/:id Post::Show#call # No auth requirement!
|
|
1108
|
+
|
|
1109
|
+
class Post::Show
|
|
1110
|
+
def raise_concerns
|
|
1111
|
+
@post = Post.find(@params[:id])
|
|
1112
|
+
unless @context.user_id == @post.user_id # @context.user_id could be nil!
|
|
1113
|
+
raise Otto::Security::AuthorizationError, "Cannot view"
|
|
1114
|
+
end
|
|
1115
|
+
end
|
|
1116
|
+
end
|
|
1117
|
+
```
|
|
1118
|
+
|
|
1119
|
+
**Why Bad:** `@context.user_id` is nil for unauthenticated users, causing errors or bypasses.
|
|
1120
|
+
|
|
1121
|
+
**Fix:** Always require authentication if doing authorization checks:
|
|
1122
|
+
```
|
|
1123
|
+
GET /posts/:id Post::Show#call auth=session,apikey
|
|
1124
|
+
```
|
|
1125
|
+
|
|
1126
|
+
---
|
|
1127
|
+
|
|
1128
|
+
## Implementation Roadmap
|
|
1129
|
+
|
|
1130
|
+
### Phase 1: Core Multi-Strategy Support (3-4 hours)
|
|
1131
|
+
|
|
1132
|
+
**Files to Modify:**
|
|
1133
|
+
|
|
1134
|
+
1. **lib/otto/route_definition.rb**
|
|
1135
|
+
- Add `auth_requirements` method (returns array)
|
|
1136
|
+
- Keep `auth_requirement` for backward compatibility
|
|
1137
|
+
|
|
1138
|
+
2. **lib/otto/security/authentication/route_auth_wrapper.rb**
|
|
1139
|
+
- Update `call` method to loop through strategies
|
|
1140
|
+
- Add timing and logging for each attempt
|
|
1141
|
+
- Implement first-success-wins logic
|
|
1142
|
+
|
|
1143
|
+
3. **spec/otto/security/route_auth_wrapper_spec.rb**
|
|
1144
|
+
- Add tests for multiple strategies
|
|
1145
|
+
- Test OR logic (first success wins)
|
|
1146
|
+
- Test strategy ordering
|
|
1147
|
+
- Test missing strategy handling
|
|
1148
|
+
- Test failure logging
|
|
1149
|
+
|
|
1150
|
+
**Deliverables:**
|
|
1151
|
+
- ✅ Routes support `auth=session,apikey,oauth` syntax
|
|
1152
|
+
- ✅ First successful strategy wins
|
|
1153
|
+
- ✅ Comprehensive logging
|
|
1154
|
+
- ✅ Full test coverage
|
|
1155
|
+
|
|
1156
|
+
---
|
|
1157
|
+
|
|
1158
|
+
### Phase 2: Enhanced Auditing (1-2 hours)
|
|
1159
|
+
|
|
1160
|
+
**Files to Create:**
|
|
1161
|
+
|
|
1162
|
+
1. **lib/otto/security/authentication/audit_logger.rb**
|
|
1163
|
+
- Centralized audit logging methods
|
|
1164
|
+
- Integration with structured logging
|
|
1165
|
+
- Optional detailed mode
|
|
1166
|
+
|
|
1167
|
+
**Files to Modify:**
|
|
1168
|
+
|
|
1169
|
+
2. **lib/otto.rb**
|
|
1170
|
+
- Add `enable_auth_audit_logging!` method
|
|
1171
|
+
- Add configuration flag
|
|
1172
|
+
|
|
1173
|
+
3. **lib/otto/security/authentication/route_auth_wrapper.rb**
|
|
1174
|
+
- Integrate `AuditLogger` calls
|
|
1175
|
+
- Collect failure reasons from all strategies
|
|
1176
|
+
|
|
1177
|
+
**Deliverables:**
|
|
1178
|
+
- ✅ Optional detailed audit logging
|
|
1179
|
+
- ✅ Configuration API
|
|
1180
|
+
- ✅ Example log aggregation queries
|
|
1181
|
+
|
|
1182
|
+
---
|
|
1183
|
+
|
|
1184
|
+
### Phase 3: Authorization Support (2-3 hours)
|
|
1185
|
+
|
|
1186
|
+
**Files to Create:**
|
|
1187
|
+
|
|
1188
|
+
1. **lib/otto/security/authorization_error.rb**
|
|
1189
|
+
- Define `AuthorizationError` exception
|
|
1190
|
+
- Include resource/action metadata
|
|
1191
|
+
|
|
1192
|
+
**Files to Modify:**
|
|
1193
|
+
|
|
1194
|
+
2. **lib/otto.rb**
|
|
1195
|
+
- Register `AuthorizationError` handler (403 response)
|
|
1196
|
+
|
|
1197
|
+
3. **CLAUDE.md**
|
|
1198
|
+
- Add Authorization section
|
|
1199
|
+
- Document two-layer pattern
|
|
1200
|
+
- Provide examples
|
|
1201
|
+
|
|
1202
|
+
**Files to Create (Documentation):**
|
|
1203
|
+
|
|
1204
|
+
4. **docs/authorization-patterns.md**
|
|
1205
|
+
- Comprehensive authorization guide
|
|
1206
|
+
- 5 common patterns with code
|
|
1207
|
+
- Anti-patterns to avoid
|
|
1208
|
+
|
|
1209
|
+
5. **examples/authorization/**
|
|
1210
|
+
- `ownership_check.rb`
|
|
1211
|
+
- `multi_condition.rb`
|
|
1212
|
+
- `resource_scoping.rb`
|
|
1213
|
+
|
|
1214
|
+
**Deliverables:**
|
|
1215
|
+
- ✅ `AuthorizationError` exception
|
|
1216
|
+
- ✅ Automatic 403 handling
|
|
1217
|
+
- ✅ Comprehensive documentation
|
|
1218
|
+
- ✅ Working examples
|
|
1219
|
+
|
|
1220
|
+
---
|
|
1221
|
+
|
|
1222
|
+
### Phase 4: Documentation & Polish (1-2 hours)
|
|
1223
|
+
|
|
1224
|
+
**Files to Modify:**
|
|
1225
|
+
|
|
1226
|
+
1. **CLAUDE.md**
|
|
1227
|
+
- Update Authentication section for multi-strategy
|
|
1228
|
+
- Add examples of `auth=session,apikey`
|
|
1229
|
+
- Link to new docs
|
|
1230
|
+
|
|
1231
|
+
2. **README.md** (if exists)
|
|
1232
|
+
- Update authentication examples
|
|
1233
|
+
- Add multi-strategy showcase
|
|
1234
|
+
|
|
1235
|
+
**Files to Create:**
|
|
1236
|
+
|
|
1237
|
+
3. **docs/authentication-strategies.md**
|
|
1238
|
+
- Complete strategy guide
|
|
1239
|
+
- How to create custom strategies
|
|
1240
|
+
- Multi-strategy best practices
|
|
1241
|
+
|
|
1242
|
+
4. **changelog.d/YYYYMMDD_multi_strategy_auth.rst**
|
|
1243
|
+
- Document new feature
|
|
1244
|
+
- Include examples
|
|
1245
|
+
- Note backward compatibility
|
|
1246
|
+
|
|
1247
|
+
**Deliverables:**
|
|
1248
|
+
- ✅ Updated documentation
|
|
1249
|
+
- ✅ Changelog entry
|
|
1250
|
+
- ✅ Example applications
|
|
1251
|
+
|
|
1252
|
+
---
|
|
1253
|
+
|
|
1254
|
+
## Summary & Next Steps
|
|
1255
|
+
|
|
1256
|
+
### Key Decisions Made
|
|
1257
|
+
|
|
1258
|
+
1. ✅ **Multi-Strategy Syntax:** `auth=session,apikey,oauth` (comma-separated)
|
|
1259
|
+
2. ✅ **OR Logic:** First success wins, fail only if all fail
|
|
1260
|
+
3. ✅ **No Hooks System:** Structured logging is sufficient for auditing
|
|
1261
|
+
4. ✅ **Authorization:** Two-layer pattern (route + Logic class)
|
|
1262
|
+
5. ✅ **No `role=` syntax (yet):** Current `auth=role:admin` works fine
|
|
1263
|
+
|
|
1264
|
+
### What Otto Gains
|
|
1265
|
+
|
|
1266
|
+
**For Developers:**
|
|
1267
|
+
- Support multiple client types (web + mobile + API) on same route
|
|
1268
|
+
- Gradual migration between auth methods
|
|
1269
|
+
- Clear separation of authentication and authorization
|
|
1270
|
+
- Comprehensive audit trail via logs
|
|
1271
|
+
|
|
1272
|
+
**For Security:**
|
|
1273
|
+
- Explicit authentication requirements in routes file
|
|
1274
|
+
- Privacy-aware logging (IPs already masked)
|
|
1275
|
+
- Resource-level authorization enforcement
|
|
1276
|
+
- Fail-secure by default
|
|
1277
|
+
|
|
1278
|
+
**For Operations:**
|
|
1279
|
+
- Structured logs integrate with log aggregation
|
|
1280
|
+
- Performance metrics per strategy
|
|
1281
|
+
- Authentication success/failure analytics
|
|
1282
|
+
- Geographic distribution tracking
|
|
1283
|
+
|
|
1284
|
+
### Implementation Estimate
|
|
1285
|
+
|
|
1286
|
+
**Total Effort:** 6-8 hours
|
|
1287
|
+
- Phase 1 (Core): 3-4 hours
|
|
1288
|
+
- Phase 2 (Auditing): 1-2 hours
|
|
1289
|
+
- Phase 3 (Authorization): 2-3 hours
|
|
1290
|
+
- Phase 4 (Docs): 1-2 hours
|
|
1291
|
+
|
|
1292
|
+
**Complexity:** Medium
|
|
1293
|
+
- Well-isolated changes
|
|
1294
|
+
- Clear architecture
|
|
1295
|
+
- Existing patterns to follow
|
|
1296
|
+
|
|
1297
|
+
### Recommended Next Steps
|
|
1298
|
+
|
|
1299
|
+
1. **Review & Approve Design** (this document)
|
|
1300
|
+
2. **Implement Phase 1** (core multi-strategy support)
|
|
1301
|
+
3. **Test with Real Application** (validate approach)
|
|
1302
|
+
4. **Implement Phases 2-4** (auditing, authorization, docs)
|
|
1303
|
+
5. **Ship & Iterate** (gather feedback)
|
|
1304
|
+
|
|
1305
|
+
---
|
|
1306
|
+
|
|
1307
|
+
## Appendix: Code Snippets
|
|
1308
|
+
|
|
1309
|
+
### Complete Example: Organization API with Multi-Strategy Auth
|
|
1310
|
+
|
|
1311
|
+
**routes.txt:**
|
|
1312
|
+
```
|
|
1313
|
+
# Organization Management API
|
|
1314
|
+
# Supports browser sessions and API keys
|
|
1315
|
+
|
|
1316
|
+
GET /orgs Org::List#call auth=session,apikey response=json
|
|
1317
|
+
POST /orgs Org::Create#call auth=session,apikey response=json
|
|
1318
|
+
GET /orgs/:id Org::Show#call auth=session,apikey response=json
|
|
1319
|
+
PUT /orgs/:id Org::Update#call auth=session,apikey response=json
|
|
1320
|
+
DELETE /orgs/:id Org::Delete#call auth=session,apikey response=json
|
|
1321
|
+
|
|
1322
|
+
# Admin only (session required for CSRF protection)
|
|
1323
|
+
GET /admin/orgs Admin::Orgs#index auth=session role=admin
|
|
1324
|
+
```
|
|
1325
|
+
|
|
1326
|
+
**app.rb:**
|
|
1327
|
+
```ruby
|
|
1328
|
+
require 'otto'
|
|
1329
|
+
|
|
1330
|
+
class OrganizationAPI < Otto
|
|
1331
|
+
def initialize
|
|
1332
|
+
super('routes.txt')
|
|
1333
|
+
|
|
1334
|
+
# Configure authentication strategies
|
|
1335
|
+
add_auth_strategy('session', SessionStrategy.new(session_key: 'user_id'))
|
|
1336
|
+
add_auth_strategy('apikey', APIKeyStrategy.new)
|
|
1337
|
+
add_auth_strategy('role', RoleStrategy.new(['admin']))
|
|
1338
|
+
|
|
1339
|
+
# Enable audit logging
|
|
1340
|
+
Otto.enable_auth_audit_logging!
|
|
1341
|
+
|
|
1342
|
+
# Register authorization error handler (automatic 403)
|
|
1343
|
+
register_error_handler(Otto::Security::AuthorizationError, status: 403, log_level: :warn)
|
|
1344
|
+
end
|
|
1345
|
+
end
|
|
1346
|
+
```
|
|
1347
|
+
|
|
1348
|
+
**Logic class with authorization:**
|
|
1349
|
+
```ruby
|
|
1350
|
+
# app/logic/org/update.rb
|
|
1351
|
+
module Org
|
|
1352
|
+
class Update
|
|
1353
|
+
def initialize(context, params)
|
|
1354
|
+
@context = context
|
|
1355
|
+
@params = params
|
|
1356
|
+
end
|
|
1357
|
+
|
|
1358
|
+
def raise_concerns
|
|
1359
|
+
@org = Organization.find(@params[:id])
|
|
1360
|
+
|
|
1361
|
+
# Layer 1: Route auth guaranteed user is authenticated
|
|
1362
|
+
# Layer 2: Check if user can edit THIS org
|
|
1363
|
+
unless can_edit_org?(@org)
|
|
1364
|
+
raise Otto::Security::AuthorizationError.new(
|
|
1365
|
+
"Cannot edit organization",
|
|
1366
|
+
resource: "Organization:#{@org.id}",
|
|
1367
|
+
action: "update",
|
|
1368
|
+
user_id: @context.user_id
|
|
1369
|
+
)
|
|
1370
|
+
end
|
|
1371
|
+
end
|
|
1372
|
+
|
|
1373
|
+
def process
|
|
1374
|
+
@org.update(name: @params[:name])
|
|
1375
|
+
{ organization: @org }
|
|
1376
|
+
end
|
|
1377
|
+
|
|
1378
|
+
private
|
|
1379
|
+
|
|
1380
|
+
def can_edit_org?(org)
|
|
1381
|
+
# Owner can edit
|
|
1382
|
+
return true if org.owner_id == @context.user_id
|
|
1383
|
+
|
|
1384
|
+
# Admins can edit
|
|
1385
|
+
return true if @context.has_role?('admin')
|
|
1386
|
+
|
|
1387
|
+
# Members with 'write' permission can edit
|
|
1388
|
+
return true if org.member?(@context.user_id) && @context.has_permission?('write')
|
|
1389
|
+
|
|
1390
|
+
false
|
|
1391
|
+
end
|
|
1392
|
+
end
|
|
1393
|
+
end
|
|
1394
|
+
```
|
|
1395
|
+
|
|
1396
|
+
**Result:**
|
|
1397
|
+
- Web browsers use session authentication
|
|
1398
|
+
- Mobile apps use API key authentication
|
|
1399
|
+
- Same route, different auth methods
|
|
1400
|
+
- Resource-level authorization in Logic class
|
|
1401
|
+
- Comprehensive audit trail via logs
|