otto 2.5.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) 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/claude.yml +1 -1
  5. data/.github/workflows/code-smells.yml +2 -2
  6. data/.github/workflows/release-gem.yml +1 -1
  7. data/.github/workflows/ruby-lint.yml +1 -1
  8. data/.github/workflows/yardoc.yml +1 -1
  9. data/.pre-commit-config.yaml +22 -5
  10. data/CHANGELOG.rst +283 -0
  11. data/Gemfile +2 -1
  12. data/Gemfile.lock +14 -12
  13. data/README.md +13 -3
  14. data/docs/.gitignore +1 -0
  15. data/docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md +1105 -0
  16. data/docs/1108-STREAMING_SUPPORT_SUMMARY.md +376 -0
  17. data/docs/geo-country.md +172 -0
  18. data/docs/reverse-proxy-network-services.md +19 -6
  19. data/examples/advanced_routes/README.md +49 -0
  20. data/examples/advanced_routes/config.rb +15 -2
  21. data/examples/advanced_routes/routes +12 -0
  22. data/examples/lambda_handlers/README.md +128 -0
  23. data/examples/lambda_handlers/config.ru +26 -0
  24. data/examples/lambda_handlers/handlers.rb +75 -0
  25. data/examples/lambda_handlers/routes +28 -0
  26. data/examples/simple_geo_resolver.rb +38 -5
  27. data/lib/otto/caddy_tls/localhost_guard.rb +43 -25
  28. data/lib/otto/core/configuration.rb +103 -1
  29. data/lib/otto/core/middleware_stack.rb +72 -25
  30. data/lib/otto/core/router.rb +67 -10
  31. data/lib/otto/core/uri_generator.rb +36 -2
  32. data/lib/otto/env_keys.rb +43 -0
  33. data/lib/otto/errors.rb +7 -0
  34. data/lib/otto/logging_helpers.rb +50 -1
  35. data/lib/otto/mcp/rate_limiting.rb +5 -2
  36. data/lib/otto/mcp/route_parser.rb +15 -4
  37. data/lib/otto/privacy/config.rb +281 -3
  38. data/lib/otto/privacy/core.rb +104 -8
  39. data/lib/otto/privacy/geo_resolver.rb +228 -128
  40. data/lib/otto/privacy/ip_privacy.rb +24 -0
  41. data/lib/otto/privacy/redacted_fingerprint.rb +58 -22
  42. data/lib/otto/privacy/user_agent_privacy.rb +64 -0
  43. data/lib/otto/privacy.rb +4 -1
  44. data/lib/otto/request.rb +35 -1
  45. data/lib/otto/route.rb +103 -41
  46. data/lib/otto/route_definition.rb +56 -6
  47. data/lib/otto/route_handlers/base.rb +4 -0
  48. data/lib/otto/route_handlers/factory.rb +15 -0
  49. data/lib/otto/route_handlers/lambda.rb +47 -32
  50. data/lib/otto/security/authentication/auth_failure.rb +36 -2
  51. data/lib/otto/security/authentication/auth_strategy.rb +12 -2
  52. data/lib/otto/security/authentication/authorization_failure.rb +7 -0
  53. data/lib/otto/security/authentication/route_auth_wrapper.rb +138 -31
  54. data/lib/otto/security/config.rb +123 -6
  55. data/lib/otto/security/core.rb +4 -1
  56. data/lib/otto/security/csp/policy.rb +135 -3
  57. data/lib/otto/security/csp/report_middleware.rb +3 -1
  58. data/lib/otto/security/csrf_enforcement_wrapper.rb +68 -0
  59. data/lib/otto/security/csrf_validation.rb +75 -0
  60. data/lib/otto/security/middleware/csrf_middleware.rb +15 -71
  61. data/lib/otto/security/middleware/ip_privacy_middleware.rb +232 -15
  62. data/lib/otto/security/rate_limiter.rb +7 -1
  63. data/lib/otto/security.rb +1 -0
  64. data/lib/otto/utils.rb +100 -0
  65. data/lib/otto/version.rb +1 -1
  66. data/lib/otto.rb +37 -5
  67. metadata +13 -6
