otto 2.0.0.pre2 → 2.0.0.pre7

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 (105) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +1 -3
  3. data/.github/workflows/claude-code-review.yml +29 -13
  4. data/.github/workflows/code-smells.yml +146 -0
  5. data/.gitignore +4 -0
  6. data/.pre-commit-config.yaml +2 -2
  7. data/.reek.yml +99 -0
  8. data/CHANGELOG.rst +90 -0
  9. data/CLAUDE.md +116 -45
  10. data/Gemfile +5 -2
  11. data/Gemfile.lock +70 -24
  12. data/README.md +49 -1
  13. data/changelog.d/20251103_235431_delano_86_improve_error_logging.rst +15 -0
  14. data/changelog.d/20251109_025012_claude_fix_backtrace_sanitization.rst +37 -0
  15. data/docs/.gitignore +1 -0
  16. data/docs/ipaddr-encoding-quirk.md +34 -0
  17. data/docs/migrating/v2.0.0-pre2.md +11 -18
  18. data/examples/advanced_routes/README.md +137 -20
  19. data/examples/authentication_strategies/README.md +212 -19
  20. data/examples/authentication_strategies/config.ru +0 -1
  21. data/examples/backtrace_sanitization_demo.rb +86 -0
  22. data/examples/basic/README.md +61 -10
  23. data/examples/error_handler_registration.rb +136 -0
  24. data/examples/logging_improvements.rb +76 -0
  25. data/examples/mcp_demo/README.md +187 -27
  26. data/examples/security_features/README.md +249 -30
  27. data/examples/simple_geo_resolver.rb +107 -0
  28. data/lib/otto/core/configuration.rb +90 -45
  29. data/lib/otto/core/error_handler.rb +138 -8
  30. data/lib/otto/core/file_safety.rb +2 -2
  31. data/lib/otto/core/freezable.rb +93 -0
  32. data/lib/otto/core/middleware_stack.rb +25 -18
  33. data/lib/otto/core/router.rb +62 -9
  34. data/lib/otto/core/uri_generator.rb +2 -2
  35. data/lib/otto/core.rb +10 -0
  36. data/lib/otto/design_system.rb +2 -2
  37. data/lib/otto/env_keys.rb +65 -12
  38. data/lib/otto/helpers/base.rb +2 -2
  39. data/lib/otto/helpers/request.rb +85 -2
  40. data/lib/otto/helpers/response.rb +5 -5
  41. data/lib/otto/helpers/validation.rb +2 -2
  42. data/lib/otto/helpers.rb +6 -0
  43. data/lib/otto/locale/config.rb +56 -0
  44. data/lib/otto/locale/middleware.rb +160 -0
  45. data/lib/otto/locale.rb +10 -0
  46. data/lib/otto/logging_helpers.rb +273 -0
  47. data/lib/otto/mcp/auth/token.rb +2 -2
  48. data/lib/otto/mcp/protocol.rb +2 -2
  49. data/lib/otto/mcp/rate_limiting.rb +2 -2
  50. data/lib/otto/mcp/registry.rb +2 -2
  51. data/lib/otto/mcp/route_parser.rb +2 -2
  52. data/lib/otto/mcp/schema_validation.rb +2 -2
  53. data/lib/otto/mcp/server.rb +2 -2
  54. data/lib/otto/mcp.rb +5 -0
  55. data/lib/otto/privacy/config.rb +201 -0
  56. data/lib/otto/privacy/geo_resolver.rb +285 -0
  57. data/lib/otto/privacy/ip_privacy.rb +177 -0
  58. data/lib/otto/privacy/redacted_fingerprint.rb +146 -0
  59. data/lib/otto/privacy.rb +31 -0
  60. data/lib/otto/response_handlers/auto.rb +2 -0
  61. data/lib/otto/response_handlers/base.rb +2 -0
  62. data/lib/otto/response_handlers/default.rb +2 -0
  63. data/lib/otto/response_handlers/factory.rb +2 -0
  64. data/lib/otto/response_handlers/json.rb +2 -0
  65. data/lib/otto/response_handlers/redirect.rb +2 -0
  66. data/lib/otto/response_handlers/view.rb +2 -0
  67. data/lib/otto/response_handlers.rb +2 -2
  68. data/lib/otto/route.rb +4 -4
  69. data/lib/otto/route_definition.rb +42 -15
  70. data/lib/otto/route_handlers/base.rb +2 -1
  71. data/lib/otto/route_handlers/class_method.rb +18 -25
  72. data/lib/otto/route_handlers/factory.rb +18 -16
  73. data/lib/otto/route_handlers/instance_method.rb +8 -5
  74. data/lib/otto/route_handlers/lambda.rb +8 -20
  75. data/lib/otto/route_handlers/logic_class.rb +25 -8
  76. data/lib/otto/route_handlers.rb +2 -2
  77. data/lib/otto/security/authentication/{failure_result.rb → auth_failure.rb} +5 -5
  78. data/lib/otto/security/authentication/auth_strategy.rb +13 -6
  79. data/lib/otto/security/authentication/route_auth_wrapper.rb +304 -41
  80. data/lib/otto/security/authentication/strategies/api_key_strategy.rb +2 -0
  81. data/lib/otto/security/authentication/strategies/noauth_strategy.rb +7 -1
  82. data/lib/otto/security/authentication/strategies/permission_strategy.rb +2 -0
  83. data/lib/otto/security/authentication/strategies/role_strategy.rb +2 -0
  84. data/lib/otto/security/authentication/strategies/session_strategy.rb +2 -0
  85. data/lib/otto/security/authentication/strategy_result.rb +6 -5
  86. data/lib/otto/security/authentication.rb +5 -6
  87. data/lib/otto/security/authorization_error.rb +73 -0
  88. data/lib/otto/security/config.rb +53 -9
  89. data/lib/otto/security/configurator.rb +17 -15
  90. data/lib/otto/security/csrf.rb +2 -2
  91. data/lib/otto/security/middleware/csrf_middleware.rb +11 -1
  92. data/lib/otto/security/middleware/ip_privacy_middleware.rb +231 -0
  93. data/lib/otto/security/middleware/rate_limit_middleware.rb +2 -0
  94. data/lib/otto/security/middleware/validation_middleware.rb +15 -0
  95. data/lib/otto/security/rate_limiter.rb +2 -2
  96. data/lib/otto/security/rate_limiting.rb +2 -2
  97. data/lib/otto/security/validator.rb +2 -2
  98. data/lib/otto/security.rb +12 -0
  99. data/lib/otto/static.rb +2 -2
  100. data/lib/otto/utils.rb +27 -2
  101. data/lib/otto/version.rb +3 -3
  102. data/lib/otto.rb +344 -89
  103. data/otto.gemspec +9 -2
  104. metadata +72 -8
  105. data/lib/otto/security/authentication/authentication_middleware.rb +0 -140
