rack_jwt_aegis 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RackJwtAegis
4
+ class MultiTenantValidator
5
+ def initialize(config)
6
+ @config = config
7
+ end
8
+
9
+ def validate(request, payload)
10
+ validate_subdomain(request, payload) if @config.validate_subdomain?
11
+ validate_company_slug(request, payload) if @config.validate_company_slug?
12
+ validate_company_header(request, payload) if @config.company_header_name
13
+ end
14
+
15
+ private
16
+
17
+ # Level 1 Multi-Tenant: Top-level tenant (Company-Group) validation via subdomain
18
+ def validate_subdomain(request, payload)
19
+ request_host = request.host
20
+ return if request_host.nil? || request_host.empty?
21
+
22
+ # Extract subdomain from request host
23
+ request_subdomain = extract_subdomain(request_host)
24
+
25
+ # Get JWT domain claim
26
+ jwt_domain_key = @config.payload_key(:company_group_domain).to_s
27
+ jwt_domain = payload[jwt_domain_key]
28
+
29
+ if jwt_domain.nil? || jwt_domain.empty?
30
+ raise AuthorizationError, 'JWT payload missing company_group_domain for subdomain validation'
31
+ end
32
+
33
+ # Extract subdomain from JWT domain
34
+ jwt_subdomain = extract_subdomain(jwt_domain)
35
+
36
+ # Compare subdomains
37
+ return if subdomains_match?(request_subdomain, jwt_subdomain)
38
+
39
+ raise AuthorizationError,
40
+ "Subdomain access denied: request subdomain '#{request_subdomain}' does not match JWT subdomain '#{jwt_subdomain}'"
41
+ end
42
+
43
+ # Level 2 Multi-Tenant: Sub-level tenant (Company) validation via URL path
44
+ def validate_company_slug(request, payload)
45
+ # Extract company slug from URL path
46
+ company_slug = extract_company_slug_from_path(request.path)
47
+
48
+ return if company_slug.nil? # No company slug in path
49
+
50
+ # Get accessible company slugs from JWT
51
+ jwt_slugs_key = @config.payload_key(:company_slugs).to_s
52
+ accessible_slugs = payload[jwt_slugs_key]
53
+
54
+ if accessible_slugs.nil? || !accessible_slugs.is_a?(Array) || accessible_slugs.empty?
55
+ raise AuthorizationError, 'JWT payload missing or invalid company_slugs for company access validation'
56
+ end
57
+
58
+ # Check if requested company slug is in user's accessible list
59
+ return if accessible_slugs.include?(company_slug)
60
+
61
+ raise AuthorizationError,
62
+ "Company access denied: '#{company_slug}' not in accessible companies #{accessible_slugs}"
63
+ end
64
+
65
+ # Company Group header validation (additional security layer)
66
+ def validate_company_header(request, payload)
67
+ header_name = "HTTP_#{@config.company_header_name.upcase.tr('-', '_')}"
68
+ header_value = request.get_header(header_name)
69
+
70
+ return if header_value.nil? # Header not present, skip validation
71
+
72
+ # Get company group ID from JWT
73
+ jwt_company_group_key = @config.payload_key(:company_group_id).to_s
74
+ jwt_company_group_id = payload[jwt_company_group_key]
75
+
76
+ if jwt_company_group_id.nil?
77
+ raise AuthorizationError, 'JWT payload missing company_group_id for header validation'
78
+ end
79
+
80
+ # Normalize values for comparison (both as strings)
81
+ header_value_str = header_value.to_s
82
+ jwt_value_str = jwt_company_group_id.to_s
83
+
84
+ return if header_value_str == jwt_value_str
85
+
86
+ raise AuthorizationError,
87
+ "Company group header mismatch: header '#{header_value_str}' does not match JWT '#{jwt_value_str}'"
88
+ end
89
+
90
+ def extract_subdomain(host)
91
+ return nil if host.nil? || host.empty?
92
+
93
+ # Handle different host formats:
94
+ # - subdomain.domain.com -> subdomain
95
+ # - subdomain.domain.co.uk -> subdomain
96
+ # - domain.com -> nil (no subdomain)
97
+ # - localhost:3000 -> nil (no subdomain)
98
+
99
+ parts = host.split('.')
100
+
101
+ # Need at least 3 parts for subdomain (subdomain.domain.tld)
102
+ # or 4 parts for country domains (subdomain.domain.co.uk)
103
+ return nil if parts.length < 3
104
+
105
+ # Return first part as subdomain
106
+ parts.first
107
+ end
108
+
109
+ def extract_company_slug_from_path(path)
110
+ return nil if path.nil? || path.empty?
111
+
112
+ # Use configured pattern to extract company slug
113
+ match = @config.company_slug_pattern.match(path)
114
+ return nil unless match && match[1]
115
+
116
+ # Return captured group (company slug)
117
+ match[1]
118
+ end
119
+
120
+ def subdomains_match?(first_subdomain, second_subdomain)
121
+ # Handle nil cases
122
+ return true if first_subdomain.nil? && second_subdomain.nil?
123
+ return false if first_subdomain.nil? || second_subdomain.nil?
124
+
125
+ # Case-insensitive comparison
126
+ first_subdomain.downcase == second_subdomain.downcase
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RackJwtAegis
4
+ class RbacManager
5
+ CACHE_TTL = 300 # 5 minutes default cache TTL
6
+ LAST_UPDATE_KEY = 'last-update'
7
+
8
+ def initialize(config)
9
+ @config = config
10
+ setup_cache_adapters
11
+ end
12
+
13
+ def authorize(request, payload)
14
+ user_id = payload[@config.payload_key(:user_id).to_s]
15
+ raise AuthorizationError, 'User ID missing from JWT payload' if user_id.nil?
16
+
17
+ # Build permission key
18
+ permission_key = build_permission_key(user_id, request)
19
+
20
+ # Check cached permission first (if middleware can write to cache)
21
+ if @permission_cache && @config.cache_write_enabled?
22
+ cached_permission = check_cached_permission(permission_key)
23
+ return if cached_permission == true
24
+
25
+ raise AuthorizationError, 'Access denied - cached permission' if cached_permission == false
26
+ end
27
+
28
+ # Permission not cached or cache miss - check RBAC store
29
+ has_permission = check_rbac_permission(user_id, request)
30
+
31
+ # Cache the result if middleware has write access
32
+ cache_permission_result(permission_key, has_permission) if @permission_cache && @config.cache_write_enabled?
33
+
34
+ return if has_permission
35
+
36
+ raise AuthorizationError, 'Access denied - insufficient permissions'
37
+ end
38
+
39
+ private
40
+
41
+ def setup_cache_adapters
42
+ if @config.cache_write_enabled? && @config.cache_store
43
+ # Shared cache mode - both RBAC and permission cache use same store
44
+ @rbac_cache = CacheAdapter.build(@config.cache_store, @config.cache_options || {})
45
+ @permission_cache = @rbac_cache
46
+ else
47
+ # Separate cache mode - different stores for RBAC and permissions
48
+ if @config.rbac_cache_store
49
+ @rbac_cache = CacheAdapter.build(@config.rbac_cache_store, @config.rbac_cache_options || {})
50
+ end
51
+
52
+ if @config.permission_cache_store
53
+ @permission_cache = CacheAdapter.build(@config.permission_cache_store, @config.permission_cache_options || {})
54
+ end
55
+ end
56
+
57
+ # Ensure we have at least RBAC cache for permission lookups
58
+ return if @rbac_cache
59
+
60
+ raise ConfigurationError, 'RBAC cache store not configured'
61
+ end
62
+
63
+ def build_permission_key(user_id, request)
64
+ "#{user_id}:#{request.host}:#{request.path}:#{request.request_method}"
65
+ end
66
+
67
+ def check_cached_permission(permission_key)
68
+ return nil unless @permission_cache
69
+
70
+ begin
71
+ # Get cached permission entry
72
+ cached_entry = @permission_cache.read(permission_key)
73
+ return nil if cached_entry.nil?
74
+
75
+ # Check if cached entry is still valid based on last-update timestamp
76
+ if cached_entry.is_a?(Hash) && cached_entry['timestamp'] && cached_entry['permission']
77
+ last_update_time = last_update_timestamp
78
+
79
+ return cached_entry['permission'] if last_update_time && cached_entry['timestamp'] >= last_update_time
80
+
81
+ # Cached entry is stale, remove it
82
+ @permission_cache.delete(permission_key)
83
+ return nil
84
+
85
+ end
86
+
87
+ # Invalid cached entry format
88
+ @permission_cache.delete(permission_key)
89
+ nil
90
+ rescue CacheError => e
91
+ # Log cache error but don't fail the request
92
+ warn "RbacManager cache read error: #{e.message}" if @config.debug_mode?
93
+ nil
94
+ end
95
+ end
96
+
97
+ def check_rbac_permission(user_id, request)
98
+ # Build RBAC lookup key
99
+ rbac_key = build_rbac_key(user_id, request.host, request.path, request.request_method)
100
+
101
+ # Check RBAC cache store for permission
102
+ permission_data = @rbac_cache.read(rbac_key)
103
+
104
+ if permission_data.nil?
105
+ # No explicit permission found - default to deny
106
+ false
107
+ else
108
+ # Permission data found - check if it grants access
109
+ case permission_data
110
+ when true, 'true', 1, '1'
111
+ true
112
+ when false, 'false', 0, '0'
113
+ false
114
+ else
115
+ # Complex permission data - delegate to custom logic if available
116
+ evaluate_complex_permission?(permission_data, user_id, request)
117
+ end
118
+ end
119
+ rescue CacheError => e
120
+ # Cache error - fail secure (deny access)
121
+ warn "RbacManager RBAC cache error: #{e.message}" if @config.debug_mode?
122
+ false
123
+ end
124
+
125
+ def cache_permission_result(permission_key, has_permission)
126
+ return unless @permission_cache
127
+
128
+ begin
129
+ current_time = Time.now.to_i
130
+ cache_entry = {
131
+ 'permission' => has_permission,
132
+ 'timestamp' => current_time,
133
+ }
134
+
135
+ @permission_cache.write(permission_key, cache_entry, expires_in: CACHE_TTL)
136
+ rescue CacheError => e
137
+ # Log cache error but don't fail the request
138
+ warn "RbacManager permission cache write error: #{e.message}" if @config.debug_mode?
139
+ end
140
+ end
141
+
142
+ def last_update_timestamp
143
+ @rbac_cache.read(LAST_UPDATE_KEY)
144
+ rescue CacheError => e
145
+ warn "RbacManager last-update read error: #{e.message}" if @config.debug_mode?
146
+ nil
147
+ end
148
+
149
+ def build_rbac_key(user_id, host, path, method)
150
+ # Standard RBAC key format as defined in architecture
151
+ "#{user_id}:#{host}:#{path}:#{method}"
152
+ end
153
+
154
+ def evaluate_complex_permission?(permission_data, user_id, request)
155
+ # Handle complex permission data structures
156
+ case permission_data
157
+ when Hash
158
+ # Permission data is a hash - could contain role-based rules
159
+ evaluate_hash_permission?(permission_data, user_id, request)
160
+ when Array
161
+ # Permission data is an array - could be list of allowed actions
162
+ evaluate_array_permission?(permission_data, request.request_method)
163
+ else
164
+ # Unknown format - default to deny
165
+ false
166
+ end
167
+ end
168
+
169
+ def evaluate_hash_permission?(permission_hash, _user_id, request)
170
+ # Example: {"allowed_methods": ["GET", "POST"], "roles": ["admin"]}
171
+
172
+ # Check allowed methods
173
+ if permission_hash['allowed_methods']
174
+ allowed_methods = Array(permission_hash['allowed_methods'])
175
+ return allowed_methods.include?(request.request_method)
176
+ end
177
+
178
+ # Check roles (would need role information from JWT payload)
179
+ if permission_hash['roles']
180
+ # This would require additional JWT payload inspection
181
+ # For now, default to allowing if roles are specified
182
+ return true
183
+ end
184
+
185
+ # Check boolean permission field
186
+ return !!permission_hash['allowed'] if permission_hash.key?('allowed')
187
+
188
+ # Default deny for unknown hash structure
189
+ false
190
+ end
191
+
192
+ def evaluate_array_permission?(permission_array, request_method)
193
+ # Array of allowed HTTP methods
194
+ permission_array.include?(request_method)
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RackJwtAegis
4
+ class RequestContext
5
+ # Standard environment keys for JWT data
6
+ JWT_PAYLOAD_KEY = 'rack_jwt_aegis.payload'
7
+ USER_ID_KEY = 'rack_jwt_aegis.user_id'
8
+ COMPANY_GROUP_ID_KEY = 'rack_jwt_aegis.company_group_id'
9
+ COMPANY_GROUP_DOMAIN_KEY = 'rack_jwt_aegis.company_group_domain'
10
+ COMPANY_SLUGS_KEY = 'rack_jwt_aegis.company_slugs'
11
+ AUTHENTICATED_KEY = 'rack_jwt_aegis.authenticated'
12
+
13
+ def initialize(config)
14
+ @config = config
15
+ end
16
+
17
+ def set_context(env, payload)
18
+ # Set the full payload
19
+ env[JWT_PAYLOAD_KEY] = payload
20
+
21
+ # Set authentication flag
22
+ env[AUTHENTICATED_KEY] = true
23
+
24
+ # Extract and set commonly used values for easy access
25
+ set_user_context(env, payload)
26
+ set_tenant_context(env, payload)
27
+ end
28
+
29
+ # Class methods for easy access from application code
30
+ def self.authenticated?(env)
31
+ !!env[AUTHENTICATED_KEY]
32
+ end
33
+
34
+ def self.payload(env)
35
+ env[JWT_PAYLOAD_KEY]
36
+ end
37
+
38
+ def self.user_id(env)
39
+ env[USER_ID_KEY]
40
+ end
41
+
42
+ def self.company_group_id(env)
43
+ env[COMPANY_GROUP_ID_KEY]
44
+ end
45
+
46
+ def self.company_group_domain(env)
47
+ env[COMPANY_GROUP_DOMAIN_KEY]
48
+ end
49
+
50
+ def self.company_slugs(env)
51
+ env[COMPANY_SLUGS_KEY] || []
52
+ end
53
+
54
+ def self.current_user_id(request)
55
+ user_id(request.env)
56
+ end
57
+
58
+ def self.current_company_group_id(request)
59
+ company_group_id(request.env)
60
+ end
61
+
62
+ def self.has_company_access?(env, company_slug)
63
+ company_slugs(env).include?(company_slug)
64
+ end
65
+
66
+ private
67
+
68
+ def set_user_context(env, payload)
69
+ user_id_key = @config.payload_key(:user_id).to_s
70
+ user_id = payload[user_id_key]
71
+
72
+ env[USER_ID_KEY] = user_id
73
+ end
74
+
75
+ def set_tenant_context(env, payload)
76
+ # Set company group information
77
+ if @config.validate_subdomain? || @config.payload_mapping.key?(:company_group_id)
78
+ company_group_id_key = @config.payload_key(:company_group_id).to_s
79
+ company_group_id = payload[company_group_id_key]
80
+ env[COMPANY_GROUP_ID_KEY] = company_group_id
81
+ end
82
+
83
+ if @config.validate_subdomain?
84
+ company_domain_key = @config.payload_key(:company_group_domain).to_s
85
+ company_domain = payload[company_domain_key]
86
+ env[COMPANY_GROUP_DOMAIN_KEY] = company_domain
87
+ end
88
+
89
+ # Set company slugs for sub-level tenant access
90
+ return unless @config.validate_company_slug? || @config.payload_mapping.key?(:company_slugs)
91
+
92
+ company_slugs_key = @config.payload_key(:company_slugs).to_s
93
+ company_slugs = payload[company_slugs_key]
94
+
95
+ # Ensure it's an array
96
+ company_slugs = Array(company_slugs) if company_slugs
97
+ env[COMPANY_SLUGS_KEY] = company_slugs || []
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module RackJwtAegis
6
+ class ResponseBuilder
7
+ def initialize(config)
8
+ @config = config
9
+ end
10
+
11
+ def unauthorized_response(message = nil)
12
+ error_response(
13
+ message || @config.unauthorized_response[:error] || 'Authentication required',
14
+ 401,
15
+ )
16
+ end
17
+
18
+ def forbidden_response(message = nil)
19
+ error_response(
20
+ message || @config.forbidden_response[:error] || 'Access denied',
21
+ 403,
22
+ )
23
+ end
24
+
25
+ def error_response(message, status_code)
26
+ response_body = build_error_body(message, status_code)
27
+
28
+ [
29
+ status_code,
30
+ {
31
+ 'Content-Type' => 'application/json',
32
+ 'Content-Length' => response_body.bytesize.to_s,
33
+ 'Cache-Control' => 'no-cache, no-store, must-revalidate',
34
+ 'Pragma' => 'no-cache',
35
+ 'Expires' => '0',
36
+ },
37
+ [response_body],
38
+ ]
39
+ end
40
+
41
+ private
42
+
43
+ def build_error_body(message, status_code)
44
+ error_data = {
45
+ error: message,
46
+ status: status_code,
47
+ timestamp: Time.now.iso8601,
48
+ }
49
+
50
+ # Add additional context in debug mode
51
+ if @config.debug_mode?
52
+ error_data[:middleware] = 'rack_jwt_aegis'
53
+ error_data[:version] = RackJwtAegis::VERSION
54
+ end
55
+
56
+ JSON.generate(error_data)
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RackJwtAegis
4
+ VERSION = '0.0.0'
5
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'rack_jwt_aegis/version'
4
+ require_relative 'rack_jwt_aegis/configuration'
5
+ require_relative 'rack_jwt_aegis/middleware'
6
+ require_relative 'rack_jwt_aegis/jwt_validator'
7
+ require_relative 'rack_jwt_aegis/multi_tenant_validator'
8
+ require_relative 'rack_jwt_aegis/rbac_manager'
9
+ require_relative 'rack_jwt_aegis/cache_adapter'
10
+ require_relative 'rack_jwt_aegis/request_context'
11
+ require_relative 'rack_jwt_aegis/response_builder'
12
+
13
+ module RackJwtAegis
14
+ class Error < StandardError; end
15
+ class ConfigurationError < Error; end
16
+ class AuthenticationError < Error; end
17
+ class AuthorizationError < Error; end
18
+ class CacheError < Error; end
19
+ end
@@ -0,0 +1,4 @@
1
+ module RackJwtAegis
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,93 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rack_jwt_aegis
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Ken C. Demanawa
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2025-08-10 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: jwt
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.10'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.10'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rack
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '3.2'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '3.2'
41
+ description: JWT authentication middleware with multi-tenant support, company validation,
42
+ and subdomain isolation.
43
+ email:
44
+ - kenneth.c.demanawa@gmail.com
45
+ executables: []
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - ".rubocop.yml"
50
+ - CODE_OF_CONDUCT.md
51
+ - LICENSE.txt
52
+ - README.md
53
+ - Rakefile
54
+ - examples/basic_usage.rb
55
+ - lib/rack_jwt_aegis.rb
56
+ - lib/rack_jwt_aegis/cache_adapter.rb
57
+ - lib/rack_jwt_aegis/configuration.rb
58
+ - lib/rack_jwt_aegis/jwt_validator.rb
59
+ - lib/rack_jwt_aegis/middleware.rb
60
+ - lib/rack_jwt_aegis/multi_tenant_validator.rb
61
+ - lib/rack_jwt_aegis/rbac_manager.rb
62
+ - lib/rack_jwt_aegis/request_context.rb
63
+ - lib/rack_jwt_aegis/response_builder.rb
64
+ - lib/rack_jwt_aegis/version.rb
65
+ - sig/rack_jwt_bastion.rbs
66
+ homepage: https://github.com/kanutocd/rack_jwt_aegis
67
+ licenses:
68
+ - MIT
69
+ metadata:
70
+ allowed_push_host: https://rubygems.org
71
+ homepage_uri: https://github.com/kanutocd/rack_jwt_aegis
72
+ source_code_uri: https://github.com/kanutocd/rack_jwt_aegis
73
+ rubygems_mfa_required: 'true'
74
+ post_install_message:
75
+ rdoc_options: []
76
+ require_paths:
77
+ - lib
78
+ required_ruby_version: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: 3.2.0
83
+ required_rubygems_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: '0'
88
+ requirements: []
89
+ rubygems_version: 3.4.19
90
+ signing_key:
91
+ specification_version: 4
92
+ summary: JWT authentication middleware for multi-tenant Rack applications
93
+ test_files: []