otto 2.0.0.pre3 → 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 (103) 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 +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 +74 -540
  10. data/Gemfile +4 -2
  11. data/Gemfile.lock +58 -19
  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/examples/advanced_routes/README.md +137 -20
  16. data/examples/authentication_strategies/README.md +212 -19
  17. data/examples/backtrace_sanitization_demo.rb +86 -0
  18. data/examples/basic/README.md +61 -10
  19. data/examples/error_handler_registration.rb +136 -0
  20. data/examples/logging_improvements.rb +76 -0
  21. data/examples/mcp_demo/README.md +187 -27
  22. data/examples/security_features/README.md +249 -30
  23. data/examples/simple_geo_resolver.rb +107 -0
  24. data/lib/otto/core/configuration.rb +15 -20
  25. data/lib/otto/core/error_handler.rb +138 -8
  26. data/lib/otto/core/file_safety.rb +2 -2
  27. data/lib/otto/core/freezable.rb +2 -2
  28. data/lib/otto/core/middleware_stack.rb +2 -2
  29. data/lib/otto/core/router.rb +61 -8
  30. data/lib/otto/core/uri_generator.rb +2 -2
  31. data/lib/otto/core.rb +2 -0
  32. data/lib/otto/design_system.rb +2 -2
  33. data/lib/otto/env_keys.rb +61 -12
  34. data/lib/otto/helpers/base.rb +2 -2
  35. data/lib/otto/helpers/request.rb +8 -3
  36. data/lib/otto/helpers/response.rb +2 -2
  37. data/lib/otto/helpers/validation.rb +2 -2
  38. data/lib/otto/helpers.rb +2 -0
  39. data/lib/otto/locale/config.rb +2 -2
  40. data/lib/otto/locale/middleware.rb +160 -0
  41. data/lib/otto/locale.rb +10 -0
  42. data/lib/otto/logging_helpers.rb +273 -0
  43. data/lib/otto/mcp/auth/token.rb +2 -2
  44. data/lib/otto/mcp/protocol.rb +2 -2
  45. data/lib/otto/mcp/rate_limiting.rb +2 -2
  46. data/lib/otto/mcp/registry.rb +2 -2
  47. data/lib/otto/mcp/route_parser.rb +2 -2
  48. data/lib/otto/mcp/schema_validation.rb +2 -2
  49. data/lib/otto/mcp/server.rb +2 -2
  50. data/lib/otto/mcp.rb +2 -0
  51. data/lib/otto/privacy/config.rb +2 -0
  52. data/lib/otto/privacy/geo_resolver.rb +199 -29
  53. data/lib/otto/privacy/ip_privacy.rb +2 -0
  54. data/lib/otto/privacy/redacted_fingerprint.rb +18 -8
  55. data/lib/otto/privacy.rb +2 -0
  56. data/lib/otto/response_handlers/auto.rb +2 -0
  57. data/lib/otto/response_handlers/base.rb +2 -0
  58. data/lib/otto/response_handlers/default.rb +2 -0
  59. data/lib/otto/response_handlers/factory.rb +2 -0
  60. data/lib/otto/response_handlers/json.rb +2 -0
  61. data/lib/otto/response_handlers/redirect.rb +2 -0
  62. data/lib/otto/response_handlers/view.rb +2 -0
  63. data/lib/otto/response_handlers.rb +2 -2
  64. data/lib/otto/route.rb +4 -4
  65. data/lib/otto/route_definition.rb +42 -15
  66. data/lib/otto/route_handlers/base.rb +2 -0
  67. data/lib/otto/route_handlers/class_method.rb +18 -25
  68. data/lib/otto/route_handlers/factory.rb +2 -2
  69. data/lib/otto/route_handlers/instance_method.rb +8 -5
  70. data/lib/otto/route_handlers/lambda.rb +8 -20
  71. data/lib/otto/route_handlers/logic_class.rb +23 -6
  72. data/lib/otto/route_handlers.rb +2 -2
  73. data/lib/otto/security/authentication/auth_failure.rb +2 -2
  74. data/lib/otto/security/authentication/auth_strategy.rb +11 -4
  75. data/lib/otto/security/authentication/route_auth_wrapper.rb +230 -78
  76. data/lib/otto/security/authentication/strategies/api_key_strategy.rb +2 -0
  77. data/lib/otto/security/authentication/strategies/noauth_strategy.rb +2 -0
  78. data/lib/otto/security/authentication/strategies/permission_strategy.rb +2 -0
  79. data/lib/otto/security/authentication/strategies/role_strategy.rb +2 -0
  80. data/lib/otto/security/authentication/strategies/session_strategy.rb +2 -0
  81. data/lib/otto/security/authentication/strategy_result.rb +6 -5
  82. data/lib/otto/security/authentication.rb +2 -2
  83. data/lib/otto/security/authorization_error.rb +73 -0
  84. data/lib/otto/security/config.rb +2 -2
  85. data/lib/otto/security/configurator.rb +17 -2
  86. data/lib/otto/security/csrf.rb +2 -2
  87. data/lib/otto/security/middleware/csrf_middleware.rb +11 -1
  88. data/lib/otto/security/middleware/ip_privacy_middleware.rb +31 -11
  89. data/lib/otto/security/middleware/rate_limit_middleware.rb +2 -0
  90. data/lib/otto/security/middleware/validation_middleware.rb +15 -0
  91. data/lib/otto/security/rate_limiter.rb +2 -2
  92. data/lib/otto/security/rate_limiting.rb +2 -2
  93. data/lib/otto/security/validator.rb +2 -2
  94. data/lib/otto/security.rb +3 -0
  95. data/lib/otto/static.rb +2 -2
  96. data/lib/otto/utils.rb +27 -2
  97. data/lib/otto/version.rb +3 -3
  98. data/lib/otto.rb +174 -14
  99. data/otto.gemspec +7 -3
  100. metadata +24 -15
  101. data/benchmark_middleware_wrap.rb +0 -163
  102. data/changelog.d/20251014_144317_delano_54_thats_a_wrapper.rst +0 -36
  103. data/changelog.d/20251014_161526_delano_54_thats_a_wrapper.rst +0 -5