data/lib/otto/request.rb CHANGED
@@ -133,6 +133,33 @@ class Otto
133
133
  redacted_fingerprint&.hashed_ip || env['otto.privacy.hashed_ip']
134
134
  end
135
135
 
136
+ # Get the stable-keyed correlation hash of the client IP.
137
+ #
138
+ # Contrast with #hashed_ip: that value is keyed with a daily-rotating
139
+ # secret (great for correlating requests within a session, useless across
140
+ # days). This value is HMAC-SHA256 over the SAME full, pre-masking client
141
+ # IP but keyed with a caller-configured STABLE secret, so the same IP
142
+ # produces the same hash indefinitely — the granularity long-lived audit
143
+ # records need without ever handling the raw IP.
144
+ #
145
+ # Both are computed before masking, so both reflect the per-host address
146
+ # (not the /24 the app is otherwise left with); the raw IP itself never
147
+ # reaches the application — only the hash does.
148
+ #
149
+ # Returns nil when IP privacy is disabled, no correlation secret is
150
+ # configured (see Otto#configure_ip_privacy(correlation_secret:)), or the
151
+ # client IP is exempt from masking. By default private/localhost IPs are
152
+ # exempt (mask_private_ips is false), so this is nil for RFC-1918 and
153
+ # loopback addresses — including the common local dev path — just like
154
+ # #masked_ip and #hashed_ip. It targets public audit-trail traffic.
155
+ #
156
+ # @return [String, nil] Hexadecimal HMAC-SHA256 hash string or nil
157
+ # @example
158
+ # req.ip_correlation_hash # => 'b7e2...' (stable across days)
159
+ def ip_correlation_hash
160
+ env['otto.privacy.correlation_hash']
161
+ end
162
+
136
163
  def client_ipaddress
137
164
  # Prefer the canonical client IP resolved once by IPPrivacyMiddleware
138
165
  # ("resolve once, read everywhere"). Falls back to the shared resolver
@@ -195,8 +222,15 @@ class Otto
195
222
  # Check direct HTTPS connection
196
223
  return true if env['HTTPS'] == 'on' || env['SERVER_PORT'] == '443'
197
224
 
225
+ # rack.url_scheme is server-/middleware-set (never a client header), so a
226
+ # scheme normalized upstream the canonical Rack way counts as authoritative
227
+ # — keeping this answer aligned with Rack::Request#scheme, which the
228
+ # session Secure-cookie gate and CSRF middleware read.
229
+ return true if env['rack.url_scheme'] == 'https'
230
+
198
231
  # Only trust forwarded proto headers when the request actually arrived via
199
- # a trusted proxy.
232
+ # a trusted proxy. Stricter than Rack::Request#scheme, which honors
233
+ # X-Forwarded-Proto unconditionally.
200
234
  return false unless forwarded_by_trusted_proxy?
201
235
 
202
236
  # X-Scheme is set by nginx; X-Forwarded-Proto by elastic load balancer
data/lib/otto/route.rb CHANGED
@@ -2,6 +2,8 @@
2
2
  #
3
3
  # frozen_string_literal: true
4
4
 
5
+ require 'concurrent'
6
+
5
7
  require_relative 'security/constant_resolver'
6
8
 
7
9
  class Otto
@@ -24,9 +26,44 @@ class Otto
24
26
  #
25
27
  #
26
28
  class Route