@@ -1,6 +1,6 @@
1
- # frozen_string_literal: true
2
-
3
1
  # lib/otto/security/authentication/route_auth_wrapper.rb
2
+ #
3
+ # frozen_string_literal: true
4
4
 
5
5
  class Otto
6
6
  module Security
@@ -15,7 +15,7 @@ class Otto
15
15
  # 1. Route matched (route_definition available)
16
16
  # 2. RouteAuthWrapper#call invoked
17
17
  # 3. Execute auth strategy based on route's auth_requirement
18
- # 4. Set env['otto.strategy_result'], env['otto.user']
18
+ # 4. Set env['otto.strategy_result']
19
19
  # 5. If auth fails, return 401 or redirect
20
20
  # 6. If auth succeeds, call wrapped handler
21
21
  #
@@ -25,62 +25,251 @@ class Otto
25
25
  # wrapped.call(env, extra_params)
26
26
  #
27
27
  class RouteAuthWrapper
28
- attr_reader :wrapped_handler, :route_definition, :auth_config
28
+ attr_reader :wrapped_handler, :route_definition, :auth_config, :security_config
29
29
 
30
- def initialize(wrapped_handler, route_definition, auth_config)
30
+ def initialize(wrapped_handler, route_definition, auth_config, security_config = nil)
31
31
  @wrapped_handler = wrapped_handler