@@ -1,6 +1,6 @@
1
- # frozen_string_literal: true
2
-
3
1
  # lib/otto/route_handlers/lambda.rb
2
+ #
3
+ # frozen_string_literal: true
4
4
  require 'securerandom'
5
5
 
6
6
  require_relative 'base'
@@ -10,6 +10,7 @@ class Otto
10
10
  # Custom handler for lambda/proc definitions (future extension)
11
11
  class LambdaHandler < BaseHandler
12
12
  def call(env, extra_params = {})
13
+ start_time = Otto::Utils.now_in_μs
13
14
  req = Rack::Request.new(env)
14
15
  res = Rack::Response.new
15
16
 
@@ -31,25 +32,12 @@ class Otto
31
32
  request: req,
32
33
  })
33
34
  rescue StandardError => e
34
- error_id = SecureRandom.hex(8)
35
- Otto.logger.error "[#{error_id}] #{e.class}: #{e.message}"
36
- Otto.logger.debug "[#{error_id}] Backtrace: #{e.backtrace.join("\n")}" if Otto.debug
37
-
38
- res.status = 500
39
- res.headers['content-type'] = 'text/plain'
35
+ # Store handler context in env for centralized error handler
36
+ handler_name = "Lambda[#{route_definition.klass_name}]"
37
+ env['otto.handler'] = handler_name
38
+ env['otto.handler_duration'] = Otto::Utils.now_in_μs - start_time
40
39
 
41
- if Otto.env?(:dev, :development)
42
- res.write "Lambda handler error (ID: #{error_id}). Check logs for details."
43
- else
44
- res.write 'An error occurred. Please try again later.'
45
- end
46
-
47
- # Add security headers if available
48
- if otto_instance.respond_to?(:security_config) && otto_instance.security_config
49
- otto_instance.security_config.security_headers.each do |header, value|
50
- res.headers[header] = value
51
- end
52
- end
40
+ raise e # Re-raise to let Otto's centralized error handler manage the response
53
41
  end
54
42
 
55
43
  res.finish
@@ -1,6 +1,6 @@
1
- # frozen_string_literal: true
2
-
3
1
  # lib/otto/route_handlers/logic_class.rb
2
+ #
3
+ # frozen_string_literal: true
4
4
  require 'json'
5
5
  require 'securerandom'
6
6
 
@@ -13,6 +13,7 @@ class Otto
13
13
  # Logic classes use signature: initialize(context, params, locale)
14
14
  class LogicClassHandler < BaseHandler
15
15
  def call(env, extra_params = {})
16
+ start_time = Otto::Utils.now_in_μs
16
17
  req = Rack::Request.new(env)
17
18
  res = Rack::Response.new
18
19
 
@@ -30,7 +31,21 @@ class Otto
30
31
  json_data = JSON.parse(req.body.read)
31
32
  logic_params = logic_params.merge(json_data) if json_data.is_a?(Hash)
32
33
  rescue JSON::ParserError => e
