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.
- 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 +143 -0
- data/.gitignore +4 -0
- data/.pre-commit-config.yaml +2 -2
- data/.reek.yml +99 -0
- data/CHANGELOG.rst +156 -0
- data/CLAUDE.md +74 -540
- data/Gemfile +4 -2
- data/Gemfile.lock +58 -19
- data/README.md +49 -1
- 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 +26 -26
- data/lib/otto/route_handlers/factory.rb +2 -2
- data/lib/otto/route_handlers/instance_method.rb +16 -6
- data/lib/otto/route_handlers/lambda.rb +8 -20
- data/lib/otto/route_handlers/logic_class.rb +33 -8
- 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/response_builder.rb +123 -0
- data/lib/otto/security/authentication/route_auth_wrapper/role_authorization.rb +120 -0
- data/lib/otto/security/authentication/route_auth_wrapper/strategy_resolver.rb +69 -0
- data/lib/otto/security/authentication/route_auth_wrapper.rb +185 -195
- 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 +25 -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
|
@@ -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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -9,10 +9,19 @@ require_relative 'base'
|
|
|
9
9
|
class Otto
|
|
10
10
|
module RouteHandlers
|
|
11
11
|
# Handler for Logic classes (new in Otto Framework Enhancement)
|
|
12
|
-
#
|
|
13
|
-
# Logic classes use signature: initialize(context, params, locale)
|
|
12
|
+
#
|
|
13
|
+
# Logic classes use a constrained signature: initialize(context, params, locale)
|
|
14
|
+
# - context: The authentication strategy result (user info, session data)
|
|
15
|
+
# - params: Merged request parameters (URL params + body + extra_params)
|
|
16
|
+
# - locale: The locale string from env['otto.locale']
|
|
17
|
+
#
|
|
18
|
+
# IMPORTANT: Logic classes do NOT receive the Rack request or env hash.
|
|
19
|
+
# This is intentional - Logic classes work with clean, authenticated contexts.
|
|
20
|
+
# For endpoints requiring direct request access (sessions, cookies, headers,
|
|
21
|
+
# or logout flows), use controller handlers (Controller#action or Controller.action).
|
|
14
22
|
class LogicClassHandler < BaseHandler
|
|
15
23
|
def call(env, extra_params = {})
|
|
24
|
+
start_time = Otto::Utils.now_in_μs
|
|
16
25
|
req = Rack::Request.new(env)
|
|
17
26
|
res = Rack::Response.new
|
|
18
27
|
|
|
@@ -30,7 +39,21 @@ class Otto
|
|
|
30
39
|
json_data = JSON.parse(req.body.read)
|
|
31
40
|
logic_params = logic_params.merge(json_data) if json_data.is_a?(Hash)
|
|
32
41
|
rescue JSON::ParserError => e
|
|
33
|
-
|
|
42
|
+
# Base context pattern: create once, reuse for correlation
|
|
43
|
+
base_context = Otto::LoggingHelpers.request_context(env)
|
|
44
|
+
|
|
45
|
+
Otto.structured_log(:error, "JSON parsing error",
|
|
46
|
+
base_context.merge(
|
|
47
|
+
handler: "#{target_class}#call",
|
|
48
|
+
error: e.message,
|
|
49
|
+
error_class: e.class.name,
|
|
50
|
+
duration: Otto::Utils.now_in_μs - start_time
|
|
51
|
+
)
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
Otto::LoggingHelpers.log_backtrace(e,
|
|
55
|
+
base_context.merge(handler: "#{target_class}#call")
|
|
56
|
+
)
|
|
34
57
|
end
|
|
35
58
|
end
|
|
36
59
|
|
|
@@ -58,9 +81,11 @@ class Otto
|
|
|
58
81
|
# In integrated context, let Otto's centralized error handler manage the response
|
|
59
82
|
# In direct testing context, handle errors locally for unit testing
|
|
60
83
|
if otto_instance
|
|
61
|
-
#
|
|
62
|
-
|
|
63
|
-
|
|
84
|
+
# Store handler context in env for centralized error handler
|
|
85
|
+
handler_name = "#{target_class}#call"
|
|
86
|
+
env['otto.handler'] = handler_name
|
|
87
|
+
env['otto.handler_duration'] = Otto::Utils.now_in_μs - start_time
|
|
88
|
+
|
|
64
89
|
raise e # Re-raise to let Otto's centralized error handler manage the response
|
|
65
90
|
else
|
|
66
91
|
# Direct handler testing context - handle errors locally with security improvements
|
data/lib/otto/route_handlers.rb
CHANGED
|
@@ -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,
|
|
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
|
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Otto
|
|
4
|
+
module Security
|
|
5
|
+
module Authentication
|
|
6
|
+
module RouteAuthWrapperComponents
|
|
7
|
+
# Builds HTTP error responses for authentication/authorization failures
|
|
8
|
+
#
|
|
9
|
+
# Handles content negotiation (JSON vs HTML) and applies security headers.
|
|
10
|
+
# Route's declared response_type takes precedence over Accept header.
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# builder = ResponseBuilder.new(route_definition, auth_config, security_config)
|
|
14
|
+
# response = builder.unauthorized(env, "Invalid token")
|
|
15
|
+
# response = builder.forbidden(env, "Admin role required")
|
|
16
|
+
# response = builder.auth_failure(env, auth_failure_result)
|
|
17
|
+
#
|
|
18
|
+
class ResponseBuilder
|
|
19
|
+
# @param route_definition [RouteDefinition] Route with response_type info
|
|
20
|
+
# @param auth_config [Hash] Auth config with :login_path for HTML redirects
|
|
21
|
+
# @param security_config [SecurityConfig, nil] Optional security config for headers
|
|
22
|
+
def initialize(route_definition, auth_config, security_config = nil)
|
|
23
|
+
@route_definition = route_definition
|
|
24
|
+
@auth_config = auth_config
|
|
25
|
+
@security_config = security_config
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Generate response for authentication failure
|
|
29
|
+
#
|
|
30
|
+
# @param env [Hash] Rack environment
|
|
31
|
+
# @param result [AuthFailure] Failure result from strategy
|
|
32
|
+
# @return [Array] Rack response array
|
|
33
|
+
def auth_failure(env, result)
|
|
34
|
+
wants_json?(env) ? json_auth_error(result) : html_auth_error(result)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Generate 401 Unauthorized response
|
|
38
|
+
#
|
|
39
|
+
# @param env [Hash] Rack environment
|
|
40
|
+
# @param message [String] Error message
|
|
41
|
+
# @return [Array] Rack response array
|
|
42
|
+
def unauthorized(env, message)
|
|
43
|
+
if wants_json?(env)
|
|
44
|
+
json_response(401, error: message)
|
|
45
|
+
else
|
|
46
|
+
text_response(401, message)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Generate 403 Forbidden response
|
|
51
|
+
#
|
|
52
|
+
# @param env [Hash] Rack environment
|
|
53
|
+
# @param message [String] Error message
|
|
54
|
+
# @return [Array] Rack response array
|
|
55
|
+
def forbidden(env, message)
|
|
56
|
+
if wants_json?(env)
|
|
57
|
+
json_response(403, error: 'Forbidden', message: message)
|
|
58
|
+
else
|
|
59
|
+
text_response(403, message)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# Determine if response should be JSON based on route config and Accept header
|
|
66
|
+
#
|
|
67
|
+
# Route's declared response type takes precedence over Accept header.
|
|
68
|
+
# This ensures API routes (response=json) always get JSON errors.
|
|
69
|
+
#
|
|
70
|
+
# @param env [Hash] Rack environment
|
|
71
|
+
# @return [Boolean] true if response should be JSON
|
|
72
|
+
def wants_json?(env)
|
|
73
|
+
return true if @route_definition.response_type == 'json'
|
|
74
|
+
|
|
75
|
+
accept_header = env['HTTP_ACCEPT'] || ''
|
|
76
|
+
accept_header.include?('application/json')
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Generate JSON 401 response for auth failure
|
|
80
|
+
def json_auth_error(result)
|
|
81
|
+
json_response(401,
|
|
82
|
+
error: 'Authentication Required',
|
|
83
|
+
message: result.failure_reason || 'Not authenticated',
|
|
84
|
+
timestamp: Time.now.to_i)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Generate HTML 401 response (redirect to login)
|
|
88
|
+
def html_auth_error(_result)
|
|
89
|
+
login_path = @auth_config[:login_path] || '/signin'
|
|
90
|
+
headers = { 'location' => login_path }
|
|
91
|
+
merge_security_headers!(headers)
|
|
92
|
+
[302, headers, ["Redirecting to #{login_path}"]]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Build a JSON response with security headers
|
|
96
|
+
def json_response(status, body_hash)
|
|
97
|
+
body = body_hash.to_json
|
|
98
|
+
headers = {
|
|
99
|
+
'content-type' => 'application/json',
|
|
100
|
+
'content-length' => body.bytesize.to_s,
|
|
101
|
+
}
|
|
102
|
+
merge_security_headers!(headers)
|
|
103
|
+
[status, headers, [body]]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Build a plain text response with security headers
|
|
107
|
+
def text_response(status, message)
|
|
108
|
+
headers = { 'content-type' => 'text/plain' }
|
|
109
|
+
merge_security_headers!(headers)
|
|
110
|
+
[status, headers, [message]]
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Merge security headers into response headers
|
|
114
|
+
def merge_security_headers!(headers)
|
|
115
|
+
return unless @security_config
|
|
116
|
+
|
|
117
|
+
headers.merge!(@security_config.security_headers)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Otto
|
|
4
|
+
module Security
|
|
5
|
+
module Authentication
|
|
6
|
+
module RouteAuthWrapperComponents
|
|
7
|
+
# Handles Layer 1 (route-level) role-based authorization
|
|
8
|
+
#
|
|
9
|
+
# Extracts user roles from authentication results and checks against
|
|
10
|
+
# route requirements using OR logic (user needs ANY of the required roles).
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# authorizer = RoleAuthorization.new(route_definition)
|
|
14
|
+
# authorizer.check!(strategy_result, env) # raises or returns true
|
|
15
|
+
#
|
|
16
|
+
# @note This is Layer 1 authorization only. Layer 2 (resource-level)
|
|
17
|
+
# authorization should be handled in Logic classes via raise_concerns.
|
|
18
|
+
#
|
|
19
|
+
class RoleAuthorization
|
|
20
|
+
# @param route_definition [RouteDefinition] Route with role requirements
|
|
21
|
+
def initialize(route_definition)
|
|
22
|
+
@route_definition = route_definition
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Check if authentication result satisfies role requirements
|
|
26
|
+
#
|
|
27
|
+
# @param result [StrategyResult] Authentication result
|
|
28
|
+
# @param env [Hash] Rack environment (for logging)
|
|
29
|
+
# @return [true] if authorized
|
|
30
|
+
# @return [Hash] failure info if not authorized: { authorized: false, required: [...], actual: [...] }
|
|
31
|
+
def check(result, env)
|
|
32
|
+
role_requirements = @route_definition.role_requirements
|
|
33
|
+
return true if role_requirements.empty?
|
|
34
|
+
|
|
35
|
+
user_roles = extract_roles(result)
|
|
36
|
+
|
|
37
|
+
# OR logic: user needs ANY of the required roles
|
|
38
|
+
if (user_roles & role_requirements).any?
|
|
39
|
+
log_success(env, role_requirements, user_roles)
|
|
40
|
+
true
|
|
41
|
+
else
|
|
42
|
+
log_failure(env, role_requirements, user_roles, result)
|
|
43
|
+
{
|
|
44
|
+
authorized: false,
|
|
45
|
+
required: role_requirements,
|
|
46
|
+
actual: user_roles,
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Check authorization, returning boolean
|
|
52
|
+
#
|
|
53
|
+
# @param result [StrategyResult] Authentication result
|
|
54
|
+
# @return [Boolean] true if authorized
|
|
55
|
+
def authorized?(result)
|
|
56
|
+
role_requirements = @route_definition.role_requirements
|
|
57
|
+
return true if role_requirements.empty?
|
|
58
|
+
|
|
59
|
+
user_roles = extract_roles(result)
|
|
60
|
+
(user_roles & role_requirements).any?
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Get the role requirements for error messages
|
|
64
|
+
#
|
|
65
|
+
# @return [Array<String>] Required roles
|
|
66
|
+
def requirements
|
|
67
|
+
@route_definition.role_requirements
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
# Extract user roles from authentication result
|
|
73
|
+
#
|
|
74
|
+
# Supports multiple role sources in order of precedence:
|
|
75
|
+
# 1. result.user_roles (Array)
|
|
76
|
+
# 2. result.user[:roles] (Array)
|
|
77
|
+
# 3. result.user['roles'] (Array)
|
|
78
|
+
# 4. result.metadata[:user_roles] (Array)
|
|
79
|
+
#
|
|
80
|
+
# @param result [StrategyResult] Authentication result
|
|
81
|
+
# @return [Array<String>] Array of role strings
|
|
82
|
+
def extract_roles(result)
|
|
83
|
+
# Try direct user_roles accessor (e.g., from RoleStrategy)
|
|
84
|
+
return Array(result.user_roles) if result.respond_to?(:user_roles) && result.user_roles
|
|
85
|
+
|
|
86
|
+
# Try user hash/object with roles
|
|
87
|
+
if result.user
|
|
88
|
+
roles = result.user[:roles] || result.user['roles']
|
|
89
|
+
return Array(roles) if roles
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Try metadata
|
|
93
|
+
return Array(result.metadata[:user_roles]) if result.metadata && result.metadata[:user_roles]
|
|
94
|
+
|
|
95
|
+
# No roles found
|
|
96
|
+
[]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def log_success(env, required_roles, user_roles)
|
|
100
|
+
Otto.structured_log(:debug, 'Role authorization succeeded',
|
|
101
|
+
Otto::LoggingHelpers.request_context(env).merge(
|
|
102
|
+
required_roles: required_roles,
|
|
103
|
+
user_roles: user_roles,
|
|
104
|
+
matched_roles: user_roles & required_roles
|
|
105
|
+
))
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def log_failure(env, required_roles, user_roles, result)
|
|
109
|
+
Otto.structured_log(:warn, 'Role authorization failed',
|
|
110
|
+
Otto::LoggingHelpers.request_context(env).merge(
|
|
111
|
+
required_roles: required_roles,
|
|
112
|
+
user_roles: user_roles,
|
|
113
|
+
user_id: result.user_id
|
|
114
|
+
))
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Otto
|
|
4
|
+
module Security
|
|
5
|
+
module Authentication
|
|
6
|
+
module RouteAuthWrapperComponents
|
|
7
|
+
# Resolves authentication strategy names to strategy instances
|
|
8
|
+
#
|
|
9
|
+
# Handles strategy lookup with caching and pattern matching:
|
|
10
|
+
# - Exact match: 'authenticated' → looks up auth_config[:auth_strategies]['authenticated']
|
|
11
|
+
# - Prefix match: 'custom:value' → looks up 'custom' strategy
|
|
12
|
+
#
|
|
13
|
+
# Results are cached to avoid repeated lookups for the same requirement.
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# resolver = StrategyResolver.new(auth_config)
|
|
17
|
+
# strategy, name = resolver.resolve('session')
|
|
18
|
+
# strategy, name = resolver.resolve('oauth:google') # prefix match
|
|
19
|
+
#
|
|
20
|
+
class StrategyResolver
|
|
21
|
+
# @param auth_config [Hash] Auth configuration with :auth_strategies key
|
|
22
|
+
def initialize(auth_config)
|
|
23
|
+
@auth_config = auth_config
|
|
24
|
+
@cache = {}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Resolve a requirement string to a strategy instance
|
|
28
|
+
#
|
|
29
|
+
# @param requirement [String] Auth requirement from route (e.g., 'session', 'oauth:google')
|
|
30
|
+
# @return [Array<AuthStrategy, String>, Array<nil, nil>] Tuple of [strategy, name] or [nil, nil]
|
|
31
|
+
def resolve(requirement)
|
|
32
|
+
return [nil, nil] unless @auth_config && @auth_config[:auth_strategies]
|
|
33
|
+
|
|
34
|
+
# Check cache first
|
|
35
|
+
return @cache[requirement] if @cache.key?(requirement)
|
|
36
|
+
|
|
37
|
+
result = find_strategy(requirement)
|
|
38
|
+
@cache[requirement] = result
|
|
39
|
+
result
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Clear the strategy cache
|
|
43
|
+
def clear_cache
|
|
44
|
+
@cache.clear
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def find_strategy(requirement)
|
|
50
|
+
strategies = @auth_config[:auth_strategies]
|
|
51
|
+
|
|
52
|
+
# Try exact match first - highest priority
|
|
53
|
+
strategy = strategies[requirement]
|
|
54
|
+
return [strategy, requirement] if strategy
|
|
55
|
+
|
|
56
|
+
# For colon-separated requirements like "custom:value", try prefix match
|
|
57
|
+
if requirement.include?(':')
|
|
58
|
+
prefix = requirement.split(':', 2).first
|
|
59
|
+
prefix_strategy = strategies[prefix]
|
|
60
|
+
return [prefix_strategy, prefix] if prefix_strategy
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
[nil, nil]
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|