otto 2.0.0.pre3 → 2.0.0.pre8

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 (104) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +1 -1
  3. data/.github/workflows/claude-code-review.yml +1 -1
  4. data/.github/workflows/code-smells.yml +143 -0
  5. data/.gitignore +4 -0
  6. data/.pre-commit-config.yaml +2 -2
  7. data/.reek.yml +99 -0
  8. data/CHANGELOG.rst +156 -0
  9. data/CLAUDE.md +74 -540
  10. data/Gemfile +4 -2
  11. data/Gemfile.lock +58 -19
  12. data/README.md +49 -1
  13. data/examples/advanced_routes/README.md +137 -20
  14. data/examples/authentication_strategies/README.md +212 -19
  15. data/examples/backtrace_sanitization_demo.rb +86 -0
  16. data/examples/basic/README.md +61 -10
  17. data/examples/error_handler_registration.rb +136 -0
  18. data/examples/logging_improvements.rb +76 -0
  19. data/examples/mcp_demo/README.md +187 -27
  20. data/examples/security_features/README.md +249 -30
  21. data/examples/simple_geo_resolver.rb +107 -0
  22. data/lib/otto/core/configuration.rb +15 -20
  23. data/lib/otto/core/error_handler.rb +138 -8
  24. data/lib/otto/core/file_safety.rb +2 -2
  25. data/lib/otto/core/freezable.rb +2 -2
  26. data/lib/otto/core/middleware_stack.rb +2 -2
  27. data/lib/otto/core/router.rb +61 -8
  28. data/lib/otto/core/uri_generator.rb +2 -2
  29. data/lib/otto/core.rb +2 -0
  30. data/lib/otto/design_system.rb +2 -2
  31. data/lib/otto/env_keys.rb +61 -12
  32. data/lib/otto/helpers/base.rb +2 -2
  33. data/lib/otto/helpers/request.rb +8 -3
  34. data/lib/otto/helpers/response.rb +2 -2
  35. data/lib/otto/helpers/validation.rb +2 -2
  36. data/lib/otto/helpers.rb +2 -0
  37. data/lib/otto/locale/config.rb +2 -2
  38. data/lib/otto/locale/middleware.rb +160 -0
  39. data/lib/otto/locale.rb +10 -0
  40. data/lib/otto/logging_helpers.rb +273 -0
  41. data/lib/otto/mcp/auth/token.rb +2 -2
  42. data/lib/otto/mcp/protocol.rb +2 -2
  43. data/lib/otto/mcp/rate_limiting.rb +2 -2
  44. data/lib/otto/mcp/registry.rb +2 -2
  45. data/lib/otto/mcp/route_parser.rb +2 -2
  46. data/lib/otto/mcp/schema_validation.rb +2 -2
  47. data/lib/otto/mcp/server.rb +2 -2
  48. data/lib/otto/mcp.rb +2 -0
  49. data/lib/otto/privacy/config.rb +2 -0
  50. data/lib/otto/privacy/geo_resolver.rb +199 -29
  51. data/lib/otto/privacy/ip_privacy.rb +2 -0
  52. data/lib/otto/privacy/redacted_fingerprint.rb +18 -8
  53. data/lib/otto/privacy.rb +2 -0
  54. data/lib/otto/response_handlers/auto.rb +2 -0
  55. data/lib/otto/response_handlers/base.rb +2 -0
  56. data/lib/otto/response_handlers/default.rb +2 -0
  57. data/lib/otto/response_handlers/factory.rb +2 -0
  58. data/lib/otto/response_handlers/json.rb +2 -0
  59. data/lib/otto/response_handlers/redirect.rb +2 -0
  60. data/lib/otto/response_handlers/view.rb +2 -0
  61. data/lib/otto/response_handlers.rb +2 -2
  62. data/lib/otto/route.rb +4 -4
  63. data/lib/otto/route_definition.rb +42 -15
  64. data/lib/otto/route_handlers/base.rb +2 -0
  65. data/lib/otto/route_handlers/class_method.rb +26 -26
  66. data/lib/otto/route_handlers/factory.rb +2 -2
  67. data/lib/otto/route_handlers/instance_method.rb +16 -6
  68. data/lib/otto/route_handlers/lambda.rb +8 -20
  69. data/lib/otto/route_handlers/logic_class.rb +33 -8
  70. data/lib/otto/route_handlers.rb +2 -2
  71. data/lib/otto/security/authentication/auth_failure.rb +2 -2
  72. data/lib/otto/security/authentication/auth_strategy.rb +11 -4
  73. data/lib/otto/security/authentication/route_auth_wrapper/response_builder.rb +123 -0
  74. data/lib/otto/security/authentication/route_auth_wrapper/role_authorization.rb +120 -0
  75. data/lib/otto/security/authentication/route_auth_wrapper/strategy_resolver.rb +69 -0
  76. data/lib/otto/security/authentication/route_auth_wrapper.rb +185 -195
  77. data/lib/otto/security/authentication/strategies/api_key_strategy.rb +2 -0
  78. data/lib/otto/security/authentication/strategies/noauth_strategy.rb +2 -0
  79. data/lib/otto/security/authentication/strategies/permission_strategy.rb +2 -0
  80. data/lib/otto/security/authentication/strategies/role_strategy.rb +2 -0
  81. data/lib/otto/security/authentication/strategies/session_strategy.rb +2 -0
  82. data/lib/otto/security/authentication/strategy_result.rb +6 -5
  83. data/lib/otto/security/authentication.rb +2 -2
  84. data/lib/otto/security/authorization_error.rb +73 -0
  85. data/lib/otto/security/config.rb +2 -2
  86. data/lib/otto/security/configurator.rb +17 -2
  87. data/lib/otto/security/csrf.rb +2 -2
  88. data/lib/otto/security/middleware/csrf_middleware.rb +11 -1
  89. data/lib/otto/security/middleware/ip_privacy_middleware.rb +31 -11
  90. data/lib/otto/security/middleware/rate_limit_middleware.rb +2 -0
  91. data/lib/otto/security/middleware/validation_middleware.rb +15 -0
  92. data/lib/otto/security/rate_limiter.rb +2 -2
  93. data/lib/otto/security/rate_limiting.rb +2 -2
  94. data/lib/otto/security/validator.rb +2 -2
  95. data/lib/otto/security.rb +3 -0
  96. data/lib/otto/static.rb +2 -2
  97. data/lib/otto/utils.rb +27 -2
  98. data/lib/otto/version.rb +3 -3
  99. data/lib/otto.rb +174 -14
  100. data/otto.gemspec +7 -3
  101. metadata +25 -15
  102. data/benchmark_middleware_wrap.rb +0 -163
  103. data/changelog.d/20251014_144317_delano_54_thats_a_wrapper.rst +0 -36
  104. data/changelog.d/20251014_161526_delano_54_thats_a_wrapper.rst +0 -5
