otto 2.0.0.pre2 → 2.0.0.pre3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +0 -2
- data/.github/workflows/claude-code-review.yml +29 -13
- data/CLAUDE.md +537 -0
- data/Gemfile +2 -1
- data/Gemfile.lock +17 -10
- data/benchmark_middleware_wrap.rb +163 -0
- data/changelog.d/20251014_144317_delano_54_thats_a_wrapper.rst +36 -0
- data/changelog.d/20251014_161526_delano_54_thats_a_wrapper.rst +5 -0
- data/docs/.gitignore +1 -0
- data/docs/ipaddr-encoding-quirk.md +34 -0
- data/docs/migrating/v2.0.0-pre2.md +11 -18
- data/examples/authentication_strategies/config.ru +0 -1
- data/lib/otto/core/configuration.rb +89 -39
- data/lib/otto/core/freezable.rb +93 -0
- data/lib/otto/core/middleware_stack.rb +24 -17
- data/lib/otto/core/router.rb +1 -1
- data/lib/otto/core.rb +8 -0
- data/lib/otto/env_keys.rb +8 -4
- data/lib/otto/helpers/request.rb +80 -2
- data/lib/otto/helpers/response.rb +3 -3
- data/lib/otto/helpers.rb +4 -0
- data/lib/otto/locale/config.rb +56 -0
- data/lib/otto/mcp.rb +3 -0
- data/lib/otto/privacy/config.rb +199 -0
- data/lib/otto/privacy/geo_resolver.rb +115 -0
- data/lib/otto/privacy/ip_privacy.rb +175 -0
- data/lib/otto/privacy/redacted_fingerprint.rb +136 -0
- data/lib/otto/privacy.rb +29 -0
- data/lib/otto/route_handlers/base.rb +1 -2
- data/lib/otto/route_handlers/factory.rb +16 -14
- data/lib/otto/route_handlers/logic_class.rb +2 -2
- data/lib/otto/security/authentication/{failure_result.rb → auth_failure.rb} +3 -3
- data/lib/otto/security/authentication/auth_strategy.rb +3 -3
- data/lib/otto/security/authentication/route_auth_wrapper.rb +137 -26
- data/lib/otto/security/authentication/strategies/noauth_strategy.rb +5 -1
- data/lib/otto/security/authentication.rb +3 -4
- data/lib/otto/security/config.rb +51 -7
- data/lib/otto/security/configurator.rb +0 -13
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +211 -0
- data/lib/otto/security.rb +9 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +181 -86
- data/otto.gemspec +3 -0
- metadata +58 -3
- data/lib/otto/security/authentication/authentication_middleware.rb +0 -140
data/Gemfile.lock
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
otto (2.0.0.
|
|
4
|
+
otto (2.0.0.pre3)
|
|
5
|
+
concurrent-ruby (~> 1.3, < 2.0)
|
|
5
6
|
facets (~> 3.1)
|
|
7
|
+
ipaddr (~> 1, < 2.0)
|
|
6
8
|
logger (~> 1, < 2.0)
|
|
7
9
|
loofah (~> 2.20)
|
|
8
10
|
rack (~> 3.1, < 4.0)
|
|
@@ -13,6 +15,7 @@ GEM
|
|
|
13
15
|
remote: https://rubygems.org/
|
|
14
16
|
specs:
|
|
15
17
|
ast (2.4.3)
|
|
18
|
+
benchmark (0.4.1)
|
|
16
19
|
bigdecimal (3.2.3)
|
|
17
20
|
concurrent-ruby (1.3.5)
|
|
18
21
|
crass (1.0.6)
|
|
@@ -21,10 +24,11 @@ GEM
|
|
|
21
24
|
irb (~> 1.10)
|
|
22
25
|
reline (>= 0.3.8)
|
|
23
26
|
diff-lcs (1.6.2)
|
|
24
|
-
erb (5.
|
|
27
|
+
erb (5.1.1)
|
|
25
28
|
facets (3.1.0)
|
|
26
29
|
hana (1.3.7)
|
|
27
30
|
io-console (0.8.1)
|
|
31
|
+
ipaddr (1.2.7)
|
|
28
32
|
irb (1.15.2)
|
|
29
33
|
pp (>= 0.6.0)
|
|
30
34
|
rdoc (>= 4.0.0)
|
|
@@ -41,7 +45,7 @@ GEM
|
|
|
41
45
|
loofah (2.24.1)
|
|
42
46
|
crass (~> 1.0.2)
|
|
43
47
|
nokogiri (>= 1.12.0)
|
|
44
|
-
minitest (5.
|
|
48
|
+
minitest (5.26.0)
|
|
45
49
|
nokogiri (1.18.10-aarch64-linux-gnu)
|
|
46
50
|
racc (~> 1.4)
|
|
47
51
|
nokogiri (1.18.10-aarch64-linux-musl)
|
|
@@ -64,7 +68,7 @@ GEM
|
|
|
64
68
|
racc
|
|
65
69
|
pastel (0.8.0)
|
|
66
70
|
tty-color (~> 0.5)
|
|
67
|
-
pp (0.6.
|
|
71
|
+
pp (0.6.3)
|
|
68
72
|
prettyprint
|
|
69
73
|
prettier_print (1.2.1)
|
|
70
74
|
prettyprint (0.2.0)
|
|
@@ -73,7 +77,7 @@ GEM
|
|
|
73
77
|
date
|
|
74
78
|
stringio
|
|
75
79
|
racc (1.8.1)
|
|
76
|
-
rack (3.2.
|
|
80
|
+
rack (3.2.3)
|
|
77
81
|
rack-attack (6.7.0)
|
|
78
82
|
rack (>= 1.0, < 4)
|
|
79
83
|
rack-parser (0.7.0)
|
|
@@ -85,9 +89,10 @@ GEM
|
|
|
85
89
|
rainbow (3.1.1)
|
|
86
90
|
rbs (3.9.5)
|
|
87
91
|
logger
|
|
88
|
-
rdoc (6.
|
|
92
|
+
rdoc (6.15.0)
|
|
89
93
|
erb
|
|
90
94
|
psych (>= 4.0.0)
|
|
95
|
+
tsort
|
|
91
96
|
regexp_parser (2.11.3)
|
|
92
97
|
reline (0.6.2)
|
|
93
98
|
io-console (~> 0.5)
|
|
@@ -104,7 +109,7 @@ GEM
|
|
|
104
109
|
rspec-mocks (3.13.5)
|
|
105
110
|
diff-lcs (>= 1.2.0, < 2.0)
|
|
106
111
|
rspec-support (~> 3.13.0)
|
|
107
|
-
rspec-support (3.13.
|
|
112
|
+
rspec-support (3.13.6)
|
|
108
113
|
rubocop (1.81.1)
|
|
109
114
|
json (~> 2.3)
|
|
110
115
|
language_server-protocol (~> 3.17.0.2)
|
|
@@ -140,15 +145,16 @@ GEM
|
|
|
140
145
|
stringio (3.1.7)
|
|
141
146
|
syntax_tree (6.3.0)
|
|
142
147
|
prettier_print (>= 1.2.0)
|
|
143
|
-
tryouts (3.6.
|
|
148
|
+
tryouts (3.6.1)
|
|
144
149
|
concurrent-ruby (~> 1.0)
|
|
145
150
|
irb
|
|
146
151
|
minitest (~> 5.0)
|
|
147
152
|
pastel (~> 0.8)
|
|
148
153
|
prism (~> 1.0)
|
|
149
|
-
rspec (
|
|
154
|
+
rspec (>= 3.0, < 5.0)
|
|
150
155
|
tty-cursor (~> 0.7)
|
|
151
156
|
tty-screen (~> 0.8)
|
|
157
|
+
tsort (0.2.0)
|
|
152
158
|
tty-color (0.6.0)
|
|
153
159
|
tty-cursor (0.7.1)
|
|
154
160
|
tty-screen (0.8.2)
|
|
@@ -167,6 +173,7 @@ PLATFORMS
|
|
|
167
173
|
x86_64-linux-musl
|
|
168
174
|
|
|
169
175
|
DEPENDENCIES
|
|
176
|
+
benchmark
|
|
170
177
|
debug
|
|
171
178
|
json_schemer
|
|
172
179
|
otto!
|
|
@@ -181,7 +188,7 @@ DEPENDENCIES
|
|
|
181
188
|
ruby-lsp
|
|
182
189
|
stackprof
|
|
183
190
|
syntax_tree
|
|
184
|
-
tryouts (~> 3.6.
|
|
191
|
+
tryouts (~> 3.6.1)
|
|
185
192
|
|
|
186
193
|
BUNDLED WITH
|
|
187
194
|
2.7.1
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Benchmark to measure real-world Otto performance with actual routes and middleware
|
|
5
|
+
# Usage: ruby benchmark_middleware_wrap.rb
|
|
6
|
+
|
|
7
|
+
require 'bundler/setup'
|
|
8
|
+
require 'benchmark'
|
|
9
|
+
require 'stringio'
|
|
10
|
+
require 'tempfile'
|
|
11
|
+
|
|
12
|
+
require_relative 'lib/otto'
|
|
13
|
+
|
|
14
|
+
REQUEST_COUNT = 50_000
|
|
15
|
+
MIDDLEWARE_COUNT = 40
|
|
16
|
+
|
|
17
|
+
# Create a temporary routes file
|
|
18
|
+
routes_content = <<~ROUTES
|
|
19
|
+
GET / TestApp.index
|
|
20
|
+
GET /users/:id TestApp.show
|
|
21
|
+
POST /users TestApp.create
|
|
22
|
+
GET /health TestApp.health
|
|
23
|
+
ROUTES
|
|
24
|
+
|
|
25
|
+
routes_file = Tempfile.new(['routes', '.txt'])
|
|
26
|
+
routes_file.write(routes_content)
|
|
27
|
+
routes_file.close
|
|
28
|
+
|
|
29
|
+
# Define test application
|
|
30
|
+
class TestApp
|
|
31
|
+
def self.index(_env, _params = {})
|
|
32
|
+
[200, { 'Content-Type' => 'text/html' }, ['Welcome']]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.show(env, params = {})
|
|
36
|
+
user_id = params[:id] || env.dig('otto.params', :id) || '123'
|
|
37
|
+
[200, { 'Content-Type' => 'text/html' }, ["User #{user_id}"]]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.create(_env, _params = {})
|
|
41
|
+
[201, { 'Content-Type' => 'application/json' }, ['{"id": 123}']]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.health(_env, _params = {})
|
|
45
|
+
[200, { 'Content-Type' => 'text/plain' }, ['OK']]
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Create real Rack middleware
|
|
50
|
+
class BenchmarkMiddleware
|
|
51
|
+
def initialize(app, _config = nil)
|
|
52
|
+
@app = app
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def call(env)
|
|
56
|
+
@app.call(env)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Create Otto instance with real configuration
|
|
61
|
+
otto = Otto.new(routes_file.path)
|
|
62
|
+
|
|
63
|
+
# Add real Otto security middleware
|
|
64
|
+
otto.enable_csrf_protection!
|
|
65
|
+
otto.enable_request_validation!
|
|
66
|
+
|
|
67
|
+
# Add custom middleware to reach target count
|
|
68
|
+
current_count = otto.middleware.size
|
|
69
|
+
(MIDDLEWARE_COUNT - current_count).times do
|
|
70
|
+
otto.use(Class.new(BenchmarkMiddleware))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Suppress error logging for benchmark
|
|
74
|
+
Otto.logger.level = Logger::FATAL
|
|
75
|
+
|
|
76
|
+
puts "\n" + ("=" * 70)
|
|
77
|
+
puts "Otto Performance Benchmark"
|
|
78
|
+
puts ("=" * 70)
|
|
79
|
+
puts "Configuration:"
|
|
80
|
+
puts " Routes: #{otto.instance_variable_get(:@route_definitions).size}"
|
|
81
|
+
actual_app = otto.instance_variable_get(:@app)
|
|
82
|
+
puts " Middleware: #{otto.middleware.size} (#{MIDDLEWARE_COUNT} total in stack, app built: #{!actual_app.nil?})"
|
|
83
|
+
puts " Requests: #{REQUEST_COUNT.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse}"
|
|
84
|
+
puts ("=" * 70)
|
|
85
|
+
|
|
86
|
+
# Create realistic Rack environments for different routes
|
|
87
|
+
def make_env(method, path)
|
|
88
|
+
{
|
|
89
|
+
'REQUEST_METHOD' => method,
|
|
90
|
+
'PATH_INFO' => path,
|
|
91
|
+
'QUERY_STRING' => '',
|
|
92
|
+
'SERVER_NAME' => 'example.com',
|
|
93
|
+
'SERVER_PORT' => '80',
|
|
94
|
+
'rack.version' => [1, 3],
|
|
95
|
+
'rack.url_scheme' => 'http',
|
|
96
|
+
'rack.input' => StringIO.new,
|
|
97
|
+
'rack.errors' => StringIO.new,
|
|
98
|
+
'rack.multithread' => false,
|
|
99
|
+
'rack.multiprocess' => true,
|
|
100
|
+
'rack.run_once' => false,
|
|
101
|
+
'REMOTE_ADDR' => '192.168.1.100',
|
|
102
|
+
'HTTP_USER_AGENT' => 'Benchmark/1.0',
|
|
103
|
+
'rack.session' => {}
|
|
104
|
+
}
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Test different routes
|
|
108
|
+
routes = [
|
|
109
|
+
['GET', '/'],
|
|
110
|
+
['GET', '/users/123'],
|
|
111
|
+
['POST', '/users'],
|
|
112
|
+
['GET', '/health']
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
# Warmup
|
|
116
|
+
puts "\nWarming up (1,000 requests)..."
|
|
117
|
+
1_000.times do |i|
|
|
118
|
+
method, path = routes[i % routes.size]
|
|
119
|
+
env = make_env(method, path)
|
|
120
|
+
otto.call(env)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
puts "\n" + ("=" * 70)
|
|
124
|
+
puts "Running benchmark..."
|
|
125
|
+
puts ("=" * 70)
|
|
126
|
+
|
|
127
|
+
# Benchmark
|
|
128
|
+
result = Benchmark.measure do
|
|
129
|
+
REQUEST_COUNT.times do |i|
|
|
130
|
+
method, path = routes[i % routes.size]
|
|
131
|
+
env = make_env(method, path)
|
|
132
|
+
otto.call(env)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
total_time = result.real
|
|
137
|
+
per_request = (total_time / REQUEST_COUNT * 1_000_000).round(2)
|
|
138
|
+
requests_per_sec = (REQUEST_COUNT / total_time).round(0)
|
|
139
|
+
|
|
140
|
+
puts "\nResults:"
|
|
141
|
+
puts ("=" * 70)
|
|
142
|
+
puts " Total time: #{(total_time * 1000).round(2)}ms"
|
|
143
|
+
puts " Time per request: #{per_request}µs"
|
|
144
|
+
puts " Requests/sec: #{requests_per_sec.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse}"
|
|
145
|
+
puts ("=" * 70)
|
|
146
|
+
|
|
147
|
+
# Performance analysis
|
|
148
|
+
puts "\nPerformance Analysis:"
|
|
149
|
+
if per_request < 20
|
|
150
|
+
puts " ✓ Excellent performance (< 20µs per request)"
|
|
151
|
+
elsif per_request < 50
|
|
152
|
+
puts " ✓ Good performance (< 50µs per request)"
|
|
153
|
+
elsif per_request < 100
|
|
154
|
+
puts " ~ Acceptable performance (< 100µs per request)"
|
|
155
|
+
else
|
|
156
|
+
puts " ⚠ May need optimization (#{per_request}µs per request)"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
puts "\nMiddleware overhead: ~#{((per_request - 2.5) / MIDDLEWARE_COUNT).round(3)}µs per middleware"
|
|
160
|
+
puts
|
|
161
|
+
|
|
162
|
+
# Cleanup
|
|
163
|
+
routes_file.unlink
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
Changed
|
|
2
|
+
-------
|
|
3
|
+
|
|
4
|
+
- Authentication now handled by RouteAuthWrapper at handler level instead of middleware
|
|
5
|
+
- RouteAuthWrapper enhanced with session persistence, security headers, strategy caching, and sophisticated pattern matching
|
|
6
|
+
- env['otto.strategy_result'] now GUARANTEED to be present on all routes (authenticated or anonymous)
|
|
7
|
+
- RouteAuthWrapper now wraps all route handlers, not just routes with auth requirements
|
|
8
|
+
|
|
9
|
+
Removed
|
|
10
|
+
-------
|
|
11
|
+
|
|
12
|
+
- Removed AuthenticationMiddleware (architecturally broken - executed before routing)
|
|
13
|
+
- Removed enable_authentication! (no longer needed - RouteAuthWrapper handles auth automatically)
|
|
14
|
+
- Removed defensive nil fallback from LogicClassHandler (no longer needed)
|
|
15
|
+
|
|
16
|
+
Fixed
|
|
17
|
+
-----
|
|
18
|
+
|
|
19
|
+
- Session persistence now works correctly (env['rack.session'] references same object as strategy_result.session)
|
|
20
|
+
- Security headers now included on all authentication failure responses (401/302)
|
|
21
|
+
- Strategy lookups now cached for performance
|
|
22
|
+
- env['otto.strategy_result'] is now guaranteed to be present (anonymous StrategyResult for public routes)
|
|
23
|
+
- Routes without auth requirements now get anonymous StrategyResult with IP metadata
|
|
24
|
+
|
|
25
|
+
Security
|
|
26
|
+
--------
|
|
27
|
+
|
|
28
|
+
- Authentication strategies now execute after routing when route_definition is available
|
|
29
|
+
- Supports exact match, prefix match (role:admin), and fallback patterns for strategies
|
|
30
|
+
|
|
31
|
+
Documentation
|
|
32
|
+
-------------
|
|
33
|
+
|
|
34
|
+
- Updated CLAUDE.md with RouteAuthWrapper architecture overview
|
|
35
|
+
- Updated env_keys.rb to document guaranteed presence of strategy_result
|
|
36
|
+
- Added comprehensive tests for anonymous route handling
|
data/docs/.gitignore
CHANGED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# IPAddr#to_s Encoding Quirk in Ruby 3
|
|
2
|
+
|
|
3
|
+
Ruby's `IPAddr#to_s` returns inconsistent encodings: IPv4 addresses use US-ASCII, IPv6 addresses use UTF-8.
|
|
4
|
+
|
|
5
|
+
## Behavior
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
IPAddr.new('192.168.1.1').to_s.encoding # => #<Encoding:US-ASCII>
|
|
9
|
+
IPAddr.new('::1').to_s.encoding # => #<Encoding:UTF-8>
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Cause
|
|
13
|
+
|
|
14
|
+
Different string construction in IPAddr's `_to_string` method:
|
|
15
|
+
|
|
16
|
+
- **IPv4**: `Array#join('.')` → US-ASCII optimization
|
|
17
|
+
- **IPv6**: `String#%` → UTF-8 default
|
|
18
|
+
|
|
19
|
+
## Impact
|
|
20
|
+
|
|
21
|
+
- Rack expects UTF-8 strings
|
|
22
|
+
- Mixed encodings cause `Encoding::CompatibilityError`
|
|
23
|
+
- String operations fail on encoding mismatches
|
|
24
|
+
|
|
25
|
+
## Solution
|
|
26
|
+
|
|
27
|
+
Use `force_encoding('UTF-8')` instead of `encode('UTF-8')`:
|
|
28
|
+
|
|
29
|
+
- IP addresses contain only ASCII characters
|
|
30
|
+
- ASCII bytes are identical in US-ASCII and UTF-8
|
|
31
|
+
- `force_encoding` changes label only (O(1))
|
|
32
|
+
- `encode` creates new string (O(n))
|
|
33
|
+
|
|
34
|
+
This ensures consistent UTF-8 encoding across all IP strings.
|
|
@@ -27,12 +27,6 @@ if strategy_result.is_a?(Otto::Security::Authentication::StrategyResult)
|
|
|
27
27
|
# handle success
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
-
# Or for middleware - FailureResult indicates failure
|
|
31
|
-
if strategy_result.is_a?(Otto::Security::Authentication::FailureResult)
|
|
32
|
-
# handle failure
|
|
33
|
-
else
|
|
34
|
-
# handle success
|
|
35
|
-
end
|
|
36
30
|
```
|
|
37
31
|
|
|
38
32
|
### 2. New Semantic Distinction
|
|
@@ -116,7 +110,7 @@ session['authenticated_at'] # Timestamp
|
|
|
116
110
|
**Optional session keys:**
|
|
117
111
|
```ruby
|
|
118
112
|
session['email'] # User email
|
|
119
|
-
session['ip_address'] # Client IP
|
|
113
|
+
session['ip_address'] # Client IP (masked by default via IPPrivacyMiddleware)
|
|
120
114
|
session['user_agent'] # Client UA
|
|
121
115
|
session['locale'] # User locale
|
|
122
116
|
```
|
|
@@ -140,7 +134,7 @@ class Controller::Base
|
|
|
140
134
|
session: session,
|
|
141
135
|
user: cust,
|
|
142
136
|
auth_method: 'session', # Hardcoded - loses semantic meaning
|
|
143
|
-
metadata: { ip: req.
|
|
137
|
+
metadata: { ip: req.masked_ip } # Uses masked IP (privacy by default)
|
|
144
138
|
)
|
|
145
139
|
end
|
|
146
140
|
end
|
|
@@ -148,10 +142,10 @@ end
|
|
|
148
142
|
|
|
149
143
|
**Correct approach:**
|
|
150
144
|
```ruby
|
|
151
|
-
# GOOD - Use
|
|
145
|
+
# GOOD - Use RouteAuthWrapper-provided result
|
|
152
146
|
class Controller::Base
|
|
153
147
|
def strategy_result
|
|
154
|
-
req.env['otto.strategy_result'] # Created by
|
|
148
|
+
req.env['otto.strategy_result'] # Created by RouteAuthWrapper
|
|
155
149
|
end
|
|
156
150
|
end
|
|
157
151
|
|
|
@@ -188,7 +182,6 @@ expect(result).to be_failure
|
|
|
188
182
|
|
|
189
183
|
# After
|
|
190
184
|
expect(result).to be_a(Otto::Security::Authentication::StrategyResult)
|
|
191
|
-
expect(result).to be_a(Otto::Security::Authentication::FailureResult)
|
|
192
185
|
```
|
|
193
186
|
|
|
194
187
|
## Architecture Clarifications
|
|
@@ -196,13 +189,13 @@ expect(result).to be_a(Otto::Security::Authentication::FailureResult)
|
|
|
196
189
|
### When StrategyResult is Created
|
|
197
190
|
|
|
198
191
|
1. **Routes WITH `auth=...` requirement:**
|
|
199
|
-
-
|
|
200
|
-
-
|
|
201
|
-
-
|
|
192
|
+
- RouteAuthWrapper executes strategy
|
|
193
|
+
- Always returns `StrategyResult` (success or failure)
|
|
194
|
+
- RouteAuthWrapper returns 401/302 response on `AuthFailure`
|
|
202
195
|
|
|
203
196
|
2. **Routes WITHOUT `auth=...` requirement:**
|
|
204
|
-
-
|
|
205
|
-
-
|
|
197
|
+
- No RouteAuthWrapper wrapping
|
|
198
|
+
- No `StrategyResult` created (routes without auth don't need it)
|
|
206
199
|
|
|
207
200
|
3. **Auth app (Roda) routes:**
|
|
208
201
|
- Manually creates `StrategyResult` for Logic class compatibility
|
|
@@ -213,7 +206,7 @@ expect(result).to be_a(Otto::Security::Authentication::FailureResult)
|
|
|
213
206
|
**Multi-app setup (Auth + Core + API):**
|
|
214
207
|
- **Shared:** Session middleware, Redis session, Logic classes, Customer model
|
|
215
208
|
- **Auth app:** Creates StrategyResult manually, uses Roda routing
|
|
216
|
-
- **Core/API apps:** StrategyResult from
|
|
209
|
+
- **Core/API apps:** StrategyResult from RouteAuthWrapper
|
|
217
210
|
- **Integration:** Pure session-based, no direct code calls between apps
|
|
218
211
|
|
|
219
212
|
## Testing Your Migration
|
|
@@ -339,7 +332,7 @@ middleware.add_with_position(
|
|
|
339
332
|
|
|
340
333
|
Review the comprehensive inline documentation in:
|
|
341
334
|
- `lib/otto/security/authentication/strategy_result.rb` (lines 1-90) - Auth semantics
|
|
342
|
-
- `lib/otto/security/authentication/
|
|
335
|
+
- `lib/otto/security/authentication/route_auth_wrapper.rb` - Auth handler wrapper
|
|
343
336
|
- `lib/otto/env_keys.rb` - Complete env key registry
|
|
344
337
|
|
|
345
338
|
The documentation includes detailed usage patterns, session contracts, and examples for common scenarios.
|
|
@@ -12,7 +12,6 @@ otto = Otto.new('routes')
|
|
|
12
12
|
# Enable security features to demonstrate advanced route parameters
|
|
13
13
|
otto.enable_csrf_protection!
|
|
14
14
|
otto.enable_request_validation!
|
|
15
|
-
otto.enable_authentication!
|
|
16
15
|
|
|
17
16
|
# Load and configure authentication strategies
|
|
18
17
|
AuthenticationSetup.configure(otto)
|
|
@@ -7,52 +7,37 @@ require_relative '../security/validator'
|
|
|
7
7
|
require_relative '../security/authentication'
|
|
8
8
|
require_relative '../security/rate_limiting'
|
|
9
9
|
require_relative '../mcp/server'
|
|
10
|
+
require_relative 'freezable'
|
|
10
11
|
|
|
11
12
|
class Otto
|
|
12
13
|
module Core
|
|
13
14
|
# Configuration module providing locale and application configuration methods
|
|
14
15
|
module Configuration
|
|
16
|
+
include Otto::Core::Freezable
|
|
15
17
|
def configure_locale(opts)
|
|
16
|
-
#
|
|
17
|
-
global_config = self.class.global_config
|
|
18
|
-
@locale_config = nil
|
|
19
|
-
|
|
20
|
-
# Check if we have any locale configuration from any source
|
|
21
|
-
has_global_locale = global_config && (global_config[:available_locales] || global_config[:default_locale])
|
|
18
|
+
# Check if we have any locale configuration
|
|
22
19
|
has_direct_options = opts[:available_locales] || opts[:default_locale]
|
|
23
20
|
has_legacy_config = opts[:locale_config]
|
|
24
21
|
|
|
25
|
-
# Only create locale_config if we have configuration
|
|
26
|
-
return unless
|
|
22
|
+
# Only create locale_config if we have configuration
|
|
23
|
+
return unless has_direct_options || has_legacy_config
|
|
27
24
|
|
|
28
|
-
|
|
25
|
+
# Initialize with direct options
|
|
26
|
+
available_locales = opts[:available_locales]
|
|
27
|
+
default_locale = opts[:default_locale]
|
|
29
28
|
|
|
30
|
-
#
|
|
31
|
-
if
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
if global_config && global_config[:default_locale]
|
|
36
|
-
@locale_config[:default_locale] =
|
|
37
|
-
global_config[:default_locale]
|
|
29
|
+
# Legacy support: Configure locale if provided via locale_config hash
|
|
30
|
+
if opts[:locale_config]
|
|
31
|
+
locale_opts = opts[:locale_config]
|
|
32
|
+
available_locales ||= locale_opts[:available_locales] || locale_opts[:available]
|
|
33
|
+
default_locale ||= locale_opts[:default_locale] || locale_opts[:default]
|
|
38
34
|
end
|
|
39
35
|
|
|
40
|
-
#
|
|
41
|
-
@locale_config
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
return unless opts[:locale_config]
|
|
46
|
-
|
|
47
|
-
locale_opts = opts[:locale_config]
|
|
48
|
-
if locale_opts[:available_locales] || locale_opts[:available]
|
|
49
|
-
@locale_config[:available_locales] =
|
|
50
|
-
locale_opts[:available_locales] || locale_opts[:available]
|
|
51
|
-
end
|
|
52
|
-
return unless locale_opts[:default_locale] || locale_opts[:default]
|
|
53
|
-
|
|
54
|
-
@locale_config[:default_locale] =
|
|
55
|
-
locale_opts[:default_locale] || locale_opts[:default]
|
|
36
|
+
# Create Otto::Locale::Config instance
|
|
37
|
+
@locale_config = Otto::Locale::Config.new(
|
|
38
|
+
available_locales: available_locales,
|
|
39
|
+
default_locale: default_locale
|
|
40
|
+
)
|
|
56
41
|
end
|
|
57
42
|
|
|
58
43
|
def configure_security(opts)
|
|
@@ -86,7 +71,6 @@ class Otto
|
|
|
86
71
|
# Enable authentication middleware if strategies are configured
|
|
87
72
|
return unless opts[:auth_strategies] && !opts[:auth_strategies].empty?
|
|
88
73
|
|
|
89
|
-
enable_authentication!
|
|
90
74
|
end
|
|
91
75
|
|
|
92
76
|
def configure_mcp(opts)
|
|
@@ -115,9 +99,14 @@ class Otto
|
|
|
115
99
|
# default_locale: 'en'
|
|
116
100
|
# )
|
|
117
101
|
def configure(available_locales: nil, default_locale: nil)
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
102
|
+
ensure_not_frozen!
|
|
103
|
+
|
|
104
|
+
# Initialize locale_config if not already set
|
|
105
|
+
@locale_config ||= Otto::Locale::Config.new
|
|
106
|
+
|
|
107
|
+
# Update configuration
|
|
108
|
+
@locale_config.available_locales = available_locales if available_locales
|
|
109
|
+
@locale_config.default_locale = default_locale if default_locale
|
|
121
110
|
end
|
|
122
111
|
|
|
123
112
|
# Configure rate limiting settings.
|
|
@@ -134,6 +123,7 @@ class Otto
|
|
|
134
123
|
# }
|
|
135
124
|
# })
|
|
136
125
|
def configure_rate_limiting(config)
|
|
126
|
+
ensure_not_frozen!
|
|
137
127
|
@security_config.rate_limiting_config.merge!(config)
|
|
138
128
|
end
|
|
139
129
|
|
|
@@ -149,14 +139,74 @@ class Otto
|
|
|
149
139
|
# 'api_key' => Otto::Security::Authentication::Strategies::APIKeyStrategy.new(api_keys: ['secret123'])
|
|
150
140
|
# })
|
|
151
141
|
def configure_auth_strategies(strategies, default_strategy: 'noauth')
|
|
142
|
+
ensure_not_frozen!
|
|
152
143
|
# Update existing @auth_config rather than creating a new one
|
|
153
144
|
@auth_config[:auth_strategies] = strategies
|
|
154
145
|
@auth_config[:default_auth_strategy] = default_strategy
|
|
155
146
|
|
|
156
|
-
enable_authentication! unless strategies.empty?
|
|
157
147
|
end
|
|
158
148
|
|
|
159
|
-
|
|
149
|
+
# Freeze the application configuration to prevent runtime modifications.
|
|
150
|
+
# Called automatically at the end of initialization to ensure immutability.
|
|
151
|
+
#
|
|
152
|
+
# This prevents security-critical configuration from being modified after
|
|
153
|
+
# the application begins handling requests. Uses deep freezing to prevent
|
|
154
|
+
# both direct modification and modification through nested structures.
|
|
155
|
+
#
|
|
156
|
+
# @raise [RuntimeError] if configuration is already frozen
|
|
157
|
+
# @return [self]
|
|
158
|
+
def freeze_configuration!
|
|
159
|
+
if frozen_configuration?
|
|
160
|
+
Otto.logger.debug '[Otto::Configuration] Configuration already frozen, skipping' if Otto.debug
|
|
161
|
+
return self
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
Otto.logger.debug '[Otto::Configuration] Starting configuration freeze process' if Otto.debug
|
|
165
|
+
|
|
166
|
+
# Deep freeze configuration objects with memoization support
|
|
167
|
+
Otto.logger.debug '[Otto::Configuration] Freezing security_config' if Otto.debug
|
|
168
|
+
@security_config.deep_freeze! if @security_config.respond_to?(:deep_freeze!)
|
|
169
|
+
|
|
170
|
+
Otto.logger.debug '[Otto::Configuration] Freezing locale_config' if Otto.debug
|
|
171
|
+
@locale_config.deep_freeze! if @locale_config.respond_to?(:deep_freeze!)
|
|
172
|
+
|
|
173
|
+
Otto.logger.debug '[Otto::Configuration] Freezing middleware stack' if Otto.debug
|
|
174
|
+
@middleware.deep_freeze! if @middleware.respond_to?(:deep_freeze!)
|
|
175
|
+
|
|
176
|
+
# Deep freeze configuration hashes (recursively freezes nested structures)
|
|
177
|
+
Otto.logger.debug '[Otto::Configuration] Freezing auth_config hash' if Otto.debug
|
|
178
|
+
deep_freeze_value(@auth_config) if @auth_config
|
|
179
|
+
|
|
180
|
+
Otto.logger.debug '[Otto::Configuration] Freezing option hash' if Otto.debug
|
|
181
|
+
deep_freeze_value(@option) if @option
|
|
182
|
+
|
|
183
|
+
# Deep freeze route structures (prevent modification of nested hashes/arrays)
|
|
184
|
+
Otto.logger.debug '[Otto::Configuration] Freezing route structures' if Otto.debug
|
|
185
|
+
deep_freeze_value(@routes) if @routes
|
|
186
|
+
deep_freeze_value(@routes_literal) if @routes_literal
|
|
187
|
+
deep_freeze_value(@routes_static) if @routes_static
|
|
188
|
+
deep_freeze_value(@route_definitions) if @route_definitions
|
|
189
|
+
|
|
190
|
+
@configuration_frozen = true
|
|
191
|
+
Otto.logger.info '[Otto::Configuration] Configuration freeze completed successfully'
|
|
192
|
+
|
|
193
|
+
self
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Check if configuration is frozen
|
|
197
|
+
#
|
|
198
|
+
# @return [Boolean] true if configuration is frozen
|
|
199
|
+
def frozen_configuration?
|
|
200
|
+
@configuration_frozen == true
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Ensure configuration is not frozen before allowing mutations
|
|
204
|
+
#
|
|
205
|
+
# @raise [FrozenError] if configuration is frozen
|
|
206
|
+
def ensure_not_frozen!
|
|
207
|
+
raise FrozenError, 'Cannot modify frozen configuration' if frozen_configuration?
|
|
208
|
+
end
|
|
209
|
+
|
|
160
210
|
|
|
161
211
|
def middleware_enabled?(middleware_class)
|
|
162
212
|
# Only check the new middleware stack as the single source of truth
|