27
- # Class methods for Route providing Otto instance access
29
+ # Class methods for Route providing Otto instance access.
30
+ #
31
+ # `route.rb` and `route_handlers/base.rb` `extend` this onto the target
32
+ # class on every request and set `.otto = otto_instance` so app code can
33
+ # read `self.class.otto` from within a handler method. A plain class
34
+ # ivar here would be shared, mutable state: two `Otto` instances sharing
35
+ # a controller/logic class, or concurrent threads/fibers serving
36
+ # requests under different `Otto` instances, would race and clobber
37
+ # `klass.otto`, leaking one request's security_config/auth_config into
38
+ # another's handler (issue #188). Backing the accessor with a
39
+ # `Concurrent::FiberLocalVar` scopes each assignment to the fiber/thread
40
+ # actually serving that request instead.
41
+ #
42
+ # NOTE (Otto v3): this whole class-level accessor is ambient per-request
43
+ # state and exists only as a convenience so handler code can reach otto via
44
+ # `self.class.otto`. The clean design carries no ambient state at all —
45
+ # the handler instance already receives its `Otto` explicitly
46
+ # (`BaseHandler.new(route_definition, otto_instance)`), so app code should
47
+ # read it from an instance-level `#otto` reader instead. Recommended for
48
+ # Otto v3: expose `otto` on the handler instance, deprecate
49
+ # `self.class.otto`, and drop `ClassMethods` — then there is no shared slot
50
+ # to race, reset, or leak, and this fiber-local workaround goes away.
28
51
  module ClassMethods
29
- attr_accessor :otto
52
+ # Per-fiber storage keyed by target class. Deliberately
53
+ # `FiberLocalVar`, not `ThreadLocalVar`: fiber-per-request schedulers
54
+ # (Falcon/Async) run many requests as fibers in one thread, so
55
+ # thread-scoped storage would let those fibers clobber each other's
56
+ # `klass.otto` — the same race, one level down. The default block gives
57
+ # each fiber its own class => otto hash on first access.
58
+ OTTO_INSTANCES = Concurrent::FiberLocalVar.new { {} }
59
+
60
+ def otto=(instance)
61
+ OTTO_INSTANCES.value[self] = instance
62
+ end
63
+
64
+ def otto
65
+ OTTO_INSTANCES.value[self]
66
+ end
30
67
  end
31
68
  # @return [Otto::RouteDefinition] The immutable route definition
32
69
  attr_reader :route_definition
@@ -52,8 +89,15 @@ class Otto
52
89
  # Create immutable route definition
53
90
  @route_definition = Otto::RouteDefinition.new(verb, path, definition, pattern: pattern, keys: keys)
54
91
 
55
- # Resolve the class
56
- @klass = Otto::Security::ConstantResolver.safe_const_get(@route_definition.klass_name)
92
+ # Resolve the class.
93
+ # Lambda routes carry a registry KEY in klass_name, not a Ruby constant.
94
+ # Skip constant resolution (it would raise on a lowercase/unregistered key
95
+ # and the loader would silently drop the route).
96
+ @klass = if @route_definition.kind == :lambda
97
+ nil
98
+ else
99
+ Otto::Security::ConstantResolver.safe_const_get(@route_definition.klass_name)
100
+ end
57
101
  end
58
102
 
59
103
  # Delegate common methods to route_definition for backward compatibility
@@ -102,14 +146,37 @@ class Otto
102
146
  # @return [Array] Rack response array [status, headers, body]
103
147
  def call(env, extra_params = {})
104
148
  extra_params ||= {}