32
32
  @route_definition = route_definition
33
33
  @auth_config = auth_config # Hash: { auth_strategies: {}, default_auth_strategy: 'publicly' }
34
+ @security_config = security_config
35
+ @strategy_cache = {} # Cache resolved strategies to avoid repeated lookups
34
36
  end
35
37
 
36
38
  # Execute authentication then call wrapped handler
37
39
  #
40
+ # For routes WITHOUT auth requirement: Sets anonymous StrategyResult
41
+ # For routes WITH auth requirement: Enforces authentication
42
+ # Supports multi-strategy with OR logic: auth=session,apikey,oauth
43
+ #
38
44
  # @param env [Hash] Rack environment
39
45
  # @param extra_params [Hash] Additional parameters
40
46
  # @return [Array] Rack response array
41
47
  def call(env, extra_params = {})
42
- # Execute authentication strategy for this route
43
- auth_requirement = route_definition.auth_requirement
44
- strategy = get_strategy(auth_requirement)
48
+ auth_requirements = route_definition.auth_requirements
49
+
50
+ # Routes without auth requirement get anonymous StrategyResult
51
+ if auth_requirements.empty?
52
+ # Note: env['REMOTE_ADDR'] is masked by IPPrivacyMiddleware by default
53
+ metadata = { ip: env['REMOTE_ADDR'] }
54
+ metadata[:country] = env['otto.privacy.geo_country'] if env['otto.privacy.geo_country']
45
55
 
46
- unless strategy
47
- Otto.logger.error "[RouteAuthWrapper] No strategy found for requirement: #{auth_requirement}"
48
- return unauthorized_response(env, "Authentication strategy not configured")
56
+ result = StrategyResult.anonymous(metadata: metadata, strategy_name: 'anonymous')
57
+ env['otto.strategy_result'] = result
58
+ return wrapped_handler.call(env, extra_params)
49
59
  end
50
60
 
51
- # Execute the strategy
52
- result = strategy.authenticate(env, auth_requirement)
61
+ # Routes WITH auth requirements: Try each strategy in order (first success wins)
62
+
63
+ # Validate all strategies exist before executing any (fail-fast)
64
+ auth_requirements.each do |requirement|
65
+ strategy, _strategy_name = get_strategy(requirement)
66
+ unless strategy
67
+ error_msg = "Authentication strategy not configured: '#{requirement}'"
68
+ Otto.logger.error "[RouteAuthWrapper] #{error_msg}"
69
+ return unauthorized_response(env, error_msg)
70
+ end
71
+ end
72
+
73
+ last_failure = nil
74
+ failed_strategies = []
75
+ total_start_time = Otto::Utils.now_in_μs
76
+
77
+ auth_requirements.each do |requirement|
78
+ strategy, strategy_name = get_strategy(requirement)
79
+
80
+ # Log strategy execution start
81
+ Otto.structured_log(:debug, "Auth strategy executing",
82
+ Otto::LoggingHelpers.request_context(env).merge(
83
+ strategy: strategy_name,
84
+ requirement: requirement,
85
+ strategy_position: auth_requirements.index(requirement) + 1,
86
+ total_strategies: auth_requirements.size
87
+ )
88
+ )
89
+
90
+ # Execute the strategy
91
+ start_time = Otto::Utils.now_in_μs
92
+ result = strategy.authenticate(env, requirement)
93
+ duration = Otto::Utils.now_in_μs - start_time
94
+
95
+ # Inject strategy_name into result (Data.define objects are immutable, use #with for updates)
96
+ if result.is_a?(StrategyResult)
97
+ result = result.with(strategy_name: strategy_name)
98
+ end
99
+
100
+ # Handle authentication success (both authenticated and anonymous results) - return immediately
101
+ if result.is_a?(StrategyResult) && (result.authenticated? || result.anonymous?)
102
+ total_duration = Otto::Utils.now_in_μs - total_start_time
103
+
104
+ # Log authentication success
105
+ Otto.structured_log(:info, "Auth strategy result",
106
+ Otto::LoggingHelpers.request_context(env).merge(
107
+ strategy: strategy_name,
108
+ success: true,
109
+ user_id: result.user_id,
110
+ duration: duration,
111
+ total_duration: total_duration,
112
+ strategies_attempted: failed_strategies.size + 1
113
+ )
114
+ )
115
+
116
+ # Set environment variables for controllers/logic on success
117
+ env['otto.strategy_result'] = result
118
+
119
+ # SESSION PERSISTENCE: This assignment is INTENTIONAL, not a merge operation.
120
+ # We must ensure env['rack.session'] and strategy_result.session reference
121
+ # the SAME object so that:
122
+ # 1. Logic classes write to strategy_result.session
123
+ # 2. Rack's session middleware persists env['rack.session']
124
+ # 3. Changes from (1) are included in (2)
125
+ #
126
+ # Using merge! instead would break this - the objects must be identical.
127
+ env['rack.session'] = result.session if result.is_a?(StrategyResult) && result.session
128
+
129
+ # Layer 1 Authorization: Check role requirements (route-level)
130
+ role_requirements = route_definition.role_requirements
131
+ unless role_requirements.empty?
132
+ user_roles = extract_user_roles(result)
53
133
 
