otto 2.0.0.pre7 → 2.0.0.pre9
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/claude.yml +1 -1
- data/.github/workflows/code-smells.yml +5 -8
- data/CHANGELOG.rst +87 -2
- data/Gemfile.lock +6 -6
- data/README.md +20 -0
- data/docs/.gitignore +2 -0
- data/docs/modern-authentication-authorization-landscape.md +558 -0
- data/docs/multi-strategy-authentication-design.md +1401 -0
- data/lib/otto/core/error_handler.rb +19 -8
- data/lib/otto/core/freezable.rb +0 -2
- data/lib/otto/core/middleware_stack.rb +12 -8
- data/lib/otto/core/router.rb +25 -31
- data/lib/otto/errors.rb +92 -0
- data/lib/otto/mcp/rate_limiting.rb +6 -2
- data/lib/otto/mcp/schema_validation.rb +1 -1
- data/lib/otto/response_handlers/json.rb +1 -3
- data/lib/otto/response_handlers/view.rb +1 -1
- data/lib/otto/route_handlers/base.rb +86 -1
- data/lib/otto/route_handlers/class_method.rb +17 -68
- data/lib/otto/route_handlers/instance_method.rb +18 -58
- data/lib/otto/route_handlers/logic_class.rb +95 -92
- data/lib/otto/security/authentication/auth_strategy.rb +2 -2
- 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 +167 -329
- data/lib/otto/security/authentication/strategy_result.rb +9 -9
- data/lib/otto/security/authorization_error.rb +1 -1
- data/lib/otto/security/config.rb +3 -3
- data/lib/otto/security/rate_limiter.rb +7 -3
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +47 -3
- metadata +7 -3
- data/changelog.d/20251103_235431_delano_86_improve_error_logging.rst +0 -15
- data/changelog.d/20251109_025012_claude_fix_backtrace_sanitization.rst +0 -37
|
@@ -1,71 +1,31 @@
|
|
|
1
1
|
# lib/otto/route_handlers/instance_method.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
9
|
# Handler for instance methods (existing Otto pattern)
|
|
11
|
-
#
|
|
10
|
+
# Route syntax: Controller#action
|
|
11
|
+
#
|
|
12
|
+
# Controller instances receive full Rack request/response access:
|
|
13
|
+
# - initialize(request, response) with Rack::Request and Rack::Response
|
|
14
|
+
# - Direct access to sessions, cookies, headers, and the raw env
|
|
15
|
+
#
|
|
16
|
+
# Use this handler for endpoints requiring request-level control (logout,
|
|
17
|
+
# session management, cookie manipulation, custom header handling).
|
|
12
18
|
class InstanceMethodHandler < BaseHandler
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
instance = target_class.new(req, res)
|
|
24
|
-
result = instance.send(route_definition.method_name)
|
|
25
|
-
|
|
26
|
-
# Only handle response if response_type is not default
|
|
27
|
-
if route_definition.response_type != 'default'
|
|
28
|
-
handle_response(result, res, {
|
|
29
|
-
instance: instance,
|
|
30
|
-
request: req,
|
|
31
|
-
})
|
|
32
|
-
end
|
|
33
|
-
rescue StandardError => e
|
|
34
|
-
# Check if we're being called through Otto's integrated context (vs direct handler testing)
|
|
35
|
-
# In integrated context, let Otto's centralized error handler manage the response
|
|
36
|
-
# In direct testing context, handle errors locally for unit testing
|
|
37
|
-
if otto_instance
|
|
38
|
-
# Store handler context in env for centralized error handler
|
|
39
|
-
handler_name = "#{target_class}##{route_definition.method_name}"
|
|
40
|
-
env['otto.handler'] = handler_name
|
|
41
|
-
env['otto.handler_duration'] = Otto::Utils.now_in_μs - start_time
|
|
42
|
-
|
|
43
|
-
raise e # Re-raise to let Otto's centralized error handler manage the response
|
|
44
|
-
else
|
|
45
|
-
# Direct handler testing context - handle errors locally with security improvements
|
|
46
|
-
error_id = SecureRandom.hex(8)
|
|
47
|
-
Otto.logger.error "[#{error_id}] #{e.class}: #{e.message}"
|
|
48
|
-
Otto.logger.debug "[#{error_id}] Backtrace: #{e.backtrace.join("\n")}" if Otto.debug
|
|
49
|
-
|
|
50
|
-
res.status = 500
|
|
51
|
-
res.headers['content-type'] = 'text/plain'
|
|
52
|
-
|
|
53
|
-
if Otto.env?(:dev, :development)
|
|
54
|
-
res.write "Server error (ID: #{error_id}). Check logs for details."
|
|
55
|
-
else
|
|
56
|
-
res.write 'An error occurred. Please try again later.'
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
# Add security headers if available
|
|
60
|
-
if otto_instance.respond_to?(:security_config) && otto_instance.security_config
|
|
61
|
-
otto_instance.security_config.security_headers.each do |header, value|
|
|
62
|
-
res.headers[header] = value
|
|
63
|
-
end
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
finalize_response(res)
|
|
19
|
+
protected
|
|
20
|
+
|
|
21
|
+
# Invoke the instance method on the target class
|
|
22
|
+
# @param req [Rack::Request] Request object
|
|
23
|
+
# @param res [Rack::Response] Response object
|
|
24
|
+
# @return [Array] [result, context] for handle_response
|
|
25
|
+
def invoke_target(req, res)
|
|
26
|
+
instance = target_class.new(req, res)
|
|
27
|
+
result = instance.send(route_definition.method_name)
|
|
28
|
+
[result, { instance: instance, request: req }]
|
|
69
29
|
end
|
|
70
30
|
end
|
|
71
31
|
end
|
|
@@ -1,109 +1,112 @@
|
|
|
1
1
|
# lib/otto/route_handlers/logic_class.rb
|
|
2
2
|
#
|
|
3
3
|
# frozen_string_literal: true
|
|
4
|
-
require 'json'
|
|
5
|
-
require 'securerandom'
|
|
6
4
|
|
|
7
5
|
require_relative 'base'
|
|
8
6
|
|
|
9
7
|
class Otto
|
|
10
8
|
module RouteHandlers
|
|
11
9
|
# Handler for Logic classes (new in Otto Framework Enhancement)
|
|
12
|
-
#
|
|
13
|
-
# Logic classes use signature: initialize(context, params, locale)
|
|
10
|
+
#
|
|
11
|
+
# Logic classes use a constrained signature: initialize(context, params, locale)
|
|
12
|
+
# - context: The authentication strategy result (user info, session data)
|
|
13
|
+
# - params: Merged request parameters (URL params + body + extra_params)
|
|
14
|
+
# - locale: The locale string from env['otto.locale']
|
|
15
|
+
#
|
|
16
|
+
# IMPORTANT: Logic classes do NOT receive the Rack request or env hash.
|
|
17
|
+
# This is intentional - Logic classes work with clean, authenticated contexts.
|
|
18
|
+
# For endpoints requiring direct request access (sessions, cookies, headers,
|
|
19
|
+
# or logout flows), use controller handlers (Controller#action or Controller.action).
|
|
14
20
|
class LogicClassHandler < BaseHandler
|
|
15
|
-
|
|
16
|
-
start_time = Otto::Utils.now_in_μs
|
|
17
|
-
req = Rack::Request.new(env)
|
|
18
|
-
res = Rack::Response.new
|
|
21
|
+
protected
|
|
19
22
|
|
|
23
|
+
# Invoke Logic class with constrained signature
|
|
24
|
+
# @param req [Rack::Request] Request object
|
|
25
|
+
# @param res [Rack::Response] Response object
|
|
26
|
+
# @return [Array] [result, context] for handle_response
|
|
27
|
+
def invoke_target(req, _res)
|
|
28
|
+
env = req.env
|
|
29
|
+
|
|
30
|
+
# Get strategy result (guaranteed to exist from RouteAuthWrapper)
|
|
31
|
+
strategy_result = env['otto.strategy_result']
|
|
32
|
+
|
|
33
|
+
# Extract params including JSON body parsing
|
|
34
|
+
logic_params = extract_logic_params(req, env)
|
|
35
|
+
|
|
36
|
+
# Get locale
|
|
37
|
+
locale = env['otto.locale'] || 'en'
|
|
38
|
+
|
|
39
|
+
# Instantiate Logic class
|
|
40
|
+
logic = target_class.new(strategy_result, logic_params, locale)
|
|
41
|
+
|
|
42
|
+
# Execute standard Logic class lifecycle
|
|
43
|
+
logic.raise_concerns if logic.respond_to?(:raise_concerns)
|
|
44
|
+
|
|
45
|
+
result = if logic.respond_to?(:process)
|
|
46
|
+
logic.process
|
|
47
|
+
else
|
|
48
|
+
logic.call || logic
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
context = {
|
|
52
|
+
logic_instance: logic,
|
|
53
|
+
request: req,
|
|
54
|
+
status_code: logic.respond_to?(:status_code) ? logic.status_code : nil,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
[result, context]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Extract logic parameters including JSON body parsing
|
|
61
|
+
# @param req [Rack::Request] Request object
|
|
62
|
+
# @param env [Hash] Rack environment
|
|
63
|
+
# @return [Hash] Parameters for Logic class
|
|
64
|
+
def extract_logic_params(req, env)
|
|
65
|
+
# req.params already has extra_params merged and indifferent_params applied
|
|
66
|
+
# by setup_request_response in BaseHandler
|
|
67
|
+
logic_params = req.params.dup
|
|
68
|
+
|
|
69
|
+
# Handle JSON request bodies
|
|
70
|
+
if req.content_type&.include?('application/json') && req.body.size.positive?
|
|
71
|
+
logic_params = parse_json_body(req, env, logic_params)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
logic_params
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Parse JSON request body with error handling
|
|
78
|
+
# @param req [Rack::Request] Request object
|
|
79
|
+
# @param env [Hash] Rack environment
|
|
80
|
+
# @param logic_params [Hash] Current parameters
|
|
81
|
+
# @return [Hash] Parameters with JSON merged (or original if parsing fails)
|
|
82
|
+
def parse_json_body(req, env, logic_params)
|
|
20
83
|
begin
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
)
|
|
49
|
-
end
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
locale = env['otto.locale'] || 'en'
|
|
53
|
-
|
|
54
|
-
logic = target_class.new(strategy_result, logic_params, locale)
|
|
55
|
-
|
|
56
|
-
# Execute standard Logic class lifecycle
|
|
57
|
-
logic.raise_concerns if logic.respond_to?(:raise_concerns)
|
|
58
|
-
|
|
59
|
-
result = if logic.respond_to?(:process)
|
|
60
|
-
logic.process
|
|
61
|
-
else
|
|
62
|
-
logic.call || logic
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
# Handle response with Logic instance context
|
|
66
|
-
handle_response(result, res, {
|
|
67
|
-
logic_instance: logic,
|
|
68
|
-
request: req,
|
|
69
|
-
status_code: logic.respond_to?(:status_code) ? logic.status_code : nil,
|
|
70
|
-
})
|
|
71
|
-
rescue StandardError => e
|
|
72
|
-
# Check if we're being called through Otto's integrated context (vs direct handler testing)
|
|
73
|
-
# In integrated context, let Otto's centralized error handler manage the response
|
|
74
|
-
# In direct testing context, handle errors locally for unit testing
|
|
75
|
-
if otto_instance
|
|
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
|
-
|
|
81
|
-
raise e # Re-raise to let Otto's centralized error handler manage the response
|
|
82
|
-
else
|
|
83
|
-
# Direct handler testing context - handle errors locally with security improvements
|
|
84
|
-
error_id = SecureRandom.hex(8)
|
|
85
|
-
Otto.logger.error "[#{error_id}] #{e.class}: #{e.message}"
|
|
86
|
-
Otto.logger.debug "[#{error_id}] Backtrace: #{e.backtrace.join("\n")}" if Otto.debug
|
|
87
|
-
|
|
88
|
-
res.status = 500
|
|
89
|
-
res.headers['content-type'] = 'text/plain'
|
|
90
|
-
|
|
91
|
-
if Otto.env?(:dev, :development)
|
|
92
|
-
res.write "Server error (ID: #{error_id}). Check logs for details."
|
|
93
|
-
else
|
|
94
|
-
res.write 'An error occurred. Please try again later.'
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
# Add security headers if available
|
|
98
|
-
if otto_instance.respond_to?(:security_config) && otto_instance.security_config
|
|
99
|
-
otto_instance.security_config.security_headers.each do |header, value|
|
|
100
|
-
res.headers[header] = value
|
|
101
|
-
end
|
|
102
|
-
end
|
|
103
|
-
end
|
|
84
|
+
req.body.rewind
|
|
85
|
+
json_data = JSON.parse(req.body.read)
|
|
86
|
+
logic_params = logic_params.merge(json_data) if json_data.is_a?(Hash)
|
|
87
|
+
rescue JSON::ParserError => e
|
|
88
|
+
# Base context pattern: create once, reuse for correlation
|
|
89
|
+
log_context = Otto::LoggingHelpers.request_context(env)
|
|
90
|
+
|
|
91
|
+
Otto.structured_log(:error, 'JSON parsing error',
|
|
92
|
+
log_context.merge(
|
|
93
|
+
handler: handler_name,
|
|
94
|
+
error: e.message,
|
|
95
|
+
error_class: e.class.name,
|
|
96
|
+
duration: Otto::Utils.now_in_μs - @start_time
|
|
97
|
+
))
|
|
98
|
+
|
|
99
|
+
Otto::LoggingHelpers.log_backtrace(e,
|
|
100
|
+
log_context.merge(handler: handler_name))
|
|
104
101
|
end
|
|
105
102
|
|
|
106
|
-
|
|
103
|
+
logic_params
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Format handler name for Logic routes
|
|
107
|
+
# @return [String] Handler name in format "ClassName#call"
|
|
108
|
+
def handler_name
|
|
109
|
+
"#{target_class.name}#call"
|
|
107
110
|
end
|
|
108
111
|
end
|
|
109
112
|
end
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# lib/otto/security/authentication/auth_strategy.rb
|
|
2
2
|
#
|
|
3
3
|
# frozen_string_literal: true
|
|
4
|
-
|
|
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
|
|
|
@@ -33,7 +33,7 @@ class Otto
|
|
|
33
33
|
user: user,
|
|
34
34
|
auth_method: auth_method || self.class.name.split('::').last,
|
|
35
35
|
metadata: metadata,
|
|
36
|
-
strategy_name: nil
|
|
36
|
+
strategy_name: nil # Will be set by RouteAuthWrapper
|
|
37
37
|
)
|
|
38
38
|
end
|
|
39
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
|