105
- req = otto.request_class.new(env)
106
- res = otto.response_class.new
149
+
150
+ # Pluggable route handler factory (Phase 4). The handler owns
151
+ # request/response construction and decoration — param merging,
152
+ # indifferent access, security headers, CSRF/validation helpers all
153
+ # happen once in BaseHandler#setup_request_response. Building them here
154
+ # too would duplicate that work on objects that get discarded (issue #189).
155
+ if otto&.route_handler_factory
156
+ # Make security config, route definition, and options available to
157
+ # middleware and handlers before delegating, so wrappers that run
158
+ # ahead of the handler's own setup (RouteAuthWrapper, the centralized
159
+ # error handler) can see them.
160
+ env['otto.security_config'] = otto.security_config if otto.respond_to?(:security_config) && otto.security_config
161
+ env['otto.route_definition'] = @route_definition
162
+ env['otto.route_options'] = @route_definition.options
163
+
164
+ handler = otto.route_handler_factory.create_handler(@route_definition, otto)
165
+ return handler.call(env, extra_params)
166
+ end
167
+
168
+ # Fallback to legacy behavior for backward compatibility. Build req/res
169
+ # before touching env, preserving the exact ordering this path always
170
+ # had — a custom request_class/response_class#initialize that reads env
171
+ # must keep seeing it unpopulated, same as before #189 (review follow-up).
172
+ req = otto.request_class.new(env)
173
+ res = otto.response_class.new
107
174
  res.request = req
108
175
 
109
176
  # Make security config available to response helpers
110
177
  env['otto.security_config'] = otto.security_config if otto.respond_to?(:security_config) && otto.security_config
111
178
 
112
- # NEW: Make route definition and options available to middleware and handlers
179
+ # Make route definition and options available to middleware and handlers
113
180
  env['otto.route_definition'] = @route_definition
114
181
  env['otto.route_options'] = @route_definition.options
115
182
 
@@ -124,8 +191,11 @@ class Otto
124
191
  end
125
192
  end
126
193
 
127
- klass.extend Otto::Route::ClassMethods
128
- klass.otto = otto
194
+ # No target class for lambda routes (klass is nil); skip class extension.
195
+ if klass
196
+ klass.extend Otto::Route::ClassMethods
197
+ klass.otto = otto
198
+ end
129
199
 
130
200
  # Add security helpers if CSRF is enabled
131
201
  if otto.respond_to?(:security_config) && otto.security_config&.csrf_enabled?
@@ -135,39 +205,31 @@ class Otto
135
205
  # Add validation helpers
136
206
  res.extend Otto::Security::ValidationHelpers
137
207
 
138
- # NEW: Use the pluggable route handler factory (Phase 4)
139
- # This replaces the hardcoded execution pattern with a factory approach
140
- if otto&.route_handler_factory
141
- handler = otto.route_handler_factory.create_handler(@route_definition, otto)
142
- handler.call(env, extra_params)
143
- else
144
- # Fallback to legacy behavior for backward compatibility
145
- inst = nil
146
- result = case kind
147
- when :instance
148
- inst = klass.new req, res
149
- inst.send(name)
150
- when :class
151
- klass.send(name, req, res)
152
- else
153
- raise "Unsupported kind for #{definition}: #{kind}"
154
- end
155
-
156
- # Handle response based on route options
157
- response_type = @route_definition.response_type
158
- if response_type != 'default'
159
- context = {
160
- logic_instance: (kind == :instance ? inst : nil),
161
- status_code: nil,
162
- redirect_path: nil,
163
- }
164
-
165
- Otto::ResponseHandlers::HandlerFactory.handle_response(result, res, response_type, context)
166
- end
167
-
168
- res.body = [res.body] unless res.body.respond_to?(:each)
169
- res.finish
208
+ inst = nil
209
+ result = case kind
210
+ when :instance
211
+ inst = klass.new req, res
212
+ inst.send(name)
213
+ when :class
214
+ klass.send(name, req, res)
215
+ else
216
+ raise "Unsupported kind for #{definition}: #{kind}"
217
+ end
218
+
219
+ # Handle response based on route options
220
+ response_type = @route_definition.response_type
221
+ if response_type != 'default'
222
+ context = {
223
+ logic_instance: (kind == :instance ? inst : nil),
224
+ status_code: nil,
225
+ redirect_path: nil,
226
+ }
227
+
228
+ Otto::ResponseHandlers::HandlerFactory.handle_response(result, res, response_type, context)
170
229
  end