54
- # Set environment variables for controllers/logic
55
- env['otto.strategy_result'] = result
56
- env['otto.user'] = result.user if result.is_a?(StrategyResult)
57
- env['otto.user_context'] = result.user_context if result.is_a?(StrategyResult)
134
+ # OR logic: user needs ANY of the required roles
135
+ unless (user_roles & role_requirements).any?
136
+ Otto.structured_log(:warn, "Role authorization failed",
137
+ Otto::LoggingHelpers.request_context(env).merge(
138
+ required_roles: role_requirements,
139
+ user_roles: user_roles,
140
+ user_id: result.user_id
141
+ )
142
+ )
58
143
 
59
- # Handle authentication failure
60
- if result.is_a?(FailureResult)
61
- return auth_failure_response(env, result)
144
+ return forbidden_response(env,
145
+ "Access denied: requires one of roles: #{role_requirements.join(', ')}")
146
+ end
147
+
148
+ Otto.structured_log(:debug, "Role authorization succeeded",
149
+ Otto::LoggingHelpers.request_context(env).merge(
150
+ required_roles: role_requirements,
151
+ user_roles: user_roles,
152
+ matched_roles: user_roles & role_requirements
153
+ )
154
+ )
155
+ end
156
+
157
+ # Authentication and authorization succeeded - call wrapped handler
158
+ return wrapped_handler.call(env, extra_params)
159
+ end
160
+
161
+ # Handle authentication failure - continue to next strategy
162
+ if result.is_a?(AuthFailure)
163
+ # Log authentication failure
164
+ Otto.structured_log(:info, "Auth strategy result",
165
+ Otto::LoggingHelpers.request_context(env).merge(
166
+ strategy: strategy_name,
167
+ success: false,
168
+ failure_reason: result.failure_reason,
169
+ duration: duration,
170
+ remaining_strategies: auth_requirements.size - auth_requirements.index(requirement) - 1
171
+ )
172
+ )
173
+
174
+ failed_strategies << { strategy: strategy_name, reason: result.failure_reason }
175
+ last_failure = result
176
+ end
177
+ end
178
+
179
+ # All strategies failed - return 401
180
+ total_duration = Otto::Utils.now_in_μs - total_start_time
181
+
182
+ # Log comprehensive failure
183
+ Otto.structured_log(:warn, "All auth strategies failed",
184
+ Otto::LoggingHelpers.request_context(env).merge(
185
+ strategies_attempted: failed_strategies.map { |f| f[:strategy] },
186
+ total_duration: total_duration,
187
+ failure_count: failed_strategies.size
188
+ )
189
+ )
190
+
191
+ # Create anonymous result with comprehensive failure info
192
+ # Note: env['REMOTE_ADDR'] is masked by IPPrivacyMiddleware by default
193
+ metadata = {
194
+ ip: env['REMOTE_ADDR'],
195
+ auth_failure: "All authentication strategies failed",
196
+ attempted_strategies: failed_strategies.map { |f| f[:strategy] },
197
+ failure_reasons: failed_strategies.map { |f| f[:reason] }
198
+ }
199
+ metadata[:country] = env['otto.privacy.geo_country'] if env['otto.privacy.geo_country']
200
+
201
+ # Use 'multi-strategy-failure' only for actual multi-strategy failures
202
+ # For single-strategy failures, use the actual strategy name
203
+ failure_strategy_name = if auth_requirements.size > 1
204
+ 'multi-strategy-failure'
205
+ elsif failed_strategies.any?
206
+ failed_strategies.first[:strategy]
207
+ else
208
+ auth_requirements.first
62
209
  end