@@ -1,28 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # lib/otto/security/authentication/route_auth_wrapper.rb
3
+ require_relative 'route_auth_wrapper/strategy_resolver'
4
+ require_relative 'route_auth_wrapper/response_builder'
5
+ require_relative 'route_auth_wrapper/role_authorization'
4
6
 
5
7
  class Otto
6
8
  module Security
7
9
  module Authentication
8
- # Wraps route handlers to enforce authentication requirements
10
+ # Wraps route handlers with authentication and authorization
9
11
  #
10
- # This wrapper executes authentication strategies AFTER routing but BEFORE
11
- # route handler execution. This solves the architectural issue where
12
- # middleware-based authentication runs before routing (so can't access route info).
12
+ # This is the main orchestrator that:
13
+ # - Sets anonymous StrategyResult for unauthenticated routes
14
+ # - Enforces authentication for protected routes
15
+ # - Supports multi-strategy with OR logic (first success wins)
16
+ # - Performs Layer 1 (route-level) role authorization
13
17
  #
14
- # Flow:
15
- # 1. Route matched (route_definition available)
16
- # 2. RouteAuthWrapper#call invoked
17
- # 3. Execute auth strategy based on route's auth_requirement
18
- # 4. Set env['otto.strategy_result'], env['otto.user']
19
- # 5. If auth fails, return 401 or redirect
20
- # 6. If auth succeeds, call wrapped handler
18
+ # @example Basic usage
19
+ # wrapper = RouteAuthWrapper.new(handler, route_def, auth_config)
20
+ # response = wrapper.call(env)
21
21
  #