33
- Otto.logger.error "[LogicClassHandler] JSON parsing error: #{e.message}"
34
+ # Base context pattern: create once, reuse for correlation
35
+ base_context = Otto::LoggingHelpers.request_context(env)
36
+
37
+ Otto.structured_log(:error, "JSON parsing error",
38
+ base_context.merge(
39
+ handler: "#{target_class}#call",
40
+ error: e.message,
41
+ error_class: e.class.name,
42
+ duration: Otto::Utils.now_in_μs - start_time
43
+ )
44
+ )
45
+
46
+ Otto::LoggingHelpers.log_backtrace(e,
47
+ base_context.merge(handler: "#{target_class}#call")
48
+ )
34
49
  end
35
50
  end
36
51
 
@@ -58,9 +73,11 @@ class Otto
58
73
  # In integrated context, let Otto's centralized error handler manage the response
59
74
  # In direct testing context, handle errors locally for unit testing
60
75
  if otto_instance
61
- # Log error for handler-specific context but let Otto's centralized error handler manage the response
62
- Otto.logger.error "[LogicClassHandler] #{e.class}: #{e.message}"
63
- Otto.logger.debug "[LogicClassHandler] Backtrace: #{e.backtrace.join("\n")}" if Otto.debug
76
+ # Store handler context in env for centralized error handler
77
+ handler_name = "#{target_class}#call"
78
+ env['otto.handler'] = handler_name
79
+ env['otto.handler_duration'] = Otto::Utils.now_in_μs - start_time
80
+
64
81
  raise e # Re-raise to let Otto's centralized error handler manage the response
65
82
  else
66
83
  # Direct handler testing context - handle errors locally with security improvements
@@ -1,6 +1,6 @@
1
- # frozen_string_literal: true
2
-
3
1
  # lib/otto/route_handlers.rb
2
+ #
3
+ # frozen_string_literal: true
4
4
 
5
5
  class Otto
6
6
  # Pluggable Route Handler Factory
@@ -1,7 +1,7 @@
1
+ # lib/otto/security/authentication/auth_failure.rb
2
+ #
1
3
  # frozen_string_literal: true
2
4
 
3
- # lib/otto/security/authentication/failure_result.rb
4
-
5
5
  class Otto
6
6
  module Security
7
7
  module Authentication
@@ -1,7 +1,7 @@
1
- # frozen_string_literal: true
2
-
3
1
  # lib/otto/security/authentication/auth_strategy.rb
4
2
  #
3
+ # frozen_string_literal: true
4
+ #
5
5
  # Base class for all authentication strategies in Otto framework
6
6
  # Provides pluggable authentication patterns that can be customized per application
7
7
 
@@ -13,7 +13,9 @@ class Otto
13
13
  # Check if the request meets the authentication requirements
14
14
  # @param env [Hash] Rack environment
15
15
  # @param requirement [String] Authentication requirement string
16
- # @return [Otto::Security::Authentication::StrategyResult, Otto::Security::Authentication::AuthFailure] StrategyResult for success, AuthFailure for failure
16
+ # @return [Otto::Security::Authentication::StrategyResult,
17
+ # Otto::Security::Authentication::AuthFailure]
18
+ # StrategyResult for success, AuthFailure for failure
17
19
  def authenticate(env, requirement)
18
20
  raise NotImplementedError, 'Subclasses must implement #authenticate'
19
21
  end
@@ -21,12 +23,17 @@ class Otto
21
23
  protected
22
24
 
23
25
  # Helper to create successful strategy result
26
+ #
27
+ # NOTE: strategy_name will be injected by RouteAuthWrapper after strategy execution.
28
+ # Strategies don't know their registered name, so we pass nil here and let the wrapper
29
+ # set it based on how the strategy was registered via add_auth_strategy(name, strategy).
24
30
  def success(user:, session: {}, auth_method: nil, **metadata)
25
31
  Otto::Security::Authentication::StrategyResult.new(
26
32
  session: session,
27
33
  user: user,
28
34
  auth_method: auth_method || self.class.name.split('::').last,
29
- metadata: metadata
35
+ metadata: metadata,
36
+ strategy_name: nil # Will be set by RouteAuthWrapper
30
37
  )
31
38
  end
32
39
 
@@ -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
  #
@@ -39,132 +39,231 @@ class Otto
39
39
  #
40
40
  # For routes WITHOUT auth requirement: Sets anonymous StrategyResult
41
41
  # For routes WITH auth requirement: Enforces authentication
42
+ # Supports multi-strategy with OR logic: auth=session,apikey,oauth
42
43
  #
43
44
  # @param env [Hash] Rack environment
44
45
  # @param extra_params [Hash] Additional parameters
45
46
  # @return [Array] Rack response array