63
210
 
64
- # Authentication succeeded - call wrapped handler
65
- wrapped_handler.call(env, extra_params)
211
+ env['otto.strategy_result'] = StrategyResult.anonymous(
212
+ metadata: metadata,
213
+ strategy_name: failure_strategy_name
214
+ )
215
+
216
+ auth_failure_response(env, last_failure || AuthFailure.new(
217
+ failure_reason: "Authentication required",
218
+ auth_method: failure_strategy_name
219
+ ))
66
220
  end
67
221
 
68
222
  private
69
223
 
70
- # Get strategy from auth_config hash
224
+ # Get strategy from auth_config hash with pattern matching
225
+ #
226
+ # Supports:
227
+ # - Exact match: 'authenticated' → looks up auth_config[:auth_strategies]['authenticated']
228
+ # - Prefix match: 'custom:value' → looks up 'custom' strategy
229
+ #
230
+ # Results are cached to avoid repeated lookups for the same requirement.
231
+ #
232
+ # NOTE: Role-based authorization should use route option `role=admin` instead of `auth=role:admin`
233
+ # to properly separate authentication from authorization concerns.
71
234
  #
72
235
  # @param requirement [String] Auth requirement from route
73
- # @return [AuthStrategy, nil] Strategy instance or nil
236
+ # @return [Array<AuthStrategy, String>, Array<nil, nil>] Tuple of [strategy, name] or [nil, nil]
74
237
  def get_strategy(requirement)
75
- return nil unless auth_config && auth_config[:auth_strategies]
238
+ return [nil, nil] unless auth_config && auth_config[:auth_strategies]
239
+
240
+ # Check cache first (cache stores [strategy, name] tuples)
241
+ return @strategy_cache[requirement] if @strategy_cache.key?(requirement)
242
+
243
+ # Try exact match first - this has highest priority
244
+ strategy = auth_config[:auth_strategies][requirement]
245
+ if strategy
246
+ result = [strategy, requirement]
247
+ @strategy_cache[requirement] = result
248
+ return result
249
+ end
250
+
251
+ # For colon-separated requirements like "custom:value", try prefix match
252
+ if requirement.include?(':')
253
+ prefix = requirement.split(':', 2).first
76
254
 
77
- auth_config[:auth_strategies][requirement]
255
+ # Check if we have a strategy registered for the prefix
256
+ prefix_strategy = auth_config[:auth_strategies][prefix]
257
+ if prefix_strategy
258
+ result = [prefix_strategy, prefix]
259
+ @strategy_cache[requirement] = result
260
+ return result
261
+ end
262
+ end
263
+
264
+ # Cache nil results too to avoid repeated failed lookups
265
+ @strategy_cache[requirement] = [nil, nil]
266
+ [nil, nil]
78
267
  end
79
268
 
80
269
  # Generate 401 response for authentication failure
81
270
  #
82
271
  # @param env [Hash] Rack environment
83
- # @param result [FailureResult] Failure result from strategy
272
+ # @param result [AuthFailure] Failure result from strategy
84
273
  # @return [Array] Rack response array
85
274
  def auth_failure_response(env, result)
86
275
  # Check if request wants JSON
@@ -96,7 +285,7 @@ class Otto
96
285
 
