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.
@@ -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