22
- # @example
23
- # handler = InstanceMethodHandler.new(route_def, otto)
24
- # wrapped = RouteAuthWrapper.new(handler, route_def, auth_config)
25
- # wrapped.call(env, extra_params)
22
+ # @see RouteAuthWrapper::StrategyResolver for strategy lookup
23
+ # @see RouteAuthWrapper::ResponseBuilder for error responses
24
+ # @see RouteAuthWrapper::RoleAuthorization for role checking
26
25
  #
27
26
  class RouteAuthWrapper
28
27
  attr_reader :wrapped_handler, :route_definition, :auth_config, :security_config
@@ -30,229 +29,220 @@ class Otto
30
29
  def initialize(wrapped_handler, route_definition, auth_config, security_config = nil)
31
30
  @wrapped_handler = wrapped_handler
32
31
  @route_definition = route_definition
33
- @auth_config = auth_config # Hash: { auth_strategies: {}, default_auth_strategy: 'publicly' }
32
+ @auth_config = auth_config
34
33
  @security_config = security_config
35
- @strategy_cache = {} # Cache resolved strategies to avoid repeated lookups
34
+
35
+ # Initialize extracted components
36
+ @strategy_resolver = RouteAuthWrapperComponents::StrategyResolver.new(auth_config)
37
+ @response_builder = RouteAuthWrapperComponents::ResponseBuilder.new(route_definition, auth_config, security_config)
38
+ @role_authorizer = RouteAuthWrapperComponents::RoleAuthorization.new(route_definition)
36
39
  end
37
40
 
38
41
  # Execute authentication then call wrapped handler
39
42
  #
40
- # For routes WITHOUT auth requirement: Sets anonymous StrategyResult
41
- # For routes WITH auth requirement: Enforces authentication
42
- #
43
43
  # @param env [Hash] Rack environment
44
44
  # @param extra_params [Hash] Additional parameters
45
45
  # @return [Array] Rack response array
46
46
  def call(env, extra_params = {})
47
- auth_requirement = route_definition.auth_requirement
47
+ auth_requirements = route_definition.auth_requirements
48
48
 
49
49
  # Routes without auth requirement get anonymous StrategyResult
50
- unless auth_requirement
51
- # Note: env['REMOTE_ADDR'] is masked by IPPrivacyMiddleware by default
52
- metadata = { ip: env['REMOTE_ADDR'] }
53
- metadata[:country] = env['otto.geo_country'] if env['otto.geo_country']
54
-
55
- result = StrategyResult.anonymous(metadata: metadata)
56
- env['otto.strategy_result'] = result
57
- env['otto.user'] = nil
58
- env['otto.user_context'] = result.user_context
59
- return wrapped_handler.call(env, extra_params)
60
- end
50
+ return handle_anonymous_route(env, extra_params) if auth_requirements.empty?
61
51
 
62
- # Routes WITH auth requirement: Execute authentication strategy
63
- strategy = get_strategy(auth_requirement)
52
+ # Validate all strategies exist before executing any (fail-fast)
53
+ validation_error = validate_strategies(auth_requirements, env)
54
+ return validation_error if validation_error
64
55
 
65
- unless strategy
66
- Otto.logger.error "[RouteAuthWrapper] No strategy found for requirement: #{auth_requirement}"
67
- return unauthorized_response(env, "Authentication strategy not configured")
68
- end
56
+ # Try each strategy in order (first success wins)
57
+ authenticate_and_authorize(env, extra_params, auth_requirements)
58
+ end
69
59
 
70
- # Execute the strategy
71
- result = strategy.authenticate(env, auth_requirement)
72
-
73
- # Handle authentication failure
74
- if result.is_a?(AuthFailure)
75
- # Create anonymous result with failure info for logging/auditing
76
- # Note: env['REMOTE_ADDR'] is masked by IPPrivacyMiddleware by default
77
- metadata = {
78
- ip: env['REMOTE_ADDR'],
79
- auth_failure: result.failure_reason,
80
- attempted_strategy: auth_requirement
81
- }
82
- metadata[:country] = env['otto.geo_country'] if env['otto.geo_country']
83
-
84
- env['otto.strategy_result'] = StrategyResult.anonymous(metadata: metadata)
85
- env['otto.user'] = nil
86
- env['otto.user_context'] = {}
87
- return auth_failure_response(env, result)
88
- end
60
+ private
89
61
 
