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.
- checksums.yaml +4 -4
- data/.github/dependabot.yml +5 -0
- data/.github/workflows/ci.yml +11 -8
- data/.github/workflows/claude-code-review.yml +38 -13
- data/.github/workflows/claude.yml +10 -8
- data/.github/workflows/code-smells.yml +5 -5
- data/.github/workflows/release-gem.yml +2 -2
- data/.github/workflows/ruby-lint.yml +3 -3
- data/.github/workflows/yardoc.yml +5 -5
- data/.gitignore +1 -5
- data/.rubocop_todo.yml +7 -5
- data/AGENTS.md +22 -1
- data/CHANGELOG.rst +203 -0
- data/Gemfile +3 -3
- data/Gemfile.lock +11 -15
- data/README.md +75 -37
- data/docs/README.md +166 -0
- data/docs/adr/README.md +16 -0
- data/docs/adr/adr-001-route-authentication-at-handler-boundary.md +38 -0
- data/docs/adr/adr-002-multi-strategy-authentication-and-authorization.md +50 -0
- data/docs/adr/adr-003-caddy-tls-route-based-integration.md +48 -0
- data/docs/adr/adr-004-separate-compatibility-from-security-maintenance.md +58 -0
- data/docs/guides/authentication.md +377 -0
- data/docs/guides/caddy-tls.md +205 -0
- data/docs/guides/configuration_freezing.md +146 -0
- data/docs/guides/enrichment.md +161 -0
- data/docs/guides/forwarded-authority.md +249 -0
- data/docs/guides/geo-country.md +168 -0
- data/docs/guides/ip_privacy.md +39 -0
- data/docs/guides/ipaddr-encoding-quirk.md +56 -0
- data/docs/guides/mcp.md +282 -0
- data/docs/guides/privacy.md +193 -0
- data/docs/guides/routing.md +181 -0
- data/docs/guides/structured_logging.md +281 -0
- data/docs/guides/testing-guide.md +391 -0
- data/docs/maintainers/github-actions.md +41 -0
- data/docs/maintainers/investigations/.gitignore +2 -0
- data/docs/migrating/v2.0.0.md +337 -0
- data/docs/reference/authentication.md +290 -0
- data/docs/reference/route-syntax.md +181 -0
- data/docs/reference/runtime-and-dependency-security.md +86 -0
- data/examples/advanced_routes/README.md +43 -57
- data/examples/authentication_strategies/README.md +37 -196
- data/examples/basic/README.md +24 -39
- data/examples/basic/config.ru +0 -1
- data/examples/caddy_tls_demo/README.md +8 -2
- data/examples/lambda_handlers/README.md +11 -2
- data/examples/mcp_demo/README.md +75 -161
- data/examples/mcp_demo/config.ru +1 -0
- data/examples/security_features/README.md +36 -234
- data/lib/otto/caddy_tls/localhost_guard.rb +22 -24
- data/lib/otto/core/configuration.rb +36 -22
- data/lib/otto/core/file_safety.rb +89 -31
- data/lib/otto/core/middleware_stack.rb +36 -36
- data/lib/otto/core/router.rb +62 -19
- data/lib/otto/env_keys.rb +20 -2
- data/lib/otto/mcp/auth/token.rb +10 -4
- data/lib/otto/mcp/core.rb +23 -5
- data/lib/otto/mcp/endpoint.rb +41 -0
- data/lib/otto/mcp/errors.rb +15 -0
- data/lib/otto/mcp/options.rb +292 -0
- data/lib/otto/mcp/protocol.rb +52 -22
- data/lib/otto/mcp/rate_limiting.rb +175 -97
- data/lib/otto/mcp/registry.rb +14 -14
- data/lib/otto/mcp/schema_validation.rb +20 -12
- data/lib/otto/mcp/server.rb +131 -32
- data/lib/otto/optional_dependency.rb +57 -0
- data/lib/otto/privacy/config.rb +10 -8
- data/lib/otto/security/authentication/route_auth_wrapper/role_authorization.rb +22 -5
- data/lib/otto/security/authentication/strategies/api_key_strategy.rb +181 -18
- data/lib/otto/security/authentication/strategies/permission_strategy.rb +1 -0
- data/lib/otto/security/authentication/strategies/role_strategy.rb +1 -0
- data/lib/otto/security/authentication/strategies/session_strategy.rb +1 -0
- data/lib/otto/security/authentication/strategy_result.rb +58 -28
- data/lib/otto/security/config.rb +328 -14
- data/lib/otto/security/configurator.rb +36 -6
- data/lib/otto/security/core.rb +19 -1
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +105 -4
- data/lib/otto/security/middleware/rate_limit_middleware.rb +1 -6
- data/lib/otto/security/rate_limiter.rb +81 -46
- data/lib/otto/utils.rb +50 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +9 -11
- data/otto.gemspec +0 -2
- metadata +31 -41
- data/docs/.gitignore +0 -10
- data/docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md +0 -1105
- data/docs/1108-STREAMING_SUPPORT_SUMMARY.md +0 -376
- data/docs/enrichment.md +0 -128
- data/docs/geo-country.md +0 -181
- data/docs/ipaddr-encoding-quirk.md +0 -34
- data/docs/migrating/v2.0.0-pre1.md +0 -276
- data/docs/migrating/v2.0.0-pre2.md +0 -338
- data/docs/modern-authentication-authorization-landscape.md +0 -558
- data/docs/multi-strategy-authentication-design.md +0 -1401
- 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)
|