otto 2.9.0 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. checksums.yaml +4 -4
  2. data/.github/dependabot.yml +5 -0
  3. data/.github/workflows/ci.yml +11 -8
  4. data/.github/workflows/claude-code-review.yml +38 -13
  5. data/.github/workflows/claude.yml +10 -8
  6. data/.github/workflows/code-smells.yml +5 -5
  7. data/.github/workflows/release-gem.yml +2 -2
  8. data/.github/workflows/ruby-lint.yml +3 -3
  9. data/.github/workflows/yardoc.yml +5 -5
  10. data/.gitignore +1 -5
  11. data/.rubocop_todo.yml +7 -5
  12. data/AGENTS.md +22 -1
  13. data/CHANGELOG.rst +203 -0
  14. data/Gemfile +3 -3
  15. data/Gemfile.lock +11 -15
  16. data/README.md +75 -37
  17. data/docs/README.md +166 -0
  18. data/docs/adr/README.md +16 -0
  19. data/docs/adr/adr-001-route-authentication-at-handler-boundary.md +38 -0
  20. data/docs/adr/adr-002-multi-strategy-authentication-and-authorization.md +50 -0
  21. data/docs/adr/adr-003-caddy-tls-route-based-integration.md +48 -0
  22. data/docs/adr/adr-004-separate-compatibility-from-security-maintenance.md +58 -0
  23. data/docs/guides/authentication.md +377 -0
  24. data/docs/guides/caddy-tls.md +205 -0
  25. data/docs/guides/configuration_freezing.md +146 -0
  26. data/docs/guides/enrichment.md +161 -0
  27. data/docs/guides/forwarded-authority.md +249 -0
  28. data/docs/guides/geo-country.md +168 -0
  29. data/docs/guides/ip_privacy.md +39 -0
  30. data/docs/guides/ipaddr-encoding-quirk.md +56 -0
  31. data/docs/guides/mcp.md +282 -0
  32. data/docs/guides/privacy.md +193 -0
  33. data/docs/guides/routing.md +181 -0
  34. data/docs/guides/structured_logging.md +281 -0
  35. data/docs/guides/testing-guide.md +391 -0
  36. data/docs/maintainers/github-actions.md +41 -0
  37. data/docs/maintainers/investigations/.gitignore +2 -0
  38. data/docs/migrating/v2.0.0.md +337 -0
  39. data/docs/reference/authentication.md +290 -0
  40. data/docs/reference/route-syntax.md +181 -0
  41. data/docs/reference/runtime-and-dependency-security.md +86 -0
  42. data/examples/advanced_routes/README.md +43 -57
  43. data/examples/authentication_strategies/README.md +37 -196
  44. data/examples/basic/README.md +24 -39
  45. data/examples/basic/config.ru +0 -1
  46. data/examples/caddy_tls_demo/README.md +8 -2
  47. data/examples/lambda_handlers/README.md +11 -2
  48. data/examples/mcp_demo/README.md +75 -161
  49. data/examples/mcp_demo/config.ru +1 -0
  50. data/examples/security_features/README.md +36 -234
  51. data/lib/otto/caddy_tls/localhost_guard.rb +22 -24
  52. data/lib/otto/core/configuration.rb +36 -22
  53. data/lib/otto/core/file_safety.rb +89 -31
  54. data/lib/otto/core/middleware_stack.rb +36 -36
  55. data/lib/otto/core/router.rb +62 -19
  56. data/lib/otto/env_keys.rb +20 -2
  57. data/lib/otto/mcp/auth/token.rb +10 -4
  58. data/lib/otto/mcp/core.rb +23 -5
  59. data/lib/otto/mcp/endpoint.rb +41 -0
  60. data/lib/otto/mcp/errors.rb +15 -0
  61. data/lib/otto/mcp/options.rb +292 -0
  62. data/lib/otto/mcp/protocol.rb +52 -22
  63. data/lib/otto/mcp/rate_limiting.rb +175 -97
  64. data/lib/otto/mcp/registry.rb +14 -14
  65. data/lib/otto/mcp/schema_validation.rb +20 -12
  66. data/lib/otto/mcp/server.rb +131 -32
  67. data/lib/otto/optional_dependency.rb +57 -0
  68. data/lib/otto/privacy/config.rb +10 -8
  69. data/lib/otto/security/authentication/route_auth_wrapper/role_authorization.rb +22 -5
  70. data/lib/otto/security/authentication/strategies/api_key_strategy.rb +181 -18
  71. data/lib/otto/security/authentication/strategies/permission_strategy.rb +1 -0
  72. data/lib/otto/security/authentication/strategies/role_strategy.rb +1 -0
  73. data/lib/otto/security/authentication/strategies/session_strategy.rb +1 -0
  74. data/lib/otto/security/authentication/strategy_result.rb +58 -28
  75. data/lib/otto/security/config.rb +328 -14
  76. data/lib/otto/security/configurator.rb +36 -6
  77. data/lib/otto/security/core.rb +19 -1
  78. data/lib/otto/security/middleware/ip_privacy_middleware.rb +105 -4
  79. data/lib/otto/security/middleware/rate_limit_middleware.rb +1 -6
  80. data/lib/otto/security/rate_limiter.rb +81 -46
  81. data/lib/otto/utils.rb +50 -0
  82. data/lib/otto/version.rb +1 -1
  83. data/lib/otto.rb +9 -11
  84. data/otto.gemspec +0 -2
  85. metadata +31 -41
  86. data/docs/.gitignore +0 -10
  87. data/docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md +0 -1105
  88. data/docs/1108-STREAMING_SUPPORT_SUMMARY.md +0 -376
  89. data/docs/enrichment.md +0 -128
  90. data/docs/geo-country.md +0 -181
  91. data/docs/ipaddr-encoding-quirk.md +0 -34
  92. data/docs/migrating/v2.0.0-pre1.md +0 -276
  93. data/docs/migrating/v2.0.0-pre2.md +0 -338
  94. data/docs/modern-authentication-authorization-landscape.md +0 -558
  95. data/docs/multi-strategy-authentication-design.md +0 -1401
  96. data/docs/reverse-proxy-network-services.md +0 -371