90
- # Set environment variables for controllers/logic on success
62
+ # Handle routes without authentication requirements
63
+ def handle_anonymous_route(env, extra_params)
64
+ metadata = build_anonymous_metadata(env)
65
+ result = StrategyResult.anonymous(metadata: metadata, strategy_name: 'anonymous')
91
66
  env['otto.strategy_result'] = result
92
- env['otto.user'] = result.user
93
- env['otto.user_context'] = result.user_context
94
-
95
- # SESSION PERSISTENCE: This assignment is INTENTIONAL, not a merge operation.
96
- # We must ensure env['rack.session'] and strategy_result.session reference
97
- # the SAME object so that:
98
- # 1. Logic classes write to strategy_result.session
99
- # 2. Rack's session middleware persists env['rack.session']
100
- # 3. Changes from (1) are included in (2)
101
- #
102
- # Using merge! instead would break this - the objects must be identical.
103
- env['rack.session'] = result.session if result.is_a?(StrategyResult) && result.session
104
-
105
- # Authentication succeeded - call wrapped handler
106
67
  wrapped_handler.call(env, extra_params)
107
68
  end
108
69
 
109
- private
110
-
111
- # Get strategy from auth_config hash with sophisticated pattern matching
112
- #
113
- # Supports:
114
- # - Exact match: 'authenticated' → looks up auth_config[:auth_strategies]['authenticated']
115
- # - Prefix match: 'role:admin' → looks up 'role' strategy
116
- # - Fallback: 'role:*' → creates default RoleStrategy
117
- # - Fallback: 'permission:*' → creates default PermissionStrategy
118
- #
119
- # Results are cached to avoid repeated lookups for the same requirement.
70
+ # Validate all strategies exist before executing
120
71
  #
121
- # @param requirement [String] Auth requirement from route
122
- # @return [AuthStrategy, nil] Strategy instance or nil
123
- def get_strategy(requirement)
124
- return nil unless auth_config && auth_config[:auth_strategies]
125
-
126
- # Check cache first
127
- return @strategy_cache[requirement] if @strategy_cache.key?(requirement)
128
-
129
- # Try exact match first - this has highest priority
130
- strategy = auth_config[:auth_strategies][requirement]
131
- if strategy
132
- @strategy_cache[requirement] = strategy
133
- return strategy
72
+ # @return [Array, nil] Error response if validation fails, nil otherwise
73
+ def validate_strategies(auth_requirements, env)
74
+ auth_requirements.each do |requirement|
75
+ strategy, _name = @strategy_resolver.resolve(requirement)
76
+ next if strategy
77
+
78
+ error_msg = "Authentication strategy not configured: '#{requirement}'"
79
+ Otto.logger.error "[RouteAuthWrapper] #{error_msg}"
80
+ return @response_builder.unauthorized(env, error_msg)
134
81
  end
82
+ nil
83
+ end
135
84
 
136
- # For colon-separated requirements like "role:admin", try prefix match
137
- if requirement.include?(':')
138
- prefix = requirement.split(':', 2).first
85
+ # Main authentication and authorization flow
86
+ def authenticate_and_authorize(env, extra_params, auth_requirements)
87
+ failed_strategies = []
88
+ total_start_time = Otto::Utils.now_in_μs
139
89
 
140
- # Check if we have a strategy registered for the prefix
141
- prefix_strategy = auth_config[:auth_strategies][prefix]
142
- if prefix_strategy
143
- @strategy_cache[requirement] = prefix_strategy
144
- return prefix_strategy
145
- end
90
+ auth_requirements.each do |requirement|
91
+ strategy, strategy_name = @strategy_resolver.resolve(requirement)
92
+
93
+ log_strategy_start(env, strategy_name, requirement, auth_requirements)
146
94
 