97
286
  # Generate JSON 401 response
98
287
  #
99
- # @param result [FailureResult] Failure result
288
+ # @param result [AuthFailure] Failure result
100
289
  # @return [Array] Rack response array
101
290
  def json_auth_error(result)
102
291
  body = {
@@ -105,26 +294,31 @@ class Otto
105
294
  timestamp: Time.now.to_i
106
295
  }.to_json
107
296
 
108
- [
109
- 401,
110
- { 'content-type' => 'application/json' },
111
- [body]
112
- ]
297
+ headers = {
298
+ 'content-type' => 'application/json',
299
+ 'content-length' => body.bytesize.to_s
300
+ }
301
+
302
+ # Add security headers if available
303
+ merge_security_headers!(headers)
304
+
305
+ [401, headers, [body]]
113
306
  end
114
307
 
115
308
  # Generate HTML 401 response or redirect
116
309
  #
117
- # @param result [FailureResult] Failure result
310
+ # @param result [AuthFailure] Failure result
118
311
  # @return [Array] Rack response array
119
312
  def html_auth_error(result)
120
313
  # For HTML requests, redirect to login
121
314
  login_path = auth_config[:login_path] || '/signin'
122
315
 
123
- [
124
- 302,
125
- { 'location' => login_path },
126
- ["Redirecting to #{login_path}"]
127
- ]
316
+ headers = { 'location' => login_path }
317
+
318
+ # Add security headers if available
319
+ merge_security_headers!(headers)
320
+
321
+ [302, headers, ["Redirecting to #{login_path}"]]
128
322
  end
129
323
 
130
324
  # Generate generic unauthorized response
@@ -138,11 +332,80 @@ class Otto
138
332
 
139
333
  if wants_json
140
334
  body = { error: message }.to_json
141
- [401, { 'content-type' => 'application/json' }, [body]]
335
+ headers = {
336
+ 'content-type' => 'application/json',
337
+ 'content-length' => body.bytesize.to_s
338
+ }
339
+ merge_security_headers!(headers)
340
+ [401, headers, [body]]
142
341
  else
143
- [401, { 'content-type' => 'text/plain' }, [message]]
342
+ headers = { 'content-type' => 'text/plain' }
343
+ merge_security_headers!(headers)
344
+ [401, headers, [message]]
144
345
  end
145
346
  end
347
+
348
+ # Generate 403 Forbidden response for role authorization failure
349
+ #
350
+ # @param env [Hash] Rack environment
351
+ # @param message [String] Error message
352
+ # @return [Array] Rack response array
353
+ def forbidden_response(env, message)
354
+ accept_header = env['HTTP_ACCEPT'] || ''
355
+ wants_json = accept_header.include?('application/json')
356
+
357
+ if wants_json
358
+ body = { error: 'Forbidden', message: message }.to_json
359
+ headers = {
360
+ 'content-type' => 'application/json',
361
+ 'content-length' => body.bytesize.to_s
362
+ }
363
+ merge_security_headers!(headers)
364
+ [403, headers, [body]]
365
+ else
366
+ headers = { 'content-type' => 'text/plain' }
367
+ merge_security_headers!(headers)
368
+ [403, headers, [message]]
369
+ end
370
+ end
371
+
372
+ # Extract user roles from authentication result
373
+ #
374
+ # Supports multiple role sources in order of precedence:
375
+ # 1. result.user_roles (Array)
376
+ # 2. result.user[:roles] (Array)
377
+ # 3. result.user['roles'] (Array)
378
+ # 4. result.metadata[:user_roles] (Array)
379
+ #
380
+ # @param result [StrategyResult] Authentication result
381
+ # @return [Array<String>] Array of role strings
382
+ def extract_user_roles(result)
383
+ # Try direct user_roles accessor (e.g., from RoleStrategy)
384
+ return Array(result.user_roles) if result.respond_to?(:user_roles) && result.user_roles
385
+
386
+ # Try user hash/object with roles
387
+ if result.user
388
+ roles = result.user[:roles] || result.user['roles']
389
+ return Array(roles) if roles
390
+ end
391
+
392
+ # Try metadata
393
+ if result.metadata && result.metadata[:user_roles]
394
+ return Array(result.metadata[:user_roles])
395
+ end
396
+
397
+ # No roles found
398
+ []
399
+ end
400
+
401
+ # Merge security headers into response headers
402
+ #
403
+ # @param headers [Hash] Response headers hash to merge into
404
+ def merge_security_headers!(headers)
405
+ return unless security_config
406
+
407
+ headers.merge!(security_config.security_headers)
408
+ end
146
409
  end