230
+
231
+ res.body = [res.body] unless res.body.respond_to?(:each)
232
+ res.finish
171
233
  end
172
234
 
173
235
  private
@@ -2,10 +2,18 @@
2
2
  #
3
3
  # frozen_string_literal: true
4
4
 
5
+ require_relative 'errors'
6
+
5
7
  class Otto
6
8
  # Immutable data class representing a complete route definition
7
9
  # This encapsulates all aspects of a route: path, target, and options
8
10
  class RouteDefinition
11
+ # Options that gate access to a route. A malformed token for one of these
12
+ # (e.g. `csrf` instead of `csrf=exempt`, or a bare `auth`) must not fall
13
+ # back to the default behavior silently — the route would serve without
14
+ # its intended protection (issue #191).
15
+ SECURITY_GATING_OPTIONS = %w[auth role csrf].freeze
16
+
9
17
  # @return [String] The HTTP verb (GET, POST, etc.)
10
18
  attr_reader :verb
11
19
 
@@ -112,6 +120,32 @@ class Otto
112
120
  option(:response, 'default')
113
121
  end
114
122
 
123
+ # Parse a single whitespace-delimited `key=value` option token, applying
124
+ # the security-gating fail-fast rule shared by normal routes and the MCP
125
+ # RouteParser (issue #191 and its MCP/TOOL follow-up).
126
+ # @param part [String] a single option token, e.g. "auth=session"
127
+ # @param context [String] human-readable source description for the
128
+ # raised error message, e.g. "route definition \"GET /admin ...\""
129
+ # @return [Array(Symbol, String), nil] the [key, value] pair to store,
130
+ # or nil if the token is malformed and should only be warned about
131
+ # @raise [Otto::RouteDefinitionError] if a security-gating option
132
+ # (auth/role/csrf) is malformed
133
+ def self.parse_option_token(part, context)
134
+ key, value = part.split('=', 2)
135
+ normalized_key = key&.downcase
136
+
137
+ if SECURITY_GATING_OPTIONS.include?(normalized_key)
138
+ if key != normalized_key || value.nil? || value.empty?
139
+ raise Otto::RouteDefinitionError,
140
+ "Malformed security option #{part.inspect} in #{context}: " \
141
+ "expected #{normalized_key}=value"
142
+ end
143
+ [key.to_sym, value]
144
+ elsif key && !key.empty? && value
145
+ [key.to_sym, value]
146
+ end
147
+ end
148
+
115
149
  # Check if CSRF is exempt for this route
116
150
  # @return [Boolean]
117
151
  def csrf_exempt?
@@ -168,18 +202,21 @@ class Otto
168
202
  # Parse route definition into target and options
169
203
  # @param definition [String] The route definition
170
204
  # @return [Hash] Hash with :target and :options keys
205
+ # @raise [Otto::RouteDefinitionError] if a security-gating option token
206
+ # (auth/role/csrf) has no value — failing fast instead of serving the
207
+ # route with default (less safe) behavior
171
208
  def parse_definition(definition)
172
209
  parts = definition.split(/\s+/)
173
210
  target = parts.shift
174
211
  options = {}
175
212
 
176
213
  parts.each do |part|
177
- key, value = part.split('=', 2)
178
- if key && value
179
- options[key.to_sym] = value
180
- elsif Otto.debug
181
- # Malformed parameter, log warning if debug enabled
182
- Otto.logger.warn "Ignoring malformed route parameter: #{part}"
214
+ pair = self.class.parse_option_token(part, "route definition #{definition.inspect}")
215
+ if pair
216
+ options[pair[0]] = pair[1]
217
+ else
218
+ Otto.structured_log(:warn, 'Malformed route option ignored',
219
+ { option: part, definition: definition })
183
220
  end
184
221
  end
185
222
 
@@ -191,6 +228,19 @@ class Otto
191
228
  # @return [Hash] Hash with :klass_name, :method_name, and :kind