147
- # Try fallback patterns for role: and permission: requirements
148
- if requirement.start_with?('role:')
149
- # Cache the fallback strategy under 'role:' key to create it only once
150
- strategy = @strategy_cache['role:'] ||= begin
151
- auth_config[:auth_strategies]['role'] || Strategies::RoleStrategy.new([])
152
- end
153
- @strategy_cache[requirement] = strategy
154
- return strategy
155
- elsif requirement.start_with?('permission:')
156
- # Cache the fallback strategy under 'permission:' key
157
- strategy = @strategy_cache['permission:'] ||= begin
158
- auth_config[:auth_strategies]['permission'] || Strategies::PermissionStrategy.new([])
159
- end
160
- @strategy_cache[requirement] = strategy
161
- return strategy
95
+ # Execute the strategy
96
+ start_time = Otto::Utils.now_in_μs
97
+ result = strategy.authenticate(env, requirement)
98
+ duration = Otto::Utils.now_in_μs - start_time
99
+
100
+ # Inject strategy_name into result
101
+ result = result.with(strategy_name: strategy_name) if result.is_a?(StrategyResult)
102
+
103
+ # Handle authentication success
104
+ if result.is_a?(StrategyResult) && (result.authenticated? || result.anonymous?)
105
+ return handle_auth_success(env, extra_params, result, strategy_name,
106
+ duration, total_start_time, failed_strategies)
162
107
  end
108
+
109
+ # Handle authentication failure - continue to next strategy
110
+ next unless result.is_a?(AuthFailure)
111
+
112
+ log_strategy_failure(env, strategy_name, result, duration, auth_requirements, requirement)
113
+ failed_strategies << { strategy: strategy_name, reason: result.failure_reason }
163
114
  end
164
115
 
165
- # Cache nil results too to avoid repeated failed lookups
166
- @strategy_cache[requirement] = nil
167
- nil
116
+ # All strategies failed
117
+ handle_all_strategies_failed(env, auth_requirements, failed_strategies, total_start_time)
168
118
  end
169
119
 
170
- # Generate 401 response for authentication failure
171
- #
172
- # @param env [Hash] Rack environment
173
- # @param result [AuthFailure] Failure result from strategy
174
- # @return [Array] Rack response array
175
- def auth_failure_response(env, result)
176
- # Check if request wants JSON
177
- accept_header = env['HTTP_ACCEPT'] || ''
178
- wants_json = accept_header.include?('application/json')
120
+ # Handle successful authentication
121
+ def handle_auth_success(env, extra_params, result, strategy_name, duration, total_start_time, failed_strategies)
122
+ total_duration = Otto::Utils.now_in_μs - total_start_time
179
123
 
180
- if wants_json
181
- json_auth_error(result)
182
- else
183
- html_auth_error(result)
184
- end
185
- end
124
+ log_auth_success(env, strategy_name, result, duration, total_duration, failed_strategies)
186
125
 
187
- # Generate JSON 401 response
188
- #
189
- # @param result [AuthFailure] Failure result
190
- # @return [Array] Rack response array
191
- def json_auth_error(result)
192
- body = {
193
- error: 'Authentication Required',
194
- message: result.failure_reason || 'Not authenticated',
195
- timestamp: Time.now.to_i
196
- }.to_json
197
-
198
- headers = {
199
- 'content-type' => 'application/json',
200
- 'content-length' => body.bytesize.to_s
201
- }
126
+ # Set environment variables for controllers/logic
127
+ env['otto.strategy_result'] = result
202
128
 
203
- # Add security headers if available
204
- merge_security_headers!(headers)
129
+ # SESSION PERSISTENCE: Ensure env['rack.session'] and strategy_result.session
130
+ # reference the SAME object for proper session persistence
131
+ env['rack.session'] = result.session if result.is_a?(StrategyResult) && result.session
205
132
 
206
- [401, headers, [body]]
207
- end
133
+ # Layer 1 Authorization: Check role requirements
134
+ auth_check = @role_authorizer.check(result, env)
135
+ unless auth_check == true
136
+ return @response_builder.forbidden(env,
137
+ "Access denied: requires one of roles: #{auth_check[:required].join(', ')}")
138
+ end
208
139
 
