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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +1 -1
- data/.github/workflows/claude-code-review.yml +1 -1
- data/.github/workflows/code-smells.yml +146 -0
- data/.gitignore +4 -0
- data/.pre-commit-config.yaml +2 -2
- data/.reek.yml +99 -0
- data/CHANGELOG.rst +90 -0
- data/CLAUDE.md +74 -540
- data/Gemfile +4 -2
- data/Gemfile.lock +58 -19
- data/README.md +49 -1
- data/changelog.d/20251103_235431_delano_86_improve_error_logging.rst +15 -0
- data/changelog.d/20251109_025012_claude_fix_backtrace_sanitization.rst +37 -0
- data/examples/advanced_routes/README.md +137 -20
- data/examples/authentication_strategies/README.md +212 -19
- data/examples/backtrace_sanitization_demo.rb +86 -0
- data/examples/basic/README.md +61 -10
- data/examples/error_handler_registration.rb +136 -0
- data/examples/logging_improvements.rb +76 -0
- data/examples/mcp_demo/README.md +187 -27
- data/examples/security_features/README.md +249 -30
- data/examples/simple_geo_resolver.rb +107 -0
- data/lib/otto/core/configuration.rb +15 -20
- data/lib/otto/core/error_handler.rb +138 -8
- data/lib/otto/core/file_safety.rb +2 -2
- data/lib/otto/core/freezable.rb +2 -2
- data/lib/otto/core/middleware_stack.rb +2 -2
- data/lib/otto/core/router.rb +61 -8
- data/lib/otto/core/uri_generator.rb +2 -2
- data/lib/otto/core.rb +2 -0
- data/lib/otto/design_system.rb +2 -2
- data/lib/otto/env_keys.rb +61 -12
- data/lib/otto/helpers/base.rb +2 -2
- data/lib/otto/helpers/request.rb +8 -3
- data/lib/otto/helpers/response.rb +2 -2
- data/lib/otto/helpers/validation.rb +2 -2
- data/lib/otto/helpers.rb +2 -0
- data/lib/otto/locale/config.rb +2 -2
- data/lib/otto/locale/middleware.rb +160 -0
- data/lib/otto/locale.rb +10 -0
- data/lib/otto/logging_helpers.rb +273 -0
- data/lib/otto/mcp/auth/token.rb +2 -2
- data/lib/otto/mcp/protocol.rb +2 -2
- data/lib/otto/mcp/rate_limiting.rb +2 -2
- data/lib/otto/mcp/registry.rb +2 -2
- data/lib/otto/mcp/route_parser.rb +2 -2
- data/lib/otto/mcp/schema_validation.rb +2 -2
- data/lib/otto/mcp/server.rb +2 -2
- data/lib/otto/mcp.rb +2 -0
- data/lib/otto/privacy/config.rb +2 -0
- data/lib/otto/privacy/geo_resolver.rb +199 -29
- data/lib/otto/privacy/ip_privacy.rb +2 -0
- data/lib/otto/privacy/redacted_fingerprint.rb +18 -8
- data/lib/otto/privacy.rb +2 -0
- data/lib/otto/response_handlers/auto.rb +2 -0
- data/lib/otto/response_handlers/base.rb +2 -0
- data/lib/otto/response_handlers/default.rb +2 -0
- data/lib/otto/response_handlers/factory.rb +2 -0
- data/lib/otto/response_handlers/json.rb +2 -0
- data/lib/otto/response_handlers/redirect.rb +2 -0
- data/lib/otto/response_handlers/view.rb +2 -0
- data/lib/otto/response_handlers.rb +2 -2
- data/lib/otto/route.rb +4 -4
- data/lib/otto/route_definition.rb +42 -15
- data/lib/otto/route_handlers/base.rb +2 -0
- data/lib/otto/route_handlers/class_method.rb +18 -25
- data/lib/otto/route_handlers/factory.rb +2 -2
- data/lib/otto/route_handlers/instance_method.rb +8 -5
- data/lib/otto/route_handlers/lambda.rb +8 -20
- data/lib/otto/route_handlers/logic_class.rb +23 -6
- data/lib/otto/route_handlers.rb +2 -2
- data/lib/otto/security/authentication/auth_failure.rb +2 -2
- data/lib/otto/security/authentication/auth_strategy.rb +11 -4
- data/lib/otto/security/authentication/route_auth_wrapper.rb +230 -78
- data/lib/otto/security/authentication/strategies/api_key_strategy.rb +2 -0
- data/lib/otto/security/authentication/strategies/noauth_strategy.rb +2 -0
- data/lib/otto/security/authentication/strategies/permission_strategy.rb +2 -0
- data/lib/otto/security/authentication/strategies/role_strategy.rb +2 -0
- data/lib/otto/security/authentication/strategies/session_strategy.rb +2 -0
- data/lib/otto/security/authentication/strategy_result.rb +6 -5
- data/lib/otto/security/authentication.rb +2 -2
- data/lib/otto/security/authorization_error.rb +73 -0
- data/lib/otto/security/config.rb +2 -2
- data/lib/otto/security/configurator.rb +17 -2
- data/lib/otto/security/csrf.rb +2 -2
- data/lib/otto/security/middleware/csrf_middleware.rb +11 -1
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +31 -11
- data/lib/otto/security/middleware/rate_limit_middleware.rb +2 -0
- data/lib/otto/security/middleware/validation_middleware.rb +15 -0
- data/lib/otto/security/rate_limiter.rb +2 -2
- data/lib/otto/security/rate_limiting.rb +2 -2
- data/lib/otto/security/validator.rb +2 -2
- data/lib/otto/security.rb +3 -0
- data/lib/otto/static.rb +2 -2
- data/lib/otto/utils.rb +27 -2
- data/lib/otto/version.rb +3 -3
- data/lib/otto.rb +174 -14
- data/otto.gemspec +7 -3
- metadata +24 -15
- data/benchmark_middleware_wrap.rb +0 -163
- data/changelog.d/20251014_144317_delano_54_thats_a_wrapper.rst +0 -36
- data/changelog.d/20251014_161526_delano_54_thats_a_wrapper.rst +0 -5
|
@@ -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
|
data/lib/otto/security/config.rb
CHANGED
|
@@ -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
|
|
data/lib/otto/security/csrf.rb
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# lib/otto/security/middleware/csrf_middleware.rb
|
|
2
|
+
#
|
|
1
3
|
# frozen_string_literal: true
|
|
2
4
|
|
|
3
5
|
require_relative '../config'
|
|
@@ -27,7 +29,15 @@ class Otto
|
|
|
27
29
|
end
|
|
28
30
|
|
|
29
31
|
# Validate CSRF token for unsafe methods
|
|
30
|
-
|
|
32
|
+
unless valid_csrf_token?(request)
|
|
33
|
+
# Log CSRF validation failure
|
|
34
|
+
Otto.structured_log(:warn, "CSRF validation failed",
|
|
35
|
+
Otto::LoggingHelpers.request_context(env).merge(
|
|
36
|
+
referrer: request.referrer
|
|
37
|
+
)
|
|
38
|
+
)
|
|
39
|
+
return csrf_error_response
|
|
40
|
+
end
|
|
31
41
|
|
|
32
42
|
@app.call(env)
|
|
33
43
|
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# lib/otto/security/middleware/ip_privacy_middleware.rb
|
|
2
|
+
#
|
|
1
3
|
# frozen_string_literal: true
|
|
2
4
|
|
|
3
5
|
class Otto
|
|
@@ -87,24 +89,32 @@ class Otto
|
|
|
87
89
|
env['REMOTE_ADDR'] = original_remote_addr
|
|
88
90
|
|
|
89
91
|
# Set privacy-safe values in environment
|
|
90
|
-
env['otto.
|
|
91
|
-
env['otto.masked_ip'] = fingerprint.masked_ip
|
|
92
|
-
env['otto.hashed_ip'] = fingerprint.hashed_ip
|
|
93
|
-
env['otto.geo_country'] = fingerprint.country
|
|
92
|
+
env['otto.privacy.fingerprint'] = fingerprint
|
|
93
|
+
env['otto.privacy.masked_ip'] = fingerprint.masked_ip
|
|
94
|
+
env['otto.privacy.hashed_ip'] = fingerprint.hashed_ip
|
|
95
|
+
env['otto.privacy.geo_country'] = fingerprint.country
|
|
94
96
|
|
|
95
|
-
# CRITICAL: Replace REMOTE_ADDR and forwarded headers with masked
|
|
97
|
+
# CRITICAL: Replace REMOTE_ADDR and forwarded headers with masked values
|
|
96
98
|
# This ensures downstream code (rate limiting, auth, logging, Rack's request.ip)
|
|
97
|
-
# automatically uses the masked
|
|
99
|
+
# automatically uses the masked values without modification
|
|
98
100
|
env['REMOTE_ADDR'] = fingerprint.masked_ip
|
|
99
101
|
|
|
102
|
+
# Replace User-Agent with anonymized version (consistent with IP masking)
|
|
103
|
+
# CRITICAL: Always replace, even if nil, to clear original sensitive data
|
|
104
|
+
env['HTTP_USER_AGENT'] = fingerprint.anonymized_ua
|
|
105
|
+
|
|
106
|
+
# Replace Referer with anonymized version (query params stripped)
|
|
107
|
+
# CRITICAL: Always replace, even if nil, to clear original sensitive data
|
|
108
|
+
env['HTTP_REFERER'] = fingerprint.referer
|
|
109
|
+
|
|
100
110
|
# Mask X-Forwarded-For headers to prevent leakage
|
|
101
111
|
# Replace with masked IP so proxy resolution logic finds the masked IP
|
|
102
112
|
mask_forwarded_headers(env, fingerprint.masked_ip)
|
|
103
113
|
|
|
104
114
|
Otto.logger.debug "[IPPrivacyMiddleware] Masked IP: #{fingerprint.masked_ip}" if Otto.debug
|
|
105
115
|
|
|
106
|
-
# NOTE: We deliberately DO NOT set env['otto.original_ip']
|
|
107
|
-
# This prevents accidental leakage of the real
|
|
116
|
+
# NOTE: We deliberately DO NOT set env['otto.original_ip'], env['otto.original_user_agent'],
|
|
117
|
+
# or env['otto.original_referer']. This prevents accidental leakage of the real values.
|
|
108
118
|
end
|
|
109
119
|
|
|
110
120
|
|
|
@@ -199,10 +209,20 @@ class Otto
|
|
|
199
209
|
#
|
|
200
210
|
# @param env [Hash] Rack environment
|
|
201
211
|
def apply_no_privacy(env)
|
|
202
|
-
# Store original
|
|
203
|
-
|
|
212
|
+
# Store original values for explicit access when privacy is disabled
|
|
213
|
+
if env['REMOTE_ADDR']
|
|
214
|
+
env['otto.original_ip'] = env['REMOTE_ADDR'].dup.force_encoding('UTF-8')
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
if env['HTTP_USER_AGENT']
|
|
218
|
+
env['otto.original_user_agent'] = env['HTTP_USER_AGENT'].dup.force_encoding('UTF-8')
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
if env['HTTP_REFERER']
|
|
222
|
+
env['otto.original_referer'] = env['HTTP_REFERER'].dup.force_encoding('UTF-8')
|
|
223
|
+
end
|
|
204
224
|
|
|
205
|
-
# env['REMOTE_ADDR']
|
|
225
|
+
# env['REMOTE_ADDR'], env['HTTP_USER_AGENT'], env['HTTP_REFERER'] remain unchanged (real values)
|
|
206
226
|
# No fingerprint is created when privacy is disabled
|
|
207
227
|
end
|
|
208
228
|
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# lib/otto/security/middleware/validation_middleware.rb
|
|
2
|
+
#
|
|
1
3
|
# frozen_string_literal: true
|
|
2
4
|
|
|
3
5
|
require_relative '../config'
|
|
@@ -52,8 +54,21 @@ class Otto
|
|
|
52
54
|
|
|
53
55
|
@app.call(env)
|
|
54
56
|
rescue Otto::Security::ValidationError => e
|
|
57
|
+
# Log validation failure
|
|
58
|
+
Otto.structured_log(:warn, "Input validation failed",
|
|
59
|
+
Otto::LoggingHelpers.request_context(env).merge(
|
|
60
|
+
error: e.message
|
|
61
|
+
)
|
|
62
|
+
)
|
|
55
63
|
validation_error_response(e.message)
|
|
56
64
|
rescue Otto::Security::RequestTooLargeError => e
|
|
65
|
+
# Log request size violation
|
|
66
|
+
Otto.structured_log(:warn, "Request too large",
|
|
67
|
+
Otto::LoggingHelpers.request_context(env).merge(
|
|
68
|
+
error: e.message,
|
|
69
|
+
content_length: request.env['CONTENT_LENGTH']
|
|
70
|
+
)
|
|
71
|
+
)
|
|
57
72
|
request_too_large_response(e.message)
|
|
58
73
|
end
|
|
59
74
|
end
|
data/lib/otto/security.rb
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
# lib/otto/security.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
2
4
|
|
|
3
5
|
require_relative 'security/authentication/strategy_result'
|
|
6
|
+
require_relative 'security/authorization_error'
|
|
4
7
|
require_relative 'security/config'
|
|
5
8
|
require_relative 'security/configurator'
|
|
6
9
|
require_relative 'security/middleware/csrf_middleware'
|
data/lib/otto/static.rb
CHANGED
data/lib/otto/utils.rb
CHANGED
|
@@ -1,12 +1,37 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
1
|
# lib/otto/utils.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
4
|
|
|
5
5
|
class Otto
|
|
6
6
|
# Utility methods for common operations and helpers
|
|
7
7
|
module Utils
|
|
8
8
|
extend self
|
|
9
9
|
|
|
10
|
+
# @return [Time] Current time in UTC
|
|
11
|
+
def now
|
|
12
|
+
Time.now.utc
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Returns the current time in microseconds.
|
|
16
|
+
# This is used to measure the duration of Database commands.
|
|
17
|
+
#
|
|
18
|
+
# Alias: now_in_microseconds
|
|
19
|
+
#
|
|
20
|
+
# @return [Integer] The current time in microseconds.
|
|
21
|
+
def now_in_μs
|
|
22
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC, :microsecond)
|
|
23
|
+
end
|
|
24
|
+
alias now_in_microseconds now_in_μs
|
|
25
|
+
|
|
26
|
+
# Determine if a value represents a "yes" or true value
|
|
27
|
+
#
|
|
28
|
+
# @param value [Object] The value to evaluate
|
|
29
|
+
# @return [Boolean] True if the value represents "yes", false otherwise
|
|
30
|
+
#
|
|
31
|
+
# Examples:
|
|
32
|
+
# yes?('true') # => true
|
|
33
|
+
# yes?('yes') # => true
|
|
34
|
+
# yes?('1') # => true
|
|
10
35
|
def yes?(value)
|
|
11
36
|
!value.to_s.empty? && %w[true yes 1].include?(value.to_s.downcase)
|
|
12
37
|
end
|
data/lib/otto/version.rb
CHANGED
data/lib/otto.rb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
1
|
# lib/otto.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
4
|
|
|
5
5
|
require 'json'
|
|
6
6
|
require 'logger'
|
|
@@ -17,13 +17,14 @@ require_relative 'otto/static'
|
|
|
17
17
|
require_relative 'otto/helpers'
|
|
18
18
|
require_relative 'otto/response_handlers'
|
|
19
19
|
require_relative 'otto/route_handlers'
|
|
20
|
-
require_relative 'otto/locale
|
|
20
|
+
require_relative 'otto/locale'
|
|
21
21
|
require_relative 'otto/mcp'
|
|
22
22
|
require_relative 'otto/core'
|
|
23
23
|
require_relative 'otto/privacy'
|
|
24
24
|
require_relative 'otto/security'
|
|
25
25
|
require_relative 'otto/utils'
|
|
26
26
|
require_relative 'otto/version'
|
|
27
|
+
require_relative 'otto/logging_helpers'
|
|
27
28
|
|
|
28
29
|
# Otto is a simple Rack router that allows you to define routes in a file
|
|
29
30
|
# with built-in security features including CSRF protection, input validation,
|
|
@@ -65,7 +66,8 @@ class Otto
|
|
|
65
66
|
|
|
66
67
|
attr_reader :routes, :routes_literal, :routes_static, :route_definitions, :option,
|
|
67
68
|
:static_route, :security_config, :locale_config, :auth_config,
|
|
68
|
-
:route_handler_factory, :mcp_server, :security, :middleware
|
|
69
|
+
:route_handler_factory, :mcp_server, :security, :middleware,
|
|
70
|
+
:error_handlers
|
|
69
71
|
attr_accessor :not_found, :server_error
|
|
70
72
|
|
|
71
73
|
def initialize(path = nil, opts = {})
|
|
@@ -77,6 +79,10 @@ class Otto
|
|
|
77
79
|
load(path) unless path.nil?
|
|
78
80
|
super()
|
|
79
81
|
|
|
82
|
+
# Auto-register AuthorizationError for 403 Forbidden responses
|
|
83
|
+
# This allows Logic classes to raise AuthorizationError for resource-level access control
|
|
84
|
+
register_error_handler(Otto::Security::AuthorizationError, status: 403, log_level: :warn)
|
|
85
|
+
|
|
80
86
|
# Build the middleware app once after all initialization is complete
|
|
81
87
|
build_app!
|
|
82
88
|
|
|
@@ -106,12 +112,35 @@ class Otto
|
|
|
106
112
|
end
|
|
107
113
|
end
|
|
108
114
|
|
|
115
|
+
# Track request timing for lifecycle hooks
|
|
116
|
+
start_time = Otto::Utils.now_in_μs
|
|
117
|
+
request = Rack::Request.new(env)
|
|
118
|
+
response_raw = nil
|
|
119
|
+
|
|
109
120
|
begin
|
|
110
121
|
# Use pre-built middleware app (built once at initialization)
|
|
111
|
-
@app.call(env)
|
|
122
|
+
response_raw = @app.call(env)
|
|
112
123
|
rescue StandardError => e
|
|
113
|
-
handle_error(e, env)
|
|
124
|
+
response_raw = handle_error(e, env)
|
|
125
|
+
ensure
|
|
126
|
+
# Execute request completion hooks if any are registered
|
|
127
|
+
unless @request_complete_callbacks.empty?
|
|
128
|
+
begin
|
|
129
|
+
duration = Otto::Utils.now_in_μs - start_time
|
|
130
|
+
# Wrap response tuple in Rack::Response for developer-friendly API
|
|
131
|
+
# Otto's hook API should provide nice abstractions like Rack::Request/Response
|
|
132
|
+
response = Rack::Response.new(response_raw[2], response_raw[0], response_raw[1])
|
|
133
|
+
@request_complete_callbacks.each do |callback|
|
|
134
|
+
callback.call(request, response, duration)
|
|
135
|
+
end
|
|
136
|
+
rescue StandardError => e
|
|
137
|
+
Otto.logger.error "[Otto] Request completion hook error: #{e.message}"
|
|
138
|
+
Otto.logger.debug "[Otto] Hook error backtrace: #{e.backtrace.join("\n")}" if Otto.debug
|
|
139
|
+
end
|
|
140
|
+
end
|
|
114
141
|
end
|
|
142
|
+
|
|
143
|
+
response_raw
|
|
115
144
|
end
|
|
116
145
|
|
|
117
146
|
# Builds the middleware application chain
|
|
@@ -145,7 +174,7 @@ class Otto
|
|
|
145
174
|
# middleware_stack=), the @app instance variable could be swapped
|
|
146
175
|
# mid-request in a multi-threaded environment.
|
|
147
176
|
|
|
148
|
-
build_app! if @app
|
|
177
|
+
build_app! if @app # Rebuild app if already initialized
|
|
149
178
|
end
|
|
150
179
|
|
|
151
180
|
# Compatibility method for existing tests
|
|
@@ -157,7 +186,7 @@ class Otto
|
|
|
157
186
|
def middleware_stack=(stack)
|
|
158
187
|
@middleware.clear!
|
|
159
188
|
Array(stack).each { |middleware| @middleware.add(middleware) }
|
|
160
|
-
build_app! if @app
|
|
189
|
+
build_app! if @app # Rebuild app if already initialized
|
|
161
190
|
end
|
|
162
191
|
|
|
163
192
|
# Compatibility method for middleware detection
|
|
@@ -301,14 +330,73 @@ class Otto
|
|
|
301
330
|
# @param strategy [Otto::Security::Authentication::AuthStrategy] Strategy instance
|
|
302
331
|
# @example
|
|
303
332
|
# otto.add_auth_strategy('custom', MyCustomStrategy.new)
|
|
333
|
+
# Add an authentication strategy with a registered name
|
|
334
|
+
#
|
|
335
|
+
# This is the primary public API for registering authentication strategies.
|
|
336
|
+
# The name you provide here will be available as `strategy_result.strategy_name`
|
|
337
|
+
# in your application code, making it easy to identify which strategy authenticated
|
|
338
|
+
# the current request.
|
|
339
|
+
#
|
|
340
|
+
# Also available via Otto::Security::Configurator for consolidated security config.
|
|
341
|
+
#
|
|
342
|
+
# @param name [String, Symbol] Strategy name (e.g., 'session', 'api_key', 'jwt')
|
|
343
|
+
# @param strategy [AuthStrategy] Strategy instance
|
|
344
|
+
# @example
|
|
345
|
+
# otto.add_auth_strategy('session', SessionStrategy.new(session_key: 'user_id'))
|
|
346
|
+
# otto.add_auth_strategy('api_key', APIKeyStrategy.new)
|
|
347
|
+
# @raise [ArgumentError] if strategy name already registered
|
|
304
348
|
def add_auth_strategy(name, strategy)
|
|
305
349
|
ensure_not_frozen!
|
|
306
350
|
# Ensure auth_config is initialized (handles edge case where it might be nil)
|
|
307
351
|
@auth_config = { auth_strategies: {}, default_auth_strategy: 'noauth' } if @auth_config.nil?
|
|
308
352
|
|
|
353
|
+
# Strict mode: Detect strategy name collisions
|
|
354
|
+
if @auth_config[:auth_strategies].key?(name)
|
|
355
|
+
raise ArgumentError, "Authentication strategy '#{name}' is already registered"
|
|
356
|
+
end
|
|
357
|
+
|
|
309
358
|
@auth_config[:auth_strategies][name] = strategy
|
|
310
359
|
end
|
|
311
360
|
|
|
361
|
+
# Register an error handler for expected business logic errors
|
|
362
|
+
#
|
|
363
|
+
# This allows you to handle known error conditions (like missing resources,
|
|
364
|
+
# expired data, rate limits) without logging them as unhandled 500 errors.
|
|
365
|
+
#
|
|
366
|
+
# @param error_class [Class, String] The exception class or class name to handle
|
|
367
|
+
# @param status [Integer] HTTP status code to return (default: 500)
|
|
368
|
+
# @param log_level [Symbol] Log level for expected errors (:info, :warn, :error)
|
|
369
|
+
# @param handler [Proc] Optional block to customize error response
|
|
370
|
+
#
|
|
371
|
+
# @example Basic usage with status code
|
|
372
|
+
# otto.register_error_handler(Onetime::MissingSecret, status: 404, log_level: :info)
|
|
373
|
+
# otto.register_error_handler(Onetime::SecretExpired, status: 410, log_level: :info)
|
|
374
|
+
#
|
|
375
|
+
# @example With custom response handler
|
|
376
|
+
# otto.register_error_handler(Onetime::RateLimited, status: 429, log_level: :warn) do |error, req|
|
|
377
|
+
# {
|
|
378
|
+
# error: 'Rate limit exceeded',
|
|
379
|
+
# retry_after: error.retry_after,
|
|
380
|
+
# message: error.message
|
|
381
|
+
# }
|
|
382
|
+
# end
|
|
383
|
+
#
|
|
384
|
+
# @example Using string class names (for lazy loading)
|
|
385
|
+
# otto.register_error_handler('Onetime::MissingSecret', status: 404, log_level: :info)
|
|
386
|
+
#
|
|
387
|
+
def register_error_handler(error_class, status: 500, log_level: :info, &handler)
|
|
388
|
+
ensure_not_frozen!
|
|
389
|
+
|
|
390
|
+
# Normalize error class to string for consistent lookup
|
|
391
|
+
error_class_name = error_class.is_a?(String) ? error_class : error_class.name
|
|
392
|
+
|
|
393
|
+
@error_handlers[error_class_name] = {
|
|
394
|
+
status: status,
|
|
395
|
+
log_level: log_level,
|
|
396
|
+
handler: handler
|
|
397
|
+
}
|
|
398
|
+
end
|
|
399
|
+
|
|
312
400
|
# Disable IP privacy to access original IP addresses
|
|
313
401
|
#
|
|
314
402
|
# IMPORTANT: By default, Otto masks public IP addresses for privacy.
|
|
@@ -327,7 +415,6 @@ class Otto
|
|
|
327
415
|
@security_config.ip_privacy_config.disable!
|
|
328
416
|
end
|
|
329
417
|
|
|
330
|
-
|
|
331
418
|
# Enable full IP privacy (mask ALL IPs including private/localhost)
|
|
332
419
|
#
|
|
333
420
|
# By default, Otto exempts private and localhost IPs from masking for
|
|
@@ -346,6 +433,56 @@ class Otto
|
|
|
346
433
|
@security_config.ip_privacy_config.mask_private_ips = true
|
|
347
434
|
end
|
|
348
435
|
|
|
436
|
+
# Register a callback to be executed after each request completes
|
|
437
|
+
#
|
|
438
|
+
# Instance-level request completion callbacks allow each Otto instance
|
|
439
|
+
# to have its own isolated set of callbacks, preventing duplicate
|
|
440
|
+
# invocations in multi-app architectures (e.g., Rack::URLMap).
|
|
441
|
+
#
|
|
442
|
+
# The callback receives three arguments:
|
|
443
|
+
# - request: Rack::Request object
|
|
444
|
+
# - response: Rack::Response object (wrapping the response tuple)
|
|
445
|
+
# - duration: Request processing duration in microseconds
|
|
446
|
+
#
|
|
447
|
+
# @example Basic usage
|
|
448
|
+
# otto = Otto.new(routes_file)
|
|
449
|
+
# otto.on_request_complete do |req, res, duration|
|
|
450
|
+
# logger.info "Request completed", path: req.path, duration: duration
|
|
451
|
+
# end
|
|
452
|
+
#
|
|
453
|
+
# @example Multi-app architecture
|
|
454
|
+
# # App 1: Core Web Application
|
|
455
|
+
# core_router = Otto.new
|
|
456
|
+
# core_router.on_request_complete do |req, res, duration|
|
|
457
|
+
# logger.info "Core app request", path: req.path
|
|
458
|
+
# end
|
|
459
|
+
#
|
|
460
|
+
# # App 2: API Application
|
|
461
|
+
# api_router = Otto.new
|
|
462
|
+
# api_router.on_request_complete do |req, res, duration|
|
|
463
|
+
# logger.info "API request", path: req.path
|
|
464
|
+
# end
|
|
465
|
+
#
|
|
466
|
+
# # Each callback only fires for its respective Otto instance
|
|
467
|
+
#
|
|
468
|
+
# @yield [request, response, duration] Block to execute after each request
|
|
469
|
+
# @yieldparam request [Rack::Request] The request object
|
|
470
|
+
# @yieldparam response [Rack::Response] The response object
|
|
471
|
+
# @yieldparam duration [Integer] Duration in microseconds
|
|
472
|
+
# @return [self] Returns self for method chaining
|
|
473
|
+
# @raise [FrozenError] if called after configuration is frozen
|
|
474
|
+
def on_request_complete(&block)
|
|
475
|
+
ensure_not_frozen!
|
|
476
|
+
@request_complete_callbacks << block if block_given?
|
|
477
|
+
self
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
# Get registered request completion callbacks (for internal use)
|
|
481
|
+
#
|
|
482
|
+
# @api private
|
|
483
|
+
# @return [Array<Proc>] Array of registered callback blocks
|
|
484
|
+
attr_reader :request_complete_callbacks
|
|
485
|
+
|
|
349
486
|
# Configure IP privacy settings
|
|
350
487
|
#
|
|
351
488
|
# Privacy is enabled by default. Use this method to customize privacy
|
|
@@ -415,7 +552,9 @@ class Otto
|
|
|
415
552
|
# Initialize @auth_config first so it can be shared with the configurator
|
|
416
553
|
@auth_config = { auth_strategies: {}, default_auth_strategy: 'noauth' }
|
|
417
554
|
@security = Otto::Security::Configurator.new(@security_config, @middleware, @auth_config)
|
|
418
|
-
@app = nil
|
|
555
|
+
@app = nil # Pre-built middleware app (built after initialization)
|
|
556
|
+
@request_complete_callbacks = [] # Instance-level request completion callbacks
|
|
557
|
+
@error_handlers = {} # Registered error handlers for expected errors
|
|
419
558
|
|
|
420
559
|
# Add IP Privacy middleware first in stack (privacy by default for public IPs)
|
|
421
560
|
# Private/localhost IPs are automatically exempted from masking
|
|
@@ -449,6 +588,29 @@ class Otto
|
|
|
449
588
|
|
|
450
589
|
class << self
|
|
451
590
|
attr_accessor :debug, :logger # rubocop:disable ThreadSafety/ClassAndModuleAttributes
|
|
591
|
+
|
|
592
|
+
# Helper method for structured logging that works with both standard Logger and structured loggers
|
|
593
|
+
def structured_log(level, message, data = {})
|
|
594
|
+
return unless logger
|
|
595
|
+
|
|
596
|
+
# Skip debug logging when Otto.debug is false
|
|
597
|
+
return if level == :debug && !debug
|
|
598
|
+
|
|
599
|
+
# Sanitize backtrace if present
|
|
600
|
+
if data.is_a?(Hash) && data[:backtrace].is_a?(Array)
|
|
601
|
+
data = data.dup
|
|
602
|
+
data[:backtrace] = Otto::LoggingHelpers.sanitize_backtrace(data[:backtrace])
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
# Try structured logging first (SemanticLogger, etc.)
|
|
606
|
+
if logger.respond_to?(level) && logger.method(level).arity > 1
|
|
607
|
+
logger.send(level, message, data)
|
|
608
|
+
else
|
|
609
|
+
# Fallback to standard logger with formatted string
|
|
610
|
+
formatted_data = data.empty? ? '' : " -- #{data.inspect}"
|
|
611
|
+
logger.send(level, "[Otto] #{message}#{formatted_data}")
|
|
612
|
+
end
|
|
613
|
+
end
|
|
452
614
|
end
|
|
453
615
|
|
|
454
616
|
# Class methods for Otto framework providing singleton access and configuration
|
|
@@ -471,7 +633,7 @@ class Otto
|
|
|
471
633
|
end
|
|
472
634
|
|
|
473
635
|
def env? *guesses
|
|
474
|
-
|
|
636
|
+
guesses.flatten.any? { |n| ENV['RACK_ENV'].to_s == n.to_s }
|
|
475
637
|
end
|
|
476
638
|
|
|
477
639
|
# Test-only method to unfreeze Otto configuration
|
|
@@ -488,9 +650,7 @@ class Otto
|
|
|
488
650
|
# @raise [RuntimeError] if RSpec is not defined (not in test environment)
|
|
489
651
|
# @api private
|
|
490
652
|
def unfreeze_for_testing(otto)
|
|
491
|
-
unless defined?(RSpec)
|
|
492
|
-
raise 'Otto.unfreeze_for_testing is only available in RSpec test environment'
|
|
493
|
-
end
|
|
653
|
+
raise 'Otto.unfreeze_for_testing is only available in RSpec test environment' unless defined?(RSpec)
|
|
494
654
|
|
|
495
655
|
otto.instance_variable_set(:@configuration_frozen, false)
|
|
496
656
|
otto
|