192
229
  def parse_target(target)
193
230
  case target
231
+ when /^&/
232
+ # Lambda handler: '&name' references a proc pre-registered in the
233
+ # lambda_handlers registry. The entire remainder after '&' is the exact
234
+ # O(1) Hash lookup key (may contain '.', '#', '::' — all inert here;
235
+ # resolution is string equality, never eval/const_get). Issue #41 security.
236
+ name = target[1..].to_s
237
+ if name.strip.empty?
238
+ raise ArgumentError,
239
+ "Invalid lambda handler target #{target.inspect}: handler name " \
240
+ "after '&' cannot be empty (expected '&handler_name')"
241
+ end
242
+ { klass_name: name, method_name: nil, kind: :lambda }
243
+
194
244
  when /^(.+)\.(.+)$/
195
245
  # Class.method - call class method directly
196
246
  { klass_name: ::Regexp.last_match(1), method_name: ::Regexp.last_match(2), kind: :class }
@@ -125,6 +125,10 @@ class Otto
125
125
  # @param env [Hash] Rack environment
126
126
  # @param extra_params [Hash] Additional parameters
127
127
  def setup_request_response(req, res, env, extra_params)
128
+ # Expose the raw path-capture hash to invoke_target (LambdaHandler
129
+ # passes it as the proc's 3rd arg). Inert for other subclasses.
130
+ @extra_params = extra_params
131
+
128
132
  # Set request reference (helpers are already included in class)
129
133
  res.request = req
130
134
 
@@ -4,6 +4,7 @@
4
4
 
5
5
  require_relative 'base'
6
6
  require_relative '../security/authentication/route_auth_wrapper'
7
+ require_relative '../security/csrf_enforcement_wrapper'
7
8
 
8
9
  class Otto
9
10
  module RouteHandlers
@@ -19,6 +20,7 @@ class Otto
19
20
  when :logic then LogicClassHandler
20
21
  when :instance then InstanceMethodHandler
21
22
  when :class then ClassMethodHandler
23
+ when :lambda then LambdaHandler
22
24
  else
23
25
  raise ArgumentError, "Unknown handler kind: #{route_definition.kind}"
24
26
  end
@@ -37,6 +39,19 @@ class Otto
37
39
  )
38
40
  end
39
41
 
42
+ # Enforce CSRF at the handler layer, where `csrf=exempt` is visible
43
+ # (the global CSRFMiddleware runs ahead of route matching and cannot
44
+ # honor per-route exemption — issue #186). Wrapped OUTSIDE
45
+ # RouteAuthWrapper so a forged unsafe request is rejected before any
46
+ # authentication work runs. Only added when protection is enabled.
47
+ if otto_instance&.security_config&.csrf_enabled?
48
+ handler = Otto::Security::CSRFEnforcementWrapper.new(
49
+ handler,
50
+ route_definition,
51
+ otto_instance.security_config
52
+ )
53
+ end
54
+
40
55
  apply_handler_wrappers(handler, route_definition, otto_instance)
41
56
  end
42
57
 
@@ -1,46 +1,61 @@
1
1
  # lib/otto/route_handlers/lambda.rb
2
2
  #
3
3
  # frozen_string_literal: true
4
- require 'securerandom'
5
4
 
6
5
  require_relative 'base'
7
6
 
8
7
  class Otto
9
8
  module RouteHandlers
10
- # Custom handler for lambda/proc definitions (future extension)
9
+ # Handler for pre-registered lambda/proc route targets (issue #41).
10
+ #
11
+ # Route syntax `GET /ping &health_check` parses to kind :lambda with
12
+ # klass_name = "health_check" (the registry KEY, not a Ruby constant) and
13
+ # method_name = nil. The proc is looked up O(1) by name from the Otto
14
+ # instance's pre-registered lambda_handlers — no eval, no dynamic constants.
15
+ #
16
+ # Reuses BaseHandler#call: implements #invoke_target and guards the base's
17
+ # constant-resolution steps (#target_class / #handler_name).
11
18
  class LambdaHandler < BaseHandler