209
- # Generate HTML 401 response or redirect
210
- #
211
- # @param result [AuthFailure] Failure result
212
- # @return [Array] Rack response array
213
- def html_auth_error(result)
214
- # For HTML requests, redirect to login
215
- login_path = auth_config[:login_path] || '/signin'
140
+ # Authentication and authorization succeeded
141
+ wrapped_handler.call(env, extra_params)
142
+ end
216
143
 
217
- headers = { 'location' => login_path }
144
+ # Handle case when all authentication strategies fail
145
+ def handle_all_strategies_failed(env, auth_requirements, failed_strategies, total_start_time)
146
+ total_duration = Otto::Utils.now_in_μs - total_start_time
147
+
148
+ log_all_failed(env, failed_strategies, total_duration)
149
+
150
+ # Create anonymous result with failure info
151
+ metadata = build_failure_metadata(env, failed_strategies)
152
+ failure_strategy_name = determine_failure_strategy_name(auth_requirements, failed_strategies)
153
+
154
+ env['otto.strategy_result'] = StrategyResult.anonymous(
155
+ metadata: metadata,
156
+ strategy_name: failure_strategy_name
157
+ )
158
+
159
+ last_failure = if failed_strategies.any?
160
+ AuthFailure.new(
161
+ failure_reason: failed_strategies.last[:reason],
162
+ auth_method: failed_strategies.last[:strategy]
163
+ )
164
+ else
165
+ AuthFailure.new(
166
+ failure_reason: 'Authentication required',
167
+ auth_method: auth_requirements.first
168
+ )
169
+ end
170
+
171
+ @response_builder.auth_failure(env, last_failure)
172
+ end
218
173
 
219
- # Add security headers if available
220
- merge_security_headers!(headers)
174
+ # Build metadata for anonymous routes
175
+ def build_anonymous_metadata(env)
176
+ metadata = { ip: env['REMOTE_ADDR'] }
177
+ metadata[:country] = env['otto.privacy.geo_country'] if env['otto.privacy.geo_country']
178
+ metadata
179
+ end
221
180
 
222
- [302, headers, ["Redirecting to #{login_path}"]]
181
+ # Build metadata for failed authentication
182
+ def build_failure_metadata(env, failed_strategies)
183
+ metadata = {
184
+ ip: env['REMOTE_ADDR'],
185
+ auth_failure: 'All authentication strategies failed',
186
+ attempted_strategies: failed_strategies.map { |f| f[:strategy] },
187
+ failure_reasons: failed_strategies.map { |f| f[:reason] },
188
+ }
189
+ metadata[:country] = env['otto.privacy.geo_country'] if env['otto.privacy.geo_country']
190
+ metadata
223
191
  end
224
192
 
225
- # Generate generic unauthorized response
226
- #
227
- # @param env [Hash] Rack environment
228
- # @param message [String] Error message
229
- # @return [Array] Rack response array
230
- def unauthorized_response(env, message)
231
- accept_header = env['HTTP_ACCEPT'] || ''
232
- wants_json = accept_header.include?('application/json')
233
-
234
- if wants_json
235
- body = { error: message }.to_json
236
- headers = {
237
- 'content-type' => 'application/json',
238
- 'content-length' => body.bytesize.to_s
239
- }
240
- merge_security_headers!(headers)
241
- [401, headers, [body]]
193
+ # Determine strategy name for failure response
194
+ def determine_failure_strategy_name(auth_requirements, failed_strategies)
195
+ if auth_requirements.size > 1
196
+ 'multi-strategy-failure'
197
+ elsif failed_strategies.any?
198
+ failed_strategies.first[:strategy]
242
199
  else
243
- headers = { 'content-type' => 'text/plain' }
244
- merge_security_headers!(headers)
245
- [401, headers, [message]]
200
+ auth_requirements.first
246
201
  end
247
202
  end
248
203
 