46
47
  def call(env, extra_params = {})
47
- auth_requirement = route_definition.auth_requirement
48
+ auth_requirements = route_definition.auth_requirements
48
49
 
49
50
  # Routes without auth requirement get anonymous StrategyResult
50
- unless auth_requirement
51
+ if auth_requirements.empty?
51
52
  # Note: env['REMOTE_ADDR'] is masked by IPPrivacyMiddleware by default
52
53
  metadata = { ip: env['REMOTE_ADDR'] }
53
- metadata[:country] = env['otto.geo_country'] if env['otto.geo_country']
54
+ metadata[:country] = env['otto.privacy.geo_country'] if env['otto.privacy.geo_country']
54
55
 
55
- result = StrategyResult.anonymous(metadata: metadata)
56
+ result = StrategyResult.anonymous(metadata: metadata, strategy_name: 'anonymous')
56
57
  env['otto.strategy_result'] = result
57
- env['otto.user'] = nil
58
- env['otto.user_context'] = result.user_context
59
58
  return wrapped_handler.call(env, extra_params)
60
59
  end
61
60
 
62
- # Routes WITH auth requirement: Execute authentication strategy
63
- strategy = get_strategy(auth_requirement)
61
+ # Routes WITH auth requirements: Try each strategy in order (first success wins)
64
62
 
65
- unless strategy
66
- Otto.logger.error "[RouteAuthWrapper] No strategy found for requirement: #{auth_requirement}"
67
- return unauthorized_response(env, "Authentication strategy not configured")
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
68
71
  end
69
72
 
70
- # Execute the strategy
71
- result = strategy.authenticate(env, auth_requirement)
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
72
99
 
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']
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)
133
+
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
+ )
143
+
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
83
178
 
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)
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
88
209
  end
89
210
 
90
- # Set environment variables for controllers/logic on success
91
- 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
- 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
+ ))
107
220
  end
108
221
 
109
222
  private
110
223
 
111
- # Get strategy from auth_config hash with sophisticated pattern matching
224
+ # Get strategy from auth_config hash with pattern matching
112
225
  #
113
226
  # Supports:
114
227
  # - 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
228
+ # - Prefix match: 'custom:value' → looks up 'custom' strategy
118
229
  #
119
230
  # Results are cached to avoid repeated lookups for the same requirement.
120
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.
234
+ #
121
235
  # @param requirement [String] Auth requirement from route
122
- # @return [AuthStrategy, nil] Strategy instance or nil
236
+ # @return [Array<AuthStrategy, String>, Array<nil, nil>] Tuple of [strategy, name] or [nil, nil]
123
237
  def get_strategy(requirement)
124
- return nil unless auth_config && auth_config[:auth_strategies]
238
+ return [nil, nil] unless auth_config && auth_config[:auth_strategies]
125
239
 
126
- # Check cache first
240
+ # Check cache first (cache stores [strategy, name] tuples)
127
241
  return @strategy_cache[requirement] if @strategy_cache.key?(requirement)
128
242
 
129
243
  # Try exact match first - this has highest priority
130
244
  strategy = auth_config[:auth_strategies][requirement]
131
245
  if strategy
132
- @strategy_cache[requirement] = strategy
133
- return strategy
246
+ result = [strategy, requirement]
247
+ @strategy_cache[requirement] = result
248
+ return result
134
249
  end
135
250
 
136
- # For colon-separated requirements like "role:admin", try prefix match
251
+ # For colon-separated requirements like "custom:value", try prefix match
137
252
  if requirement.include?(':')
138
253
  prefix = requirement.split(':', 2).first
139
254
 
140
255
  # Check if we have a strategy registered for the prefix
141
256
  prefix_strategy = auth_config[:auth_strategies][prefix]
142
257
  if prefix_strategy
143
- @strategy_cache[requirement] = prefix_strategy
144
- return prefix_strategy
145
- end
146
-
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
258
+ result = [prefix_strategy, prefix]
259
+ @strategy_cache[requirement] = result
260
+ return result
162
261
  end
163
262
  end
164
263
 
165
264
  # Cache nil results too to avoid repeated failed lookups
166
- @strategy_cache[requirement] = nil
167
- nil
265
+ @strategy_cache[requirement] = [nil, nil]
266
+ [nil, nil]
168
267
  end
169
268
 
170
269
  # Generate 401 response for authentication failure
@@ -246,6 +345,59 @@ class Otto
246
345
  end
247
346
  end
248
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
+
249
401
  # Merge security headers into response headers
250
402
  #
251
403
  # @param headers [Hash] Response headers hash to merge into
@@ -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