147
410
  end
148
411
  end
@@ -1,3 +1,5 @@
1
+ # lib/otto/security/authentication/strategies/api_key_strategy.rb
2
+ #
1
3
  # frozen_string_literal: true
2
4
 
3
5
  require_relative '../auth_strategy'
@@ -1,3 +1,5 @@
1
+ # lib/otto/security/authentication/strategies/noauth_strategy.rb
2
+ #
1
3
  # frozen_string_literal: true
2
4
 
3
5
  require_relative '../auth_strategy'
@@ -10,7 +12,11 @@ class Otto
10
12
  # Public access strategy - always allows access
11
13
  class NoAuthStrategy < AuthStrategy
12
14
  def authenticate(env, _requirement)
13
- Otto::Security::Authentication::StrategyResult.anonymous(metadata: { ip: env['REMOTE_ADDR'] })
15
+ # Note: env['REMOTE_ADDR'] is masked by IPPrivacyMiddleware by default
16
+ metadata = { ip: env['REMOTE_ADDR'] }
17
+ metadata[:country] = env['otto.geo_country'] if env['otto.geo_country']
18
+
19
+ Otto::Security::Authentication::StrategyResult.anonymous(metadata: metadata)
14
20
  end
15
21
  end
16
22
  end
@@ -1,3 +1,5 @@
1
+ # lib/otto/security/authentication/strategies/permission_strategy.rb
2
+ #
1
3
  # frozen_string_literal: true
2
4
 
3
5
  require_relative '../auth_strategy'
@@ -1,3 +1,5 @@
1
+ # lib/otto/security/authentication/strategies/role_strategy.rb
2
+ #
1
3
  # frozen_string_literal: true
2
4
 
3
5
  require_relative '../auth_strategy'
@@ -1,3 +1,5 @@
1
+ # lib/otto/security/authentication/strategies/session_strategy.rb
2
+ #
1
3
  # frozen_string_literal: true
2
4
 
3
5
  require_relative '../auth_strategy'
@@ -1,6 +1,6 @@
1
- # frozen_string_literal: true
2
-
3
1
  # lib/otto/security/authentication/strategy_result.rb
2
+ #
3
+ # frozen_string_literal: true
4
4
 
5
5
  # StrategyResult is an immutable data structure that holds the result of an
6
6
  # authentication strategy. It contains session, user, and metadata needed by
@@ -21,7 +21,7 @@
21
21
  class Otto
22
22
  module Security
23
23
  module Authentication
24
- StrategyResult = Data.define(:session, :user, :auth_method, :metadata) do
24
+ StrategyResult = Data.define(:session, :user, :auth_method, :metadata, :strategy_name) do
25
25
  # =====================================================================
26
26
  # USAGE PATTERNS - READ THIS FIRST
27
27
  # =====================================================================
@@ -110,12 +110,13 @@ class Otto
110
110
  #
111
111
  # @param metadata [Hash] Optional metadata (IP, user agent, etc.)
112
112
  # @return [StrategyResult] Anonymous result with nil user
113
- def self.anonymous(metadata: {})
113
+ def self.anonymous(metadata: {}, strategy_name: 'anonymous')
114
114
  new(
115
115
  session: {},
116
116
  user: nil,
117
117
  auth_method: 'anonymous',
118
- metadata: metadata
118
+ metadata: metadata,
119
+ strategy_name: strategy_name
119
120
  )
120
121
  end
121
122
 
@@ -1,14 +1,14 @@
1
- # frozen_string_literal: true
2
-
3
1
  # lib/otto/security/authentication.rb