249
- # Merge security headers into response headers
250
- #
251
- # @param headers [Hash] Response headers hash to merge into
252
- def merge_security_headers!(headers)
253
- return unless security_config
204
+ # Logging helpers
205
+
206
+ def log_strategy_start(env, strategy_name, requirement, auth_requirements)
207
+ Otto.structured_log(:debug, 'Auth strategy executing',
208
+ Otto::LoggingHelpers.request_context(env).merge(
209
+ strategy: strategy_name,
210
+ requirement: requirement,
211
+ strategy_position: auth_requirements.index(requirement) + 1,
212
+ total_strategies: auth_requirements.size
213
+ ))
214
+ end
215
+
216
+ def log_auth_success(env, strategy_name, result, duration, total_duration, failed_strategies)
217
+ Otto.structured_log(:info, 'Auth strategy result',
218
+ Otto::LoggingHelpers.request_context(env).merge(
219
+ strategy: strategy_name,
220
+ success: true,
221
+ user_id: result.user_id,
222
+ duration: duration,
223
+ total_duration: total_duration,
224
+ strategies_attempted: failed_strategies.size + 1
225
+ ))
226
+ end
227
+
228
+ def log_strategy_failure(env, strategy_name, result, duration, auth_requirements, requirement)
229
+ Otto.structured_log(:info, 'Auth strategy result',
230
+ Otto::LoggingHelpers.request_context(env).merge(
231
+ strategy: strategy_name,
232
+ success: false,
233
+ failure_reason: result.failure_reason,
234
+ duration: duration,
235
+ remaining_strategies: auth_requirements.size - auth_requirements.index(requirement) - 1
236
+ ))
237
+ end
254
238
 
255
- headers.merge!(security_config.security_headers)
239
+ def log_all_failed(env, failed_strategies, total_duration)
240
+ Otto.structured_log(:warn, 'All auth strategies failed',
241
+ Otto::LoggingHelpers.request_context(env).merge(
242
+ strategies_attempted: failed_strategies.map { |f| f[:strategy] },
243
+ total_duration: total_duration,
244
+ failure_count: failed_strategies.size
245
+ ))
256
246
  end
257
247
  end
258
248
  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'
@@ -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,7 +1,7 @@
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
 
@@ -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
@@ -1,6 +1,6 @@
1
- # frozen_string_literal: true
2
-
3
1
  # lib/otto/security/config.rb
2
+ #
3
+ # frozen_string_literal: true
4
4
 
5
5
  require 'securerandom'
6
6
  require 'digest'
@@ -1,6 +1,6 @@
1
- # frozen_string_literal: true
2
-
3
1
  # lib/otto/security/configurator.rb
2
+ #
3
+ # frozen_string_literal: true
4
4
 
5
5
  require_relative 'middleware/csrf_middleware'
6
6
  require_relative 'middleware/validation_middleware'
@@ -170,9 +170,24 @@ class Otto
170
170
 
171
171
  # Add a single authentication strategy
172
172
  #
173
+ # Part of the Security::Configurator facade for consolidated configuration.
174
+ # This delegates to the same storage as Otto#add_auth_strategy, allowing
175
+ # authentication to be configured alongside other security features.
176
+ #
177
+ # Prefer using Otto#add_auth_strategy directly for simpler cases, or use this
178
+ # when configuring multiple security features together via the security facade.
179
+ #
173
180
  # @param name [String] Strategy name
174
181
  # @param strategy [Otto::Security::Authentication::AuthStrategy] Strategy instance
182
+ # @example
183
+ # otto.security.add_auth_strategy('session', SessionStrategy.new)
184
+ # @raise [ArgumentError] if strategy name already registered
175
185
  def add_auth_strategy(name, strategy)
186
+ # Strict mode: Detect strategy name collisions
187
+ if @auth_config[:auth_strategies].key?(name)
188
+ raise ArgumentError, "Authentication strategy '#{name}' is already registered"
189
+ end
190
+
176
191
  @auth_config[:auth_strategies][name] = strategy
177
192
  end
178
193
 
@@ -1,7 +1,7 @@
1
- # frozen_string_literal: true
2
-
3
1
  # lib/otto/security/csrf.rb
4
2
  #
3
+ # frozen_string_literal: true
4
+ #
5
5
  # Index file for CSRF protection components
6
6
  # Provides backward compatibility for existing CSRF usage
7
7