vector_mcp 0.3.0 → 0.3.1

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.
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VectorMCP
4
+ module Security
5
+ # Manages authorization policies for VectorMCP servers
6
+ # Provides fine-grained access control for tools and resources
7
+ class Authorization
8
+ attr_reader :policies, :enabled
9
+
10
+ def initialize
11
+ @policies = {}
12
+ @enabled = false
13
+ end
14
+
15
+ # Enable authorization system
16
+ def enable!
17
+ @enabled = true
18
+ end
19
+
20
+ # Disable authorization (return to pass-through mode)
21
+ def disable!
22
+ @enabled = false
23
+ end
24
+
25
+ # Add an authorization policy for a resource type
26
+ # @param resource_type [Symbol] the type of resource (e.g., :tool, :resource, :prompt)
27
+ # @param block [Proc] the policy block that receives (user, action, resource)
28
+ def add_policy(resource_type, &block)
29
+ @policies[resource_type] = block
30
+ end
31
+
32
+ # Remove an authorization policy
33
+ # @param resource_type [Symbol] the resource type to remove policy for
34
+ def remove_policy(resource_type)
35
+ @policies.delete(resource_type)
36
+ end
37
+
38
+ # Check if a user is authorized to perform an action on a resource
39
+ # @param user [Object] the authenticated user object
40
+ # @param action [Symbol] the action being attempted (e.g., :call, :read, :list)
41
+ # @param resource [Object] the resource being accessed
42
+ # @return [Boolean] true if authorized, false otherwise
43
+ def authorize(user, action, resource)
44
+ return true unless @enabled
45
+
46
+ resource_type = determine_resource_type(resource)
47
+ policy = @policies[resource_type]
48
+
49
+ # If no policy is defined, allow access (opt-in authorization)
50
+ return true unless policy
51
+
52
+ begin
53
+ policy_result = policy.call(user, action, resource)
54
+ policy_result ? true : false
55
+ rescue StandardError
56
+ # Log error but deny access for safety
57
+ false
58
+ end
59
+ end
60
+
61
+ # Check if authorization is required
62
+ # @return [Boolean] true if authorization is enabled
63
+ def required?
64
+ @enabled
65
+ end
66
+
67
+ # Get list of resource types with policies
68
+ # @return [Array<Symbol>] array of resource types
69
+ def policy_types
70
+ @policies.keys
71
+ end
72
+
73
+ private
74
+
75
+ # Determine the resource type from the resource object
76
+ # @param resource [Object] the resource object
77
+ # @return [Symbol] the resource type
78
+ def determine_resource_type(resource)
79
+ case resource
80
+ when VectorMCP::Definitions::Tool
81
+ :tool
82
+ when VectorMCP::Definitions::Resource
83
+ :resource
84
+ when VectorMCP::Definitions::Prompt
85
+ :prompt
86
+ when VectorMCP::Definitions::Root
87
+ :root
88
+ else
89
+ # Try to infer from class name
90
+ class_name = resource.class.name.split("::").last&.downcase
91
+ class_name&.to_sym || :unknown
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VectorMCP
4
+ module Security
5
+ # Security middleware for request authentication and authorization
6
+ # Integrates with transport layers to provide security controls
7
+ class Middleware
8
+ attr_reader :auth_manager, :authorization
9
+
10
+ # Initialize middleware with auth components
11
+ # @param auth_manager [AuthManager] the authentication manager
12
+ # @param authorization [Authorization] the authorization manager
13
+ def initialize(auth_manager, authorization)
14
+ @auth_manager = auth_manager
15
+ @authorization = authorization
16
+ end
17
+
18
+ # Authenticate a request and return session context
19
+ # @param request [Hash] the request object
20
+ # @param strategy [Symbol] optional authentication strategy override
21
+ # @return [SessionContext] the session context for the request
22
+ def authenticate_request(request, strategy: nil)
23
+ auth_result = @auth_manager.authenticate(request, strategy: strategy)
24
+ SessionContext.from_auth_result(auth_result)
25
+ end
26
+
27
+ # Check if a session is authorized for an action on a resource
28
+ # @param session_context [SessionContext] the session context
29
+ # @param action [Symbol] the action being attempted
30
+ # @param resource [Object] the resource being accessed
31
+ # @return [Boolean] true if authorized
32
+ def authorize_action(session_context, action, resource)
33
+ # Always allow if authorization is disabled
34
+ return true unless @authorization.required?
35
+
36
+ # Check authorization policy
37
+ @authorization.authorize(session_context.user, action, resource)
38
+ end
39
+
40
+ # Process a request through the complete security pipeline
41
+ # @param request [Hash] the request object
42
+ # @param action [Symbol] the action being attempted
43
+ # @param resource [Object] the resource being accessed
44
+ # @return [Hash] result with session_context and authorization status
45
+ def process_request(request, action: :access, resource: nil)
46
+ # Step 1: Authenticate the request
47
+ session_context = authenticate_request(request)
48
+
49
+ # Step 2: Check if authentication is required but failed
50
+ if @auth_manager.required? && !session_context.authenticated?
51
+ return {
52
+ success: false,
53
+ error: "Authentication required",
54
+ error_code: "AUTHENTICATION_REQUIRED",
55
+ session_context: session_context
56
+ }
57
+ end
58
+
59
+ # Step 3: Check authorization if resource is provided
60
+ if resource && !authorize_action(session_context, action, resource)
61
+ return {
62
+ success: false,
63
+ error: "Access denied",
64
+ error_code: "AUTHORIZATION_FAILED",
65
+ session_context: session_context
66
+ }
67
+ end
68
+
69
+ # Step 4: Success
70
+ {
71
+ success: true,
72
+ session_context: session_context
73
+ }
74
+ end
75
+
76
+ # Create a request object from different transport formats
77
+ # @param transport_request [Object] the transport-specific request
78
+ # @return [Hash] normalized request object
79
+ def normalize_request(transport_request)
80
+ case transport_request
81
+ when Hash
82
+ # Check if it's a Rack environment (has REQUEST_METHOD key)
83
+ if transport_request.key?("REQUEST_METHOD")
84
+ extract_from_rack_env(transport_request)
85
+ else
86
+ # Already normalized
87
+ transport_request
88
+ end
89
+ else
90
+ # Extract from transport-specific request (e.g., custom objects)
91
+ extract_request_data(transport_request)
92
+ end
93
+ end
94
+
95
+ # Check if security is enabled
96
+ # @return [Boolean] true if any security features are enabled
97
+ def security_enabled?
98
+ @auth_manager.required? || @authorization.required?
99
+ end
100
+
101
+ # Get security status for debugging/monitoring
102
+ # @return [Hash] current security configuration status
103
+ def security_status
104
+ {
105
+ authentication: {
106
+ enabled: @auth_manager.required?,
107
+ strategies: @auth_manager.available_strategies,
108
+ default_strategy: @auth_manager.default_strategy
109
+ },
110
+ authorization: {
111
+ enabled: @authorization.required?,
112
+ policy_types: @authorization.policy_types
113
+ }
114
+ }
115
+ end
116
+
117
+ private
118
+
119
+ # Extract request data from transport-specific formats
120
+ # @param transport_request [Object] the transport request
121
+ # @return [Hash] extracted request data
122
+ def extract_request_data(transport_request)
123
+ # Handle Rack environment (for SSE transport)
124
+ if transport_request.respond_to?(:[]) && transport_request["REQUEST_METHOD"]
125
+ extract_from_rack_env(transport_request)
126
+ else
127
+ # Default fallback
128
+ { headers: {}, params: {} }
129
+ end
130
+ end
131
+
132
+ # Extract data from Rack environment
133
+ # @param env [Hash] the Rack environment
134
+ # @return [Hash] extracted request data
135
+ def extract_from_rack_env(env)
136
+ # Extract headers (HTTP_ prefixed in Rack env)
137
+ headers = {}
138
+ env.each do |key, value|
139
+ next unless key.start_with?("HTTP_")
140
+
141
+ # Convert HTTP_X_API_KEY to X-API-Key format
142
+ header_name = key[5..].split("_").map do |part|
143
+ case part.upcase
144
+ when "API" then "API" # Keep API in all caps
145
+ else part.capitalize
146
+ end
147
+ end.join("-")
148
+ headers[header_name] = value
149
+ end
150
+
151
+ # Add special headers
152
+ headers["Authorization"] = env["HTTP_AUTHORIZATION"] if env["HTTP_AUTHORIZATION"]
153
+ headers["Content-Type"] = env["CONTENT_TYPE"] if env["CONTENT_TYPE"]
154
+
155
+ # Extract query parameters
156
+ params = {}
157
+ if env["QUERY_STRING"]
158
+ require "uri"
159
+ params = URI.decode_www_form(env["QUERY_STRING"]).to_h
160
+ end
161
+
162
+ {
163
+ headers: headers,
164
+ params: params,
165
+ method: env["REQUEST_METHOD"],
166
+ path: env["PATH_INFO"],
167
+ rack_env: env
168
+ }
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VectorMCP
4
+ module Security
5
+ # Represents the security context for a user session
6
+ # Contains authentication and authorization information
7
+ class SessionContext
8
+ attr_reader :user, :authenticated, :permissions, :auth_strategy, :authenticated_at
9
+
10
+ # Initialize session context
11
+ # @param user [Object] the authenticated user object
12
+ # @param authenticated [Boolean] whether the user is authenticated
13
+ # @param auth_strategy [String] the authentication strategy used
14
+ # @param authenticated_at [Time] when authentication occurred
15
+ def initialize(user: nil, authenticated: false, auth_strategy: nil, authenticated_at: nil)
16
+ @user = user
17
+ @authenticated = authenticated
18
+ @auth_strategy = auth_strategy
19
+ @authenticated_at = authenticated_at || Time.now
20
+ @permissions = Set.new
21
+ end
22
+
23
+ # Check if the session is authenticated
24
+ # @return [Boolean] true if authenticated
25
+ def authenticated?
26
+ @authenticated
27
+ end
28
+
29
+ # Check if the user has a specific permission
30
+ # @param permission [String, Symbol] the permission to check
31
+ # @return [Boolean] true if user has the permission
32
+ def can?(permission)
33
+ @permissions.include?(permission.to_s)
34
+ end
35
+
36
+ # Check if the user can perform an action on a resource
37
+ # @param action [String, Symbol] the action (e.g., 'read', 'write', 'execute')
38
+ # @param resource [String, Symbol] the resource (e.g., 'tools', 'resources')
39
+ # @return [Boolean] true if user can perform the action
40
+ def can_access?(action, resource)
41
+ can?("#{action}:#{resource}") || can?("#{action}:*") || can?("*:#{resource}") || can?("*:*")
42
+ end
43
+
44
+ # Add a permission to the session
45
+ # @param permission [String, Symbol] the permission to add
46
+ def add_permission(permission)
47
+ @permissions << permission.to_s
48
+ end
49
+
50
+ # Add multiple permissions to the session
51
+ # @param permissions [Array<String, Symbol>] the permissions to add
52
+ def add_permissions(permissions)
53
+ permissions.each { |perm| add_permission(perm) }
54
+ end
55
+
56
+ # Remove a permission from the session
57
+ # @param permission [String, Symbol] the permission to remove
58
+ def remove_permission(permission)
59
+ @permissions.delete(permission.to_s)
60
+ end
61
+
62
+ # Clear all permissions
63
+ def clear_permissions
64
+ @permissions.clear
65
+ end
66
+
67
+ # Get user identifier for logging/auditing
68
+ # @return [String] a string identifying the user
69
+ def user_identifier
70
+ return "anonymous" unless authenticated?
71
+ return "anonymous" if @user.nil?
72
+
73
+ case @user
74
+ when Hash
75
+ @user[:user_id] || @user[:sub] || @user[:email] || @user[:api_key] || "authenticated_user"
76
+ when String
77
+ @user
78
+ else
79
+ @user.respond_to?(:id) ? @user.id.to_s : "authenticated_user"
80
+ end
81
+ end
82
+
83
+ # Get authentication method used
84
+ # @return [String] the authentication strategy
85
+ def auth_method
86
+ @auth_strategy || "none"
87
+ end
88
+
89
+ # Check if authentication is recent (within specified seconds)
90
+ # @param max_age [Integer] maximum age in seconds (default: 3600 = 1 hour)
91
+ # @return [Boolean] true if authentication is recent
92
+ def auth_recent?(max_age: 3600)
93
+ return false unless authenticated?
94
+
95
+ (Time.now - @authenticated_at) <= max_age
96
+ end
97
+
98
+ # Convert to hash for serialization
99
+ # @return [Hash] session context as hash
100
+ def to_h
101
+ {
102
+ authenticated: @authenticated,
103
+ user_identifier: user_identifier,
104
+ auth_strategy: @auth_strategy,
105
+ authenticated_at: @authenticated_at&.iso8601,
106
+ permissions: @permissions.to_a
107
+ }
108
+ end
109
+
110
+ # Create an anonymous (unauthenticated) session context
111
+ # @return [SessionContext] an unauthenticated session
112
+ def self.anonymous
113
+ new(authenticated: false)
114
+ end
115
+
116
+ # Create an authenticated session context from auth result
117
+ # @param auth_result [Hash] the authentication result
118
+ # @return [SessionContext] an authenticated session
119
+ def self.from_auth_result(auth_result)
120
+ return anonymous unless auth_result&.dig(:authenticated)
121
+
122
+ user_data = auth_result[:user]
123
+
124
+ # Handle special marker for authenticated nil user
125
+ if user_data == :authenticated_nil_user
126
+ new(
127
+ user: nil,
128
+ authenticated: true,
129
+ auth_strategy: "custom",
130
+ authenticated_at: Time.now
131
+ )
132
+ else
133
+ # Extract strategy and authenticated_at only if user_data is a Hash
134
+ strategy = user_data.is_a?(Hash) ? user_data[:strategy] : nil
135
+ auth_time = user_data.is_a?(Hash) ? user_data[:authenticated_at] : nil
136
+
137
+ new(
138
+ user: user_data,
139
+ authenticated: true,
140
+ auth_strategy: strategy,
141
+ authenticated_at: auth_time
142
+ )
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VectorMCP
4
+ module Security
5
+ module Strategies
6
+ # API Key authentication strategy
7
+ # Supports multiple key formats and storage methods
8
+ class ApiKey
9
+ attr_reader :valid_keys
10
+
11
+ # Initialize with a list of valid API keys
12
+ # @param keys [Array<String>] array of valid API keys
13
+ def initialize(keys: [])
14
+ @valid_keys = Set.new(keys.map(&:to_s))
15
+ end
16
+
17
+ # Add a valid API key
18
+ # @param key [String] the API key to add
19
+ def add_key(key)
20
+ @valid_keys << key.to_s
21
+ end
22
+
23
+ # Remove an API key
24
+ # @param key [String] the API key to remove
25
+ def remove_key(key)
26
+ @valid_keys.delete(key.to_s)
27
+ end
28
+
29
+ # Authenticate a request using API key
30
+ # @param request [Hash] the request object
31
+ # @return [Hash, false] user info hash or false if authentication failed
32
+ def authenticate(request)
33
+ api_key = extract_api_key(request)
34
+ return false unless api_key&.length&.positive?
35
+
36
+ if @valid_keys.include?(api_key)
37
+ {
38
+ api_key: api_key,
39
+ strategy: "api_key",
40
+ authenticated_at: Time.now
41
+ }
42
+ else
43
+ false
44
+ end
45
+ end
46
+
47
+ # Check if any keys are configured
48
+ # @return [Boolean] true if keys are available
49
+ def configured?
50
+ !@valid_keys.empty?
51
+ end
52
+
53
+ # Get count of configured keys (for debugging)
54
+ # @return [Integer] number of configured keys
55
+ def key_count
56
+ @valid_keys.size
57
+ end
58
+
59
+ private
60
+
61
+ # Extract API key from various request formats
62
+ # @param request [Hash] the request object
63
+ # @return [String, nil] the extracted API key
64
+ def extract_api_key(request)
65
+ headers = normalize_headers(request)
66
+ params = normalize_params(request)
67
+
68
+ extract_from_headers(headers) || extract_from_params(params)
69
+ end
70
+
71
+ # Normalize headers to handle different formats
72
+ # @param request [Hash] the request object
73
+ # @return [Hash] normalized headers
74
+ def normalize_headers(request)
75
+ # Check if it's a Rack environment (has REQUEST_METHOD)
76
+ if request["REQUEST_METHOD"]
77
+ extract_headers_from_rack_env(request)
78
+ else
79
+ request[:headers] || request["headers"] || {}
80
+ end
81
+ end
82
+
83
+ # Normalize params to handle different formats
84
+ # @param request [Hash] the request object
85
+ # @return [Hash] normalized params
86
+ def normalize_params(request)
87
+ # Check if it's a Rack environment (has REQUEST_METHOD)
88
+ if request["REQUEST_METHOD"]
89
+ extract_params_from_rack_env(request)
90
+ else
91
+ request[:params] || request["params"] || {}
92
+ end
93
+ end
94
+
95
+ # Extract headers from Rack environment
96
+ # @param env [Hash] the Rack environment
97
+ # @return [Hash] normalized headers
98
+ def extract_headers_from_rack_env(env)
99
+ headers = {}
100
+ env.each do |key, value|
101
+ next unless key.start_with?("HTTP_")
102
+
103
+ # Convert HTTP_X_API_KEY to X-API-Key format
104
+ header_name = key[5..].split("_").map do |part|
105
+ case part.upcase
106
+ when "API" then "API" # Keep API in all caps
107
+ else part.capitalize
108
+ end
109
+ end.join("-")
110
+ headers[header_name] = value
111
+ end
112
+
113
+ # Add special headers
114
+ headers["Authorization"] = env["HTTP_AUTHORIZATION"] if env["HTTP_AUTHORIZATION"]
115
+ headers["Content-Type"] = env["CONTENT_TYPE"] if env["CONTENT_TYPE"]
116
+ headers
117
+ end
118
+
119
+ # Extract params from Rack environment
120
+ # @param env [Hash] the Rack environment
121
+ # @return [Hash] normalized params
122
+ def extract_params_from_rack_env(env)
123
+ params = {}
124
+ if env["QUERY_STRING"]
125
+ require "uri"
126
+ params = URI.decode_www_form(env["QUERY_STRING"]).to_h
127
+ end
128
+ params
129
+ end
130
+
131
+ # Extract API key from headers
132
+ # @param headers [Hash] request headers
133
+ # @return [String, nil] the API key if found
134
+ def extract_from_headers(headers)
135
+ # 1. X-API-Key header (most common)
136
+ api_key = headers["X-API-Key"] || headers["x-api-key"]
137
+ return api_key if api_key
138
+
139
+ # 2. Authorization header
140
+ extract_from_auth_header(headers["Authorization"] || headers["authorization"])
141
+ end
142
+
143
+ # Extract API key from Authorization header
144
+ # @param auth_header [String, nil] the authorization header value
145
+ # @return [String, nil] the API key if found
146
+ def extract_from_auth_header(auth_header)
147
+ return nil unless auth_header
148
+
149
+ # Bearer token format
150
+ return auth_header[7..] if auth_header.start_with?("Bearer ")
151
+
152
+ # API-Key scheme format
153
+ return auth_header[8..] if auth_header.start_with?("API-Key ")
154
+
155
+ nil
156
+ end
157
+
158
+ # Extract API key from query parameters
159
+ # @param params [Hash] request parameters
160
+ # @return [String, nil] the API key if found
161
+ def extract_from_params(params)
162
+ params["api_key"] || params["apikey"]
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VectorMCP
4
+ module Security
5
+ module Strategies
6
+ # Custom authentication strategy
7
+ # Allows developers to implement their own authentication logic
8
+ class Custom
9
+ attr_reader :handler
10
+
11
+ # Initialize with a custom authentication handler
12
+ # @param handler [Proc] a block that takes a request and returns user info or false
13
+ def initialize(&handler)
14
+ raise ArgumentError, "Custom authentication strategy requires a block" unless handler
15
+
16
+ @handler = handler
17
+ end
18
+
19
+ # Authenticate a request using the custom handler
20
+ # @param request [Hash] the request object
21
+ # @return [Object, false] result from custom handler or false if authentication failed
22
+ def authenticate(request)
23
+ result = @handler.call(request)
24
+
25
+ # Ensure result includes strategy info if it's successful
26
+ if result && result != false
27
+ format_successful_result(result)
28
+ else
29
+ false
30
+ end
31
+ rescue NoMemoryError, StandardError
32
+ # Log error but return false for security
33
+ false
34
+ end
35
+
36
+ # Check if handler is configured
37
+ # @return [Boolean] true if handler is present
38
+ def configured?
39
+ !@handler.nil?
40
+ end
41
+
42
+ private
43
+
44
+ # Format successful authentication result with strategy metadata
45
+ # @param result [Object] the result from the custom handler
46
+ # @return [Object] formatted result with strategy metadata
47
+ def format_successful_result(result)
48
+ case result
49
+ when Hash
50
+ # If result has a :user key, extract it and use as main user data
51
+ if result.key?(:user)
52
+ user_data = result[:user]
53
+ # For nil user, return a marker that will become nil in session context
54
+ return :authenticated_nil_user if user_data.nil?
55
+
56
+ user_data
57
+ else
58
+ result.merge(strategy: "custom", authenticated_at: Time.now)
59
+ end
60
+ else
61
+ {
62
+ user: result,
63
+ strategy: "custom",
64
+ authenticated_at: Time.now
65
+ }
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end