4
2
  #
3
+ # frozen_string_literal: true
4
+ #
5
5
  # Index file for Otto authentication module
6
6
  # Requires all authentication-related components for backward compatibility
7
7
 
8
8
  require_relative 'authentication/auth_strategy'
9
9
  require_relative 'authentication/strategy_result'
10
- require_relative 'authentication/failure_result'
11
- require_relative 'authentication/authentication_middleware'
10
+ require_relative 'authentication/auth_failure'
11
+ require_relative 'authentication/route_auth_wrapper'
12
12
 
13
13
  # Load all strategies
14
14
  require_relative 'authentication/strategies/noauth_strategy'
@@ -26,10 +26,9 @@ class Otto
26
26
  RoleStrategy = Authentication::Strategies::RoleStrategy
27
27
  APIKeyStrategy = Authentication::Strategies::APIKeyStrategy
28
28
  PermissionStrategy = Authentication::Strategies::PermissionStrategy
29
- AuthenticationMiddleware = Authentication::AuthenticationMiddleware
30
29
  end
31
30
 
32
31
  # Top-level backward compatibility aliases
33
32
  StrategyResult = Security::Authentication::StrategyResult
34
- FailureResult = Security::Authentication::FailureResult
33
+ AuthFailure = Security::Authentication::AuthFailure
35
34
  end
@@ -0,0 +1,73 @@
1
+ # lib/otto/security/authorization_error.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ class Otto
6
+ module Security
7
+ # Authorization error for resource-level access control failures
8
+ #
9
+ # This exception is designed to be raised from Logic classes when a user
10
+ # attempts to access a resource they don't have permission to access.
11
+ #
12
+ # Otto automatically registers this as a 403 Forbidden error during
13
+ # initialization, so raising this exception will return a 403 response
14
+ # instead of a 500 error.
15
+ #
16
+ # Two-Layer Authorization Pattern:
17
+ # - Layer 1 (Route-level): RouteAuthWrapper checks authentication/basic roles
18
+ # - Layer 2 (Resource-level): Logic classes raise AuthorizationError for ownership/permissions
19
+ #
20
+ # @example Ownership check in Logic class
21
+ # class PostEditLogic
22
+ # def raise_concerns
23
+ # @post = Post.find(params[:id])
24
+ #
25
+ # unless @post.user_id == @context.user_id
26
+ # raise Otto::Security::AuthorizationError, "Cannot edit another user's post"
27
+ # end
28
+ # end
29
+ # end
30
+ #
31
+ # @example Multi-condition authorization
32
+ # class OrganizationDeleteLogic
33
+ # def raise_concerns
34
+ # @org = Organization.find(params[:id])
35
+ #
36
+ # unless @context.user_roles.include?('admin') || @org.owner_id == @context.user_id
37
+ # raise Otto::Security::AuthorizationError,
38
+ # "Requires admin role or organization ownership"
39
+ # end
40
+ # end
41
+ # end
42
+ #
43
+ class AuthorizationError < StandardError
44
+ # Optional additional context for logging/debugging
45
+ attr_reader :resource, :action, :user_id
46
+
47
+ # Initialize authorization error with optional context
48
+ #
49
+ # @param message [String] Human-readable error message
50
+ # @param resource [String, nil] Resource type being accessed (e.g., 'Post', 'Organization')
51
+ # @param action [String, nil] Action being attempted (e.g., 'edit', 'delete')
52
+ # @param user_id [String, Integer, nil] ID of user attempting access
53
+ def initialize(message = 'Access denied', resource: nil, action: nil, user_id: nil)
54
+ super(message)
55
+ @resource = resource
56
+ @action = action
57
+ @user_id = user_id
58
+ end
59
+
60
+ # Generate structured log data for authorization failures
61
+ #
62
+ # @return [Hash] Hash suitable for structured logging
63
+ def to_log_data
64
+ {
65
+ error: message,
66
+ resource: resource,
67
+ action: action,
68
+ user_id: user_id,
69
+ }.compact
70
+ end
71
+ end
72
+ end
73
+ end