12
- def call(env, extra_params = {})
13
- start_time = Otto::Utils.now_in_μs
14
- req = otto_instance ? otto_instance.request_class.new(env) : Otto::Request.new(env)
15
- res = otto_instance ? otto_instance.response_class.new : Otto::Response.new
16
-
17
- begin
18
- # Security: Lambda handlers require pre-configured procs from Otto instance
19
- # This prevents code injection via eval and maintains security
20
- handler_name = route_definition.klass_name
21
- lambda_registry = otto_instance&.config&.dig(:lambda_handlers) || {}
22
-
23
- lambda_proc = lambda_registry[handler_name]
24
- unless lambda_proc.respond_to?(:call)
25
- raise ArgumentError, "Lambda handler '#{handler_name}' not found in registry or not callable"
26
- end
27
-
28
- result = lambda_proc.call(req, res, extra_params)
29
-
30
- handle_response(result, res, {
31
- lambda: lambda_proc,
32
- request: req,
33
- })
34
- rescue StandardError => e
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
39
-
40
- raise e # Re-raise to let Otto's centralized error handler manage the response
19
+ protected
20
+
21
+ # No Ruby constant backs a lambda route. Returning nil (a) prevents
22
+ # ConstantResolver.safe_const_get from raising on a registry key and
23
+ # (b) makes BaseHandler#setup_request_response skip its target_class
24
+ # extension block.
25
+ def target_class
26
+ nil
27
+ end
28
+
29
+ # Derive the log/handler name from the route, not target_class.name
30
+ # (which would be nil.name -> NoMethodError inside handle_execution_error).
31
+ def handler_name
32
+ "Lambda[#{route_definition.klass_name}]"
33
+ end
34
+
35
+ # Look up the pre-registered proc and invoke it with (req, res, extra_params).
36
+ # @return [Array] [result, context] consumed by BaseHandler#handle_response
37
+ def invoke_target(req, res)
38
+ handler_key = route_definition.klass_name
39
+ lambda_proc = lambda_registry[handler_key]
40
+
41
+ unless lambda_proc.respond_to?(:call)
42
+ raise ArgumentError,
43
+ "Lambda handler '#{handler_key}' is not registered or not callable"
41
44
  end
42
45
 
43
- res.finish
46
+ result = lambda_proc.call(req, res, @extra_params || {})
47
+ [result, { request: req }]
48
+ end
49
+
50
+ private
51
+
52
+ # O(1) read of the frozen registry from Otto config. Tolerates the
53
+ # direct-testing context (no otto_instance / no :config) by returning {},
54
+ # so invoke_target raises the clear "not registered" ArgumentError.
55
+ def lambda_registry
56
+ return {} unless otto_instance.respond_to?(:config)
57
+
58
+ (otto_instance.config && otto_instance.config[:lambda_handlers]) || {}
44
59
  end
45
60
  end
46
61
  end
@@ -6,10 +6,44 @@ class Otto
6
6
  module Security
7
7
  module Authentication
8
8
  # Failure result for authentication failures
9
- AuthFailure = Data.define(:failure_reason, :auth_method) do
9
+ AuthFailure = Data.define(:failure_reason, :auth_method, :terminal) do
10
10
  # AuthFailure represents authentication failure
11
11
  # Returned by strategies when authentication fails
12
12
  # Contains failure reason for error messages