@@ -0,0 +1,337 @@
1
+ # Otto v2.0.0 Migration Guide
2
+
3
+ ## Overview
4
+
5
+ This guide covers upgrading from Otto v1.x to v2.0.0. If you are already on
6
+ v2.x, see the later guides in this directory (for example
7
+ [v2.3.0](v2.3.0.md)) for changes since.
8
+
9
+ Otto v2.0.0 is a major release introducing a modular architecture, security and privacy by default, and handler-level authentication. The main themes are:
10
+
11
+ - **Modular Architecture**: Core functionality extracted into focused modules (Router, Configuration, ErrorHandler, etc.) using composition patterns
12
+ - **Security by Default**: IP privacy middleware, configuration freezing, backtrace sanitization
13
+ - **Privacy by Default**: Public IP masking runs automatically; no original values stored
14
+ - **Handler-Level Authentication**: Auth moved from middleware to RouteAuthWrapper, executing after routing for precise control
15
+ - **Improved Developer Experience**: Unified middleware API, typed request/response helpers, structured logging
16
+
17
+ ## Breaking Changes Summary
18
+
19
+ | Change | Before | After |
20
+ |--------|--------|-------|
21
+ | Middleware registration | `otto.middleware_stack <<` | `otto.use()` |
22
+ | Authentication location | Middleware (before routing) | RouteAuthWrapper (after routing) |
23
+ | Logic class constructor | `initialize(session, user, params, locale)` | `initialize(context, params, locale)` |
24
+ | StrategyResult checks | `success?` / `failure?` | `authenticated?` / `auth_attempt_succeeded?` |
25
+ | Request completion callback | `Otto.on_request_complete` (class) | `otto.on_request_complete` (instance) |
26
+ | Timing precision | Milliseconds | Microseconds |
27
+
28
+ ## Quick Migration Checklist
29
+
30
+ - [ ] Replace `otto.middleware_stack <<` with `otto.use()`
31
+ - [ ] Remove `enable_authentication!` calls (RouteAuthWrapper handles auth automatically)
32
+ - [ ] Update Logic class constructors: 4 params to 3 params (`context, params, locale`)
33
+ - [ ] Replace `@session`/`@user` with `@context` in Logic classes
34
+ - [ ] Replace `success?`/`failure?` with `authenticated?` or type checks
35
+ - [ ] Change `Otto.on_request_complete` to `otto.on_request_complete` (instance method)
36
+ - [ ] Update timing expectations from milliseconds to microseconds
37
+ - [ ] Register custom error handlers before first request
38
+ - [ ] Register request/response helpers before first request
39
+ - [ ] Run full test suite and verify all routes work
40
+
41
+ ## Detailed Migration Sections
42
+
43
+ ### Middleware Configuration
44
+
45
+ The middleware stack API has been unified for consistency and performance.
46
+
47
+ **Before:**
48
+ ```ruby
49
+ otto.middleware_stack << SomeMiddleware
50
+ otto.middleware.add(AnotherMiddleware)
51
+ ```
52
+
53
+ **After:**
54
+ ```ruby
55
+ otto.use(SomeMiddleware)
56
+ otto.use(AnotherMiddleware)
57
+ ```
58
+
59
+ Key improvements:
60
+ - O(1) middleware lookup using Set-based tracking
61
+ - Memoized middleware list reduces array creation
62
+ - Prevents duplicate middleware registrations
63
+ - Use `otto.middleware.includes?()` for stack checks
64
+
65
+ See [v2.0.0-pre1 migration guide](v2.0.0-pre1.md#middleware-stack-unified-api) for detailed examples.
66
+
67
+ ### Authentication Setup
68
+
69
+ Authentication has moved from middleware to handler level via RouteAuthWrapper.
70
+
71
+ **What changed:**
72
+ - `AuthenticationMiddleware` removed (executed before routing)
73
+ - `enable_authentication!` removed (no longer needed)
74
+ - RouteAuthWrapper now wraps all routes automatically
75
+ - `env['otto.strategy_result']` guaranteed present on all routes
76
+
77
+ **Configuration:**
78
+ ```ruby
79
+ # Add strategies (before first request)
80
+ otto.add_auth_strategy('session', SessionStrategy.new)
81
+ otto.add_auth_strategy('apikey', APIKeyStrategy.new(api_keys: ENV.fetch('API_KEYS').split(',')))
82
+
83
+ # Configure login redirect path (optional)
84
+ otto.auth_config[:login_path] = '/auth/login' # Default: '/signin'
85
+ ```
86
+
87
+ **Route definitions:**
88
+ ```ruby
89
+ # Routes file - strategies execute left-to-right, first success wins
90
+ GET /api/data DataLogic#show auth=session,apikey
91
+
92
+ # With role requirements (OR logic)
93
+ GET /admin/users AdminLogic auth=session role=admin,editor
94
+ ```
95
+
96
+ ### Logic Class Constructor Pattern
97
+
98
+ Logic classes now receive an immutable context object instead of separate parameters.
99
+
100
+ **Before:**
101
+ ```ruby
102
+ class MyLogic
103
+ def initialize(session, user, params, locale)
104
+ @session = session
105
+ @user = user
106
+ @params = params
107
+ @locale = locale
108
+ end
109
+
110
+ def raise_concerns
111
+ raise 'Access denied' unless @user&.dig('role') == 'admin'
112
+ end
113
+ end
114
+ ```
115
+
116
+ **After:**
117
+ ```ruby
118
+ class MyLogic
119
+ def initialize(context, params, locale)
120
+ @context = context
121
+ @params = params
122
+ @locale = locale
123
+ end
124
+
125
+ def raise_concerns
126
+ raise 'Access denied' unless @context.has_role?('admin')
127
+ end
128
+ end
129
+ ```
130
+
131
+ **Context API:**
132
+ ```ruby
133
+ @context.authenticated? # User in session (request state)
134
+ @context.anonymous? # Not authenticated
135
+ @context.auth_attempt_succeeded? # Auth strategy just succeeded (auth outcome)
136
+ @context.user_id # User ID
137
+ @context.user_name # User name
138
+ @context.session_id # Session ID
139
+ @context.has_role?('admin') # Role check
140
+ @context.has_permission?('write') # Permission check
141
+ @context.roles # Array of roles
142
+ @context.permissions # Array of permissions
143
+ ```
144
+
145
+ See [v2.0.0-pre1 migration guide](v2.0.0-pre1.md#logic-class-requestcontext-pattern) for comprehensive examples.
146
+
147
+ ### StrategyResult Semantics
148
+
149
+ The `success?` and `failure?` methods have been removed. Use semantic methods instead.
150
+
151
+ **Before:**
152
+ ```ruby
153
+ if @strategy_result.success?
154
+ # Always true for StrategyResult, meaningless
155
+ end
156
+ ```
157
+
158
+ **After:**
159
+ ```ruby
160
+ # For "user in session" checks (registration, profile access)
161
+ if @strategy_result.authenticated?
162
+ raise "Already signed up"
163
+ end
164
+
165
+ # For "just logged in" checks (redirects, welcome messages)
166
+ if @strategy_result.auth_attempt_succeeded?
167
+ redirect_to dashboard_path
168
+ end
169
+
170
+ # For type checking
171
+ if strategy_result.is_a?(Otto::Security::Authentication::StrategyResult)
172
+ # handle authenticated request
173
+ end
174
+ ```
175
+
176
+ See [v2.0.0-pre2 migration guide](v2.0.0-pre2.md) for detailed semantic explanations.
177
+
178
+ ### Error Handling
179
+
180
+ Register handlers for expected business logic errors to avoid 500 error logging.
181
+
182
+ ```ruby
183
+ otto = Otto.new('routes.txt')
184
+
185
+ # Register before first request
186
+ otto.register_error_handler(YourApp::NotFound, status: 404, log_level: :info)
187
+ otto.register_error_handler(YourApp::RateLimited, status: 429, log_level: :warn)
188
+
189
+ # With custom response handler
190
+ otto.register_error_handler(YourApp::ValidationError, status: 422) do |error, env|
191
+ { errors: error.details }.to_json
192
+ end
193
+ ```
194
+
195
+ Framework error classes now have proper HTTP status codes:
196
+ - `Otto::NotFoundError` - 404
197
+ - `Otto::BadRequestError` - 400
198
+ - `Otto::ForbiddenError` - 403
199
+ - `Otto::UnauthorizedError` - 401
200
+ - `Otto::PayloadTooLargeError` - 413
201
+
202
+ ### Request/Response Helpers
203
+
204
+ Register application-specific helpers that integrate with Otto features.
205
+
206
+ ```ruby
207
+ module YourApp::RequestHelpers
208
+ def current_customer
209
+ user = strategy_result&.user
210
+ user.is_a?(YourApp::Customer) ? user : YourApp::Customer.anonymous
211
+ end
212
+ end
213
+
214
+ module YourApp::ResponseHelpers
215
+ def set_customer_cookie(customer)
216
+ set_cookie('customer_id', value: customer.id, httponly: true)
217
+ end
218
+ end
219
+
220
+ otto = Otto.new('routes.txt')
221
+ otto.register_request_helpers(YourApp::RequestHelpers)
222
+ otto.register_response_helpers(YourApp::ResponseHelpers)
223
+ ```
224
+
225
+ Helpers are included at class level, not per-request extension.
226
+
227
+ **Reserved method names** - Do not override these in helper modules:
228
+ - Request: `env`, `params`, `cookies`, `session`, `path`, `request_method`, `ip`, etc.
229
+ - Response: `status`, `headers`, `body`, `finish`, `write`, `redirect`, etc.
230
+ - Otto-specific: `request`, `app_path`, `masked_ip`, `hashed_ip`, `secure?`, `local?`, `ajax?`
231
+
232
+ ### Request Completion Callbacks
233
+
234
+ Callbacks are now instance methods to prevent duplicate invocations in multi-app architectures.
235
+
236
+ **Before:**
237
+ ```ruby
238
+ Otto.on_request_complete { |req, res, duration| ... }
239
+ ```
240
+
241
+ **After:**
242
+ ```ruby
243
+ otto.on_request_complete { |req, res, duration| ... }
244
+ ```
245
+
246
+ The callback now receives a `Rack::Response` object instead of `[status, headers, body]` tuple.
247
+
248
+ ### Timing Precision
249
+
250
+ All timing now uses microseconds via `Otto::Utils.now_in_μs`.
251
+
252
+ **Before:** `15.2` (milliseconds as float)
253
+ **After:** `15200` (microseconds as integer)
254
+
255
+ Update any code that parses or displays timing values.
256
+
257
+ ## New Features to Adopt
258
+
259
+ ### IP Privacy Middleware
260
+
261
+ Automatically masks public IP addresses while preserving private/localhost IPs:
262
+ - Public IPs masked: `192.0.2.100` becomes `192.0.2.0`
263
+ - Private IPs preserved: `127.0.0.1`, `192.168.x.x`, `10.x.x.x`
264
+ - Runs FIRST in middleware stack
265
+
266
+ No configuration needed - enabled by default.
267
+
268
+ ### Structured Logging
269
+
270
+ ```ruby
271
+ Otto.structured_log(:debug, "Route matched",
272
+ Otto::LoggingHelpers.request_context(env).merge(
273
+ type: 'literal',
274
+ handler: route.definition
275
+ )
276
+ )
277
+
278
+ # For timed operations
279
+ Otto::LoggingHelpers.log_timed_operation(:info, "Operation", env, key: value) do
280
+ perform_operation()
281
+ end
282
+ ```
283
+
284
+ ### Configuration Freezing
285
+
286
+ Otto automatically freezes all configuration after the first request. Complete all setup before handling requests:
287
+
288
+ ```ruby
289
+ otto = Otto.new('routes.txt')
290
+
291
+ # All configuration must happen here
292
+ otto.add_auth_strategy('session', SessionStrategy.new)
293
+ otto.register_error_handler(MyError, status: 400)
294
+ otto.register_request_helpers(MyHelpers)
295
+
296
+ # Configuration freezes on first request
297
+ run otto
298
+ ```
299
+
300
+ ### Base Error Classes
301
+
302
+ Subclass framework error classes for consistent HTTP semantics:
303
+
304
+ ```ruby
305
+ class RecordNotFound < Otto::NotFoundError; end # Returns 404
306
+ class InvalidInput < Otto::BadRequestError; end # Returns 400
307
+ class AccessDenied < Otto::ForbiddenError; end # Returns 403
308
+ ```
309
+
310
+ ### Routes with JSON Response Type
311
+
312
+ Routes declaring `response=json` now return JSON errors instead of redirects:
313
+
314
+ ```ruby
315
+ # Routes file
316
+ GET /api/data ApiLogic#show auth=session response=json
317
+ ```
318
+
319
+ Auth failures return `401 JSON` instead of `302 redirect`.
320
+
321
+ ## Testing Your Migration
322
+
323
+ ```bash
324
+ # Run full test suite
325
+ bundle exec rspec
326
+
327
+ # Verify specific patterns were updated
328
+ grep -r "@strategy_result.success?" app/
329
+ grep -r "middleware_stack <<" config/
330
+ grep -r "Otto.on_request_complete" app/
331
+ ```
332
+
333
+ ## Questions?
334
+
335
+ - Pre-release guides: [v2.0.0-pre1](v2.0.0-pre1.md), [v2.0.0-pre2](v2.0.0-pre2.md)
336
+ - Source documentation: `lib/otto/security/authentication/strategy_result.rb`, `lib/otto/env_keys.rb`
337
+ - Issues: [github.com/delano/otto/issues](https://github.com/delano/otto/issues)
@@ -0,0 +1,290 @@
1
+ # Authentication Architecture Documentation
2
+
3
+ Otto implements authentication at the handler level via `RouteAuthWrapper`, NOT through middleware. This provides precise control over authentication requirements per route.
4
+
5
+ ## Basic Configuration
6
+
7
+ Authentication strategies are configured during Otto initialization:
8
+
9
+ ```ruby
10
+ otto = Otto.new('routes.txt')
11
+ otto.add_auth_strategy('session', SessionStrategy.new)
12
+ otto.add_auth_strategy('apikey', APIKeyStrategy.new(api_keys: ENV.fetch('API_KEYS').split(',')))
13
+ otto.add_auth_strategy('oauth', OAuthStrategy.new)
14
+ ```
15
+
16
+ **Key Rules:**
17
+ - Strategy names must be unique (duplicate registration raises ArgumentError)
18
+ - Must be registered before first request (configuration freezing)
19
+ - Routes with `auth` requirements are automatically wrapped by RouteAuthWrapper
20
+
21
+ ## Multi-Strategy Authentication (OR Logic)
22
+
23
+ Routes can specify multiple authentication strategies with comma-separated syntax:
24
+
25
+ ```ruby
26
+ # Routes file
27
+ GET /api/data DataLogic#show auth=session,apikey,oauth
28
+ ```
29
+
30
+ **Execution Flow:**
31
+ 1. Strategies execute left-to-right in order
32
+ 2. **First success wins** - remaining strategies are not executed
33
+ 3. Returns 401 only if **all** strategies fail
34
+ 4. Unknown strategies cause immediate 401 (strict mode)
35
+
36
+ **Performance Tip:** Put fastest/most-common strategies first (e.g., `auth=session,apikey`)
37
+
38
+ **Example Execution:**
39
+ ```ruby
40
+ # Route: auth=session,apikey,oauth
41
+ # 1. Tries 'session' strategy
42
+ # 2. If session succeeds → call handler (apikey/oauth not executed)
43
+ # 3. If session fails → try 'apikey' strategy
44
+ # 4. If apikey succeeds → call handler (oauth not executed)
45
+ # 5. If apikey fails → try 'oauth' strategy
46
+ # 6. If oauth succeeds → call handler
47
+ # 7. If all fail → return 401
48
+ ```
49
+
50
+ ## Strategy Pattern Matching
51
+
52
+ - **Exact match**: `'authenticated'` → looks up `auth_config[:auth_strategies]['authenticated']`
53
+ - **Prefix match**: `'custom:value'` → looks up `'custom'` strategy and passes full requirement
54
+ - **Results are cached** per wrapper instance
55
+
56
+ ## Two-Layer Authorization Pattern
57
+
58
+ Otto implements industry-standard separation between authentication and authorization:
59
+
60
+ ### Layer 1: Route-Level Authorization
61
+
62
+ Handled by `RouteAuthWrapper` before handler execution:
63
+
64
+ ```ruby
65
+ # Routes file examples
66
+ GET /admin/users AdminUserLogic auth=session role=admin
67
+ GET /content/edit ContentEditLogic auth=session role=admin,editor
68
+ GET /profile ProfileLogic auth=session
69
+ ```
70
+
71
+ **Features:**
72
+ - Use `auth=` for authentication strategies
73
+ - Use `role=` for role-based route access (OR logic for multiple roles)
74
+ - Fast execution (no database queries)
75
+ - Returns 401 (Unauthorized) for authentication failures
76
+ - Returns 403 (Forbidden) for authorization failures
77
+
78
+ **Role Extraction Order:**
79
+ 1. `result.user_roles` (direct accessor)
80
+ 2. `result.user[:roles]` (user hash with symbol key)
81
+ 3. `result.user['roles']` (user hash with string key)
82
+ 4. `result.metadata[:user_roles]` (metadata)
83
+
84
+ ### Layer 2: Resource-Level Authorization
85
+
86
+ Handled by Logic classes in `raise_concerns` method:
87
+
88
+ ```ruby
89
+ # Route: GET /posts/:id/edit PostEditLogic auth=session
90
+ class PostEditLogic
91
+ def raise_concerns
92
+ @post = Post.find(params[:id])
93
+
94
+ # Resource-level authorization
95
+ unless @post.user_id == @context.user_id
96
+ raise Otto::Security::AuthorizationError, "Cannot edit another user's post"
97
+ end
98
+ end
99
+
100
+ def process
101
+ # Edit post logic
102
+ end
103
+ end
104
+ ```
105
+
106
+ **Features:**
107
+ - Checks ownership, relationships, resource attributes
108
+ - Requires database queries to load resources
109
+ - Raises `Otto::Security::AuthorizationError` for 403 response
110
+ - Auto-registered during Otto initialization (logged at WARN level)
111
+
112
+ ## Strategy Implementation Examples
113
+
114
+ ### Session Strategy with Roles
115
+
116
+ ```ruby
117
+ class RoleAwareSessionStrategy < Otto::Security::Authentication::AuthStrategy
118
+ def authenticate(env, _requirement)
119
+ session = env['rack.session']
120
+ return failure('No session') unless session
121
+
122
+ user_id = session['user_id']
123
+ # A session cookie is an ambient credential: leave this non-terminal so a
124
+ # later strategy in an OR chain can still run.
125
+ return failure('Not authenticated') unless user_id
126
+
127
+ # Include roles in the user data
128
+ success(
129
+ user: {
130
+ id: user_id,
131
+ roles: session['user_roles'] || [] # Accessible as user[:roles]
132
+ },
133
+ session: session
134
+ )
135
+ end
136
+ end
137
+ ```
138
+
139
+ Strategies do not choose a redirect. `RouteAuthWrapper` turns a `failure` into
140
+ a 401 for API clients and, for HTML requests, a 302 to
141
+ `otto.auth_config[:login_path]` (default `/signin`).
142
+
143
+ ### API Key Strategy
144
+
145
+ Otto ships `Otto::Security::Authentication::Strategies::APIKeyStrategy`; see
146
+ that class for the real implementation. It reads the `X-API-Key` header only
147
+ unless you pass `param_name: 'api_key'` to also accept the credential as a query
148
+ or form parameter; keys in URLs are recorded by access logs, proxies, and
149
+ browser history. The strategy never places the raw key in the result; its own
150
+ field is `metadata[:api_key_fingerprint]` (a truncated SHA-256 digest). With a
151
+ static `api_keys:` list `user` is a Hash carrying the same fingerprint, and
152
+ with a resolver `user` is whatever the resolver returned, verbatim.
153
+
154
+ Keys come from exactly one of three sources. `api_keys:` takes a static list
155
+ and matches under constant-time comparison. A block, or a `resolver:` that
156
+ responds to `#call`, looks the presented key up and returns the account behind
157
+ it:
158
+
159
+ ```ruby
160
+ APIKeyStrategy = Otto::Security::Authentication::Strategies::APIKeyStrategy
161
+
162
+ # Static list
163
+ APIKeyStrategy.new(api_keys: ENV.fetch('API_KEYS').split(','))
164
+
165
+ # Block resolver, looking up by digest so raw keys never touch the database
166
+ APIKeyStrategy.new do |presented_key|
167
+ ApiKey.find_by(digest: APIKeyStrategy.digest(presented_key))&.account
168
+ end
169
+
170
+ # Callable resolver
171
+ APIKeyStrategy.new(resolver: repo.method(:find_by_key))
172
+ ```
173
+
174
+ Passing none of the three, or more than one, raises `ArgumentError`. The
175
+ resolver receives only the presented key as a non-empty `String`; a missing
176
+ credential is still the non-terminal `No API key provided` failure and never
177
+ reaches the resolver. A `nil` or `false` return is the terminal
178
+ `Invalid API key` failure (401), the same as a static mismatch; any other
179
+ value, including an empty relation, Array, or Hash, is a match, so return one
180
+ record or `nil`, not a `where(...)` relation. Exceptions from
181
+ the resolver propagate rather than turning into a 401 or a success. The
182
+ returned value becomes `user`, and `api_key_fingerprint` is set in the metadata
183
+ regardless. The result is stored in `env['otto.strategy_result']` and exposed
184
+ to handlers, so anything the application serializes or logs from it carries
185
+ `user`; the resolver must not return an object that carries the raw key:
186
+ return the account, not the `ApiKey` row that stores the key, and store
187
+ digests. Returning the presented key string itself as the user raises
188
+ `ArgumentError`. `APIKeyStrategy.digest(key)` is the full SHA-256 hex digest;
189
+ the fingerprint is its first 12 characters.
190
+
191
+ The strategy cannot make a black-box lookup constant-time. Store SHA-256
192
+ digests and look up by `APIKeyStrategy.digest(presented_key)`, as above.
193
+
194
+ `APIKeyStrategy` is a small static-allowlist authenticator shipped as a
195
+ low-dependency convenience and reference implementation. It has no native
196
+ support for runtime addition or revocation, expiration, per-client roles or
197
+ scopes, ownership or descriptive metadata, usage quotas, a management API,
198
+ persisted audit history, or hashed verifier storage. The resolver form hands
199
+ validity, roles, and metadata to the application's key store; the rest stay
200
+ outside the strategy. See the
201
+ [authentication guide](../guides/authentication.md#what-apikeystrategy-does-not-provide).
202
+
203
+ A custom key-backed strategy that needs more than the resolver offers (for
204
+ example, consulting `env`) follows the same shape:
205
+
206
+ ```ruby
207
+ class DatabaseAPIKeyStrategy < Otto::Security::Authentication::AuthStrategy
208
+ def authenticate(env, _requirement)
209
+ api_key = env['HTTP_X_API_KEY'] || extract_from_params(env)
210
+ # No credential presented: non-terminal, so a later strategy may still run.
211
+ return failure('Missing API key') if api_key.nil? || api_key.empty?
212
+
213
+ digest = Otto::Security::Authentication::Strategies::APIKeyStrategy.digest(api_key)
214
+ user = User.find_by(api_key_digest: digest)
215
+ # A credential WAS presented and rejected: terminal, fail closed with 401.
216
+ return failure('Invalid API key', terminal: true) unless user
217
+
218
+ success(user: { id: user.id, roles: user.roles }, auth_method: 'api_key')
219
+ end
220
+
221
+ private
222
+
223
+ def extract_from_params(env)
224
+ Otto::Request.new(env).params['api_key']
225
+ end
226
+ end
227
+ ```
228
+
229
+ `success`, `failure`, and `authorization_failure` are protected helpers on
230
+ `AuthStrategy`; `failure` maps to 401 (or the login redirect above, for HTML
231
+ requests) and `authorization_failure` to 403.
232
+
233
+ ## Complex Authorization Example
234
+
235
+ ```ruby
236
+ class OrganizationDeleteLogic
237
+ def raise_concerns
238
+ @org = Organization.find(params[:id])
239
+
240
+ # Complex authorization: admin role OR ownership
241
+ has_permission = @context.user_roles.include?('admin') ||
242
+ @org.owner_id == @context.user_id
243
+
244
+ unless has_permission
245
+ raise Otto::Security::AuthorizationError,
246
+ "Requires admin role or organization ownership",
247
+ resource: 'Organization',
248
+ action: 'delete',
249
+ user_id: @context.user_id
250
+ end
251
+ end
252
+ end
253
+ ```
254
+
255
+ ## AuthorizationError Features
256
+
257
+ - Auto-registered during Otto initialization (returns 403)
258
+ - Logged at WARN level (not ERROR)
259
+ - Optional context: `resource`, `action`, `user_id` for debugging
260
+ - Supports structured logging via `to_log_data`
261
+
262
+ ## RouteAuthWrapper Flow
263
+
264
+ When a route has authentication requirements:
265
+
266
+ 1. Looks up strategies from `auth_config[:auth_strategies]`
267
+ 2. Executes `strategy.authenticate(env, requirement)` for each strategy
268
+ 3. On first success:
269
+ - Sets `env['rack.session']` (if provided)
270
+ - Sets `env['otto.strategy_result']`
271
+ - Sets `env['otto.user']` (extracted from result)
272
+ - Checks role requirements (if `role=` specified)
273
+ - Calls wrapped handler
274
+ 4. If all strategies fail: Returns 401/302
275
+ 5. If role check fails: Returns 403
276
+
277
+ ## Compatibility Notes
278
+
279
+ - `enable_authentication!` is a no-op kept for API compatibility
280
+ - AuthenticationMiddleware was removed (architecturally broken - ran before routing)
281
+ - `auth=role:admin` syntax removed in favor of separate `role=admin` option
282
+
283
+ ## Best Practices
284
+
285
+ 1. **Use Layer 1 for broad access control** (admin-only sections)
286
+ 2. **Use Layer 2 for resource-specific authorization** (ownership, relationships)
287
+ 3. **Put fastest strategies first** in multi-strategy auth
288
+ 4. **Include roles in StrategyResult.user** for route-level authorization
289
+ 5. **Use structured logging** for authorization failures
290
+ 6. **Register all strategies before first request** (configuration freezing)