13
+ #
14
+ # TERMINAL FAILURES
15
+ # -----------------
16
+ # By default a failure is non-terminal: RouteAuthWrapper records it and
17
+ # consults the next strategy in the route's chain (OR logic), so a
18
+ # request without credentials can still fall through to an
19
+ # anonymous-capable strategy like noauth.
20
+ #
21
+ # A failure constructed with `terminal: true` means "this request
22
+ # explicitly presented credentials and they were examined and
23
+ # rejected — do not consult further strategies." RouteAuthWrapper
24
+ # halts the chain and renders the 401 with this failure's reason,
25
+ # regardless of where the strategy sits in the chain. This lets mixed
26
+ # credentialed/anonymous chains (e.g. auth=basicauth,noauth) fail
27
+ # closed on invalid credentials instead of silently degrading to
28
+ # anonymous.
29
+ #
30
+ # Only reject terminally when credentials were EXPLICITLY presented
31
+ # (e.g. an Authorization header). Ambient credentials such as session
32
+ # cookies should fail non-terminally so a logged-out browser can still
33
+ # degrade to anonymous on noauth-capable routes.
34
+
35
+ # terminal defaults to false so existing keyword construction
36
+ # (failure_reason:, auth_method:) is unaffected.
37
+ def initialize(failure_reason:, auth_method:, terminal: false)
38
+ super
39
+ end
40
+
41
+ # Check if this failure halts the strategy chain
42
+ #
43
+ # @return [Boolean] True when the chain must fail closed
44
+ def terminal?
45
+ terminal
46
+ end
13
47
 
14
48
  # Check if authenticated - always false for failures
15
49
  #
@@ -36,7 +70,7 @@ class Otto
36
70
  #
37
71
  # @return [String] Debug representation
38
72
  def inspect
39
- "#<AuthFailure reason=#{failure_reason.inspect} method=#{auth_method}>"
73
+ "#<AuthFailure reason=#{failure_reason.inspect} method=#{auth_method}#{' terminal' if terminal}>"
40
74
  end
41
75
  end
42
76
  end
@@ -42,11 +42,21 @@ class Otto
42
42
  # Use for a missing, invalid, or expired credential. RouteAuthWrapper maps
43
43
  # this to 401 Unauthorized. For a VALID credential that is not permitted,
44
44
  # use #authorization_failure instead (403 Forbidden).
45
- def failure(reason = nil)
45
+ #
46
+ # Pass terminal: true when the request EXPLICITLY presented credentials
47
+ # (e.g. an Authorization header) that were examined and rejected.
48
+ # RouteAuthWrapper then halts the strategy chain and fails closed with
49
+ # 401 instead of consulting further strategies — so an anonymous-capable
50
+ # strategy later (or earlier) in the chain cannot silently accept the
51
+ # request as anonymous. Leave it false for missing credentials and for
52
+ # ambient credentials (session cookies), which should keep today's OR
53
+ # fallthrough. See AuthFailure.
54
+ def failure(reason = nil, terminal: false)
46
55
  Otto.logger.debug "[#{self.class}] Authentication failed: #{reason}" if reason
47
56
  Otto::Security::Authentication::AuthFailure.new(
48
57
  failure_reason: reason || 'Authentication failed',
49
- auth_method: strategy_auth_method
58
+ auth_method: strategy_auth_method,
59
+ terminal: terminal
50
60
  )
51
61
  end
52
62
 
@@ -26,6 +26,13 @@ class Otto
26
26
  # StrategyResult. This type covers the complementary case: a strategy that
27
27
  # owns authorization itself (including permission tiers, which Layer-1 does
28
28
  # not model) and needs to signal a 403 directly.
29
+ #
30
+ # DELIBERATELY has no `terminal` member (unlike AuthFailure): an
31
+ # authorization denial must not halt the strategy chain — a later
32
+ # strategy's success still wins (a different credential may well be
33
+ # permitted). When the chain DOES end in failure, a recorded denial
34
+ # already takes response precedence (403 over 401, even over a terminal
35
+ # halt), so there is nothing a terminal flag here would add.
29
36
  AuthorizationFailure = Data.define(:failure_reason, :auth_method) do
30
37
  # Authorization failures are not an authenticated request state. The
31
38
  # request never reaches the handler, so handler-facing predicates report