rack_jwt_aegis 1.0.0 → 1.0.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 775e606ab0475ef440f8d5e0b0cf4fa7834fdcadb9565c6123197437103b2e1f
4
- data.tar.gz: 8dbf8f35c54e16ee86cc31b07a24d8ced023d7aa7684a3f1841cc811ceeb7c97
3
+ metadata.gz: 85f1445f011d0e42c075e3777164d61b215aa04b6f78e535db987c3f0e239c6f
4
+ data.tar.gz: 94fa6e5dd9041709d394e1c42dc5cc938aeb4fb9325218a94cab6352a4a7cf47
5
5
  SHA512:
6
- metadata.gz: 758002b6d87d06d508eb2a548970ef81a16690db287362054927d4833c1200ded1f179fd573f505201d0d10204a9345bbc9f97c83af4c557757a3b39ed6eee8a
7
- data.tar.gz: cfa522bc3373b26e1180507d04808132e4a3ea8aabfb4ac2a1661b62ec4953fc5014b93a89df9ae2d46f9c4af5b89893c298db7c37a9680d1f82bae1608e75ac
6
+ metadata.gz: 0e3e83285e30c8b86efb2977aba6cfeccf728539b689fb80fb658e29ac91e61c455ab48c5793b689293154d3fe985ad6fee71d3b2381261f34b481370c89e8a1
7
+ data.tar.gz: f42e6cdb32d72f06ef14d7c016cbb7970ad5b04f9626b16cc1bf7a4062cdb6b68a899548e80259bac934622b2adc3080121c2b7e82daa84ab93f2df30ad34e63
data/.yardopts CHANGED
@@ -1,16 +1,14 @@
1
1
  --output-dir doc
2
2
  --readme README.md
3
- --markup-provider=kramdown
4
- --markup=markdown
5
3
  --main README.md
4
+ --markup=markdown
6
5
  --protected
7
6
  --private
8
7
  --no-private
9
- --title "RackJwtAegis API Documentation"
8
+ --title "RackJwtAegis Rack Middleware API Documentation"
10
9
  --charset utf-8
11
10
  lib/**/*.rb
12
11
  -
13
12
  README.md
14
13
  LICENSE.txt
15
14
  CODE_OF_CONDUCT.md
16
- adrs/architecture.md
data/CHANGELOG.md CHANGED
@@ -5,6 +5,54 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.0.2] - 2025-08-13
9
+
10
+ ### 🔧 Fixed
11
+
12
+ - Add :validate_tenant_id configuration and incorporate this to the MultiTenantValidator#validate_tenant_id_header
13
+
14
+ #### Code Quality & Maintenance
15
+
16
+ - Refactor the Configuration and MultiTenantValidator
17
+
18
+ ## [1.0.1] - 2025-08-13
19
+
20
+ ### 🔧 Fixed
21
+
22
+ #### Code Quality & Maintenance
23
+
24
+ - **DRY Refactoring**: Eliminated duplicate `debug_log` method implementations by creating a shared `DebugLogger` module
25
+ - Created `lib/rack_jwt_aegis/debug_logger.rb` with consistent debug logging functionality
26
+ - Updated `Middleware` and `RbacManager` classes to include the shared module
27
+ - Improved code maintainability by centralizing debug logging logic
28
+ - Maintains all existing functionality and logging behavior
29
+ - **RBAC Cache Validation**: Enhanced wildcard permission validation in `validate_rbac_cache_format` to support `admin/*` patterns
30
+ - **JWT Payload Resolution**: Fixed JWT payload key resolution to handle string keys consistently across components
31
+ - **Test Coverage**: Maintained high test coverage (98.17% line coverage) after refactoring
32
+
33
+ #### Developer Experience
34
+
35
+ - **Consistent Logging**: Unified debug log format across all components with automatic timestamp formatting
36
+ - **Component Identification**: Automatic component name inference for better log traceability
37
+ - **Configurable Log Levels**: Support for info, warn, and error log levels with appropriate output streams
38
+
39
+ ### 🏗️ Technical Details
40
+
41
+ #### Architecture Improvements
42
+
43
+ - **Shared Module Pattern**: Introduced consistent module inclusion pattern for cross-cutting concerns
44
+ - **Code Organization**: Better separation of concerns with dedicated debug logging module
45
+ - **Maintainability**: Reduced code duplication from ~40 lines to a single shared implementation
46
+
47
+ #### Testing & Quality
48
+
49
+ - **Test Suite**: All 340 tests pass with 975 assertions
50
+ - **Coverage Maintained**: 98.17% line coverage, 92.83% branch coverage
51
+ - **RBAC Integration**: Verified all role-based authorization tests pass after refactoring
52
+ - **Zero Regression**: No functional changes, only structural improvements
53
+
54
+ ---
55
+
8
56
  ## [1.0.0] - 2025-08-13
9
57
 
10
58
  ### 🎉 Initial Release
@@ -201,4 +249,5 @@ This 1.0.0 release represents a production-ready JWT authentication middleware w
201
249
 
202
250
  **Deprecations**: None (initial release).
203
251
 
252
+ [1.0.1]: https://github.com/kanutocd/rack_jwt_aegis/releases/tag/v1.0.1
204
253
  [1.0.0]: https://github.com/kanutocd/rack_jwt_aegis/releases/tag/v1.0.0
data/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
  [![Ruby Version](https://img.shields.io/badge/ruby-%3E%3D%203.1.0-ruby.svg)](https://www.ruby-lang.org/en/)
7
7
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
8
8
 
9
- JWT authentication middleware for hierarchical multi-tenant Rack applications with 2-level tenant support.
9
+ JWT authentication and authorization middleware for hierarchical multi-tenant Rack applications with 2-level tenant support.
10
10
 
11
11
  ## Features
12
12
 
@@ -14,7 +14,9 @@ JWT authentication middleware for hierarchical multi-tenant Rack applications wi
14
14
  - 2-level multi-tenant support (Example: Company-Group → Company, Organization → Department, etc.)
15
15
  - Subdomain-based tenant isolation for top-level tenants
16
16
  - URL pathname slug access control for sub-level tenants
17
+ - **RBAC (Role-Based Access Control)** with flexible role extraction from JWT payloads
17
18
  - Configurable path exclusions for public endpoints
19
+ - **Flexible payload mapping** for custom JWT claim names
18
20
  - Custom payload validation
19
21
  - Debug mode for development
20
22
 
@@ -44,25 +46,25 @@ Rack JWT Aegis includes a command-line tool for generating secure JWT secrets:
44
46
 
45
47
  ```bash
46
48
  # Generate a secure JWT secret
47
- rack-jwt-aegis secret
49
+ rack_jwt_aegis secret
48
50
 
49
51
  # Generate base64-encoded secret
50
- rack-jwt-aegis secret --format base64
52
+ rack_jwt_aegis secret --format base64
51
53
 
52
54
  # Generate secret in environment variable format
53
- rack-jwt-aegis secret --env
55
+ rack_jwt_aegis secret --env
54
56
 
55
57
  # Generate multiple secrets
56
- rack-jwt-aegis secret --count 3
58
+ rack_jwt_aegis secret --count 3
57
59
 
58
60
  # Quiet mode (secret only)
59
- rack-jwt-aegis secret --quiet
61
+ rack_jwt_aegis secret --quiet
60
62
 
61
63
  # Custom length (32 bytes)
62
- rack-jwt-aegis secret --length 32
64
+ rack_jwt_aegis secret --length 32
63
65
 
64
66
  # Show help
65
- rack-jwt-aegis --help
67
+ rack_jwt_aegis --help
66
68
  ```
67
69
 
68
70
  ### Security Features
@@ -80,6 +82,7 @@ Rack JWT Aegis includes a command-line tool for generating secure JWT secrets:
80
82
  # config/application.rb
81
83
  config.middleware.insert_before 0, RackJwtAegis::Middleware, {
82
84
  jwt_secret: ENV['JWT_SECRET'],
85
+ validate_tenant_id: true,
83
86
  tenant_id_header_name: 'X-Tenant-Id',
84
87
  skip_paths: ['/api/v1/login', '/health']
85
88
  }
@@ -92,6 +95,7 @@ Rack JWT Aegis includes a command-line tool for generating secure JWT secrets:
92
95
 
93
96
  use RackJwtAegis::Middleware, {
94
97
  jwt_secret: ENV['JWT_SECRET'],
98
+ validate_tenant_id: true,
95
99
  tenant_id_header_name: 'X-Tenant-Id',
96
100
  skip_paths: ['/login', '/health']
97
101
  }
@@ -124,10 +128,20 @@ RackJwtAegis::Middleware.new(app, {
124
128
  jwt_algorithm: 'HS256', # Default: 'HS256'
125
129
 
126
130
  # Multi-Tenant Settings
131
+ validate_tenant_id: true, # Default: false
127
132
  tenant_id_header_name: 'X-Tenant-Id', # Default: 'X-Tenant-Id'
128
133
  validate_subdomain: true, # Default: false
129
134
  validate_pathname_slug: true, # Default: false
130
135
 
136
+ # Default Payload Mapping:
137
+ payload_mapping: {
138
+ user_id: :user_id,
139
+ tenant_id: :tenant_id,
140
+ subdomain: :subdomain,
141
+ pathname_slugs: :pathname_slugs,
142
+ role_ids: :role_ids,
143
+ },
144
+
131
145
  # Path Configuration
132
146
  skip_paths: ['/health', '/api/v1/login'],
133
147
  pathname_slug_pattern: /^\/api\/v1\/([^\/]+)\//, # Default pattern
@@ -170,19 +184,22 @@ RackJwtAegis::Middleware.new(app, {
170
184
  payload['role'] == 'admin' || payload['permissions'].include?('read')
171
185
  },
172
186
 
173
- # Flexible Payload Mapping
187
+ # Payload Mapping defaults:
188
+ # payload_mapping = {
189
+ # user_id: :user_id,
190
+ # tenant_id: :tenant_id,
191
+ # subdomain: :subdomain,
192
+ # pathname_slugs: :pathname_slugs,
193
+ # role_ids: :role_ids,
194
+ # }
195
+
196
+ # Flexible Payload Mapping can be customized into:
174
197
  payload_mapping: {
175
198
  user_id: :sub, # Map 'sub' claim to user_id
176
199
  tenant_id: :company_group_id, # Map 'company_group_id' claim
177
200
  subdomain: :company_group_domain_name, # Map 'company_group_domain_name' claim
178
- pathname_slugs: :accessible_company_slugs # Map array of accessible companies
179
- },
180
-
181
- # Custom Tenant Extraction
182
- tenant_strategy: :custom,
183
- tenant_extractor: ->(request) {
184
- # Extract tenant from custom header or logic
185
- request.get_header('HTTP_X_TENANT_ID')
201
+ pathname_slugs: :accessible_company_slugs, # Map array of accessible companies
202
+ role_ids: :user_roles # Map 'user_roles' claim for RBAC authorization
186
203
  }
187
204
  })
188
205
  ```
@@ -194,7 +211,7 @@ Rack JWT Aegis provides multiple strategies for multi-tenant authentication:
194
211
  ### Subdomain Validation
195
212
 
196
213
  ```ruby
197
- # Validates that the JWT's domain matches the request subdomain
214
+ # Validates that the JWT's subdomain claim matches the request's host subdomain
198
215
  config.validate_subdomain = true
199
216
  ```
200
217
 
@@ -210,6 +227,7 @@ config.pathname_slug_pattern = /^\/api\/v1\/([^\/]+)\//
210
227
 
211
228
  ```ruby
212
229
  # Validates the X-Tenant-Id header against JWT payload
230
+ config.validate_tenant_id = true
213
231
  config.tenant_id_header_name = 'X-Tenant-Id'
214
232
  ```
215
233
 
@@ -285,7 +303,8 @@ The middleware expects JWT payloads with the following structure:
285
303
  "tenant_id": 67890,
286
304
  "subdomain": "acme-group-of-companies", # the subdomain part of the host of the request url, e.g. `http://acme-group-of-companies.example.com`
287
305
  "pathname_slugs": ["an-acme-company-subsidiary", "another-acme-company-the-user-has-access"], # the user has access to these kind of request urls: https://acme-group-of-companies.example.com/api/v1/an-acme-company-subsidiary/* or https://acme-group-of-companies.example.com/api/v1/another-acme-company-the-user-has-access/
288
- "roles": ["admin", "user"],
306
+ "role_ids": ["123", "456"], # Role IDs for RBAC authorization (can also be integers)
307
+ "roles": ["admin", "user"], # Legacy role names (kept for backward compatibility)
289
308
  "exp": 1640995200,
290
309
  "iat": 1640991600
291
310
  }
@@ -293,6 +312,69 @@ The middleware expects JWT payloads with the following structure:
293
312
 
294
313
  You can customize the payload mapping using the `payload_mapping` configuration option.
295
314
 
315
+ ### RBAC Role Extraction
316
+
317
+ When RBAC is enabled, the middleware extracts user roles from the JWT payload for authorization. The default payload mapping includes:
318
+
319
+ ```ruby
320
+ payload_mapping: {
321
+ user_id: :user_id,
322
+ tenant_id: :tenant_id,
323
+ subdomain: :subdomain,
324
+ pathname_slugs: :pathname_slugs,
325
+ role_ids: :role_ids # Default field for user roles
326
+ }
327
+ ```
328
+
329
+ #### Role Field Resolution
330
+
331
+ The middleware looks for roles in the following priority order:
332
+
333
+ 1. **Configured Field**: Uses the `role_ids` mapping (e.g., if mapped to `:user_roles`, looks for `user_roles` field)
334
+ 2. **Fallback Fields**: If the mapped field is not found, tries these common alternatives:
335
+ - `roles` - Array of role identifiers
336
+ - `role` - Single role identifier
337
+ - `user_roles` - Array of user role identifiers
338
+ - `role_ids` - Array of role IDs (numeric or string)
339
+
340
+ #### Custom Role Field Mapping
341
+
342
+ You can customize the role field using payload mapping:
343
+
344
+ ```ruby
345
+ # Use a custom field name for roles
346
+ payload_mapping: {
347
+ role_ids: :user_permissions # Look for roles in 'user_permissions' field
348
+ }
349
+
350
+ # JWT payload would contain:
351
+ {
352
+ "user_id": 123,
353
+ "user_permissions": ["admin", "manager"],
354
+ ...
355
+ }
356
+ ```
357
+
358
+ #### Role Format Support
359
+
360
+ The middleware supports flexible role formats:
361
+
362
+ ```ruby
363
+ # Array of strings (recommended)
364
+ "role_ids": ["123", "456", "admin"]
365
+
366
+ # Array of integers
367
+ "role_ids": [123, 456]
368
+
369
+ # Single string
370
+ "role_ids": "admin"
371
+
372
+ # Single integer
373
+ "role_ids": 123
374
+ ```
375
+
376
+ All role values are normalized to strings internally for consistent matching against RBAC cache permissions.
377
+
296
378
  ## Security Features
297
379
 
298
380
  - JWT signature verification
@@ -410,7 +492,8 @@ When RBAC is enabled, the middleware expects permissions to be stored in the cac
410
492
 
411
493
  2. **RBAC Permissions Validation**: Full permission evaluation
412
494
 
413
- - Extract user roles from JWT payload (`roles`, `role`, `user_roles`, or `role_ids` field)
495
+ - Extract user roles from JWT payload using configurable field mapping (default: `role_ids`)
496
+ - Fallback to common fields: `roles`, `role`, `user_roles`, or `role_ids` if mapped field not found
414
497
  - Load RBAC permissions collection and validate format
415
498
  - For each user role, check if any permission matches:
416
499
  - Extract resource path from request URL (removes subdomain/pathname slug)
@@ -491,8 +574,8 @@ To install this gem onto your local machine, run `bundle exec rake install`.
491
574
 
492
575
  This project maintains high test coverage:
493
576
 
494
- - **Line Coverage**: 97.8% (668/683 lines)
495
- - **Branch Coverage**: 86.62% (259/299 branches)
577
+ - **Line Coverage**: 97.81% (670/685 lines)
578
+ - **Branch Coverage**: 87.13% (264/303 branches)
496
579
 
497
580
  Run tests with coverage: `bundle exec rake test`
498
581
 
data/Rakefile CHANGED
@@ -14,19 +14,11 @@ begin
14
14
  require 'yard'
15
15
  require 'yard/rake/yardoc_task'
16
16
 
17
- # Load custom GFM configuration if available
18
- begin
19
- require_relative '.yard/yard_gfm_config'
20
- rescue LoadError
21
- # GFM config not available, use default markdown processing
22
- end
23
-
24
17
  YARD::Rake::YardocTask.new do |t|
25
18
  t.files = ['lib/**/*.rb']
26
19
  t.options = [
27
20
  '--output-dir', 'doc',
28
21
  '--readme', 'README.md',
29
- '--markup-provider', 'kramdown',
30
22
  '--markup', 'markdown'
31
23
  ]
32
24
  t.stats_options = ['--list-undoc']
@@ -49,6 +49,10 @@ module RackJwtAegis
49
49
  # @return [Boolean] true if pathname slug validation is enabled
50
50
  attr_accessor :validate_pathname_slug
51
51
 
52
+ # Whether to validate tenant id from request header against the tenant id from JWT payload
53
+ # @return [Boolean] true if tenant id validation is enabled
54
+ attr_accessor :validate_tenant_id
55
+
52
56
  # Whether RBAC (Role-Based Access Control) is enabled
53
57
  # @return [Boolean] true if RBAC is enabled
54
58
  attr_accessor :rbac_enabled
@@ -204,6 +208,12 @@ module RackJwtAegis
204
208
  config_boolean?(validate_pathname_slug)
205
209
  end
206
210
 
211
+ # Check if tenant id validation is enabled
212
+ # @return [Boolean] true if tenant id validation is enabled
213
+ def validate_tenant_id?
214
+ config_boolean?(validate_tenant_id)
215
+ end
216
+
207
217
  # Check if debug mode is enabled
208
218
  # @return [Boolean] true if debug mode is enabled
209
219
  def debug_mode?
@@ -261,6 +271,7 @@ module RackJwtAegis
261
271
  @jwt_algorithm = 'HS256'
262
272
  @validate_subdomain = false
263
273
  @validate_pathname_slug = false
274
+ @validate_tenant_id = false
264
275
  @rbac_enabled = false
265
276
  @tenant_id_header_name = 'X-Tenant-Id'
266
277
  @pathname_slug_pattern = %r{^/api/v1/([^/]+)/}
@@ -273,6 +284,7 @@ module RackJwtAegis
273
284
  tenant_id: :tenant_id,
274
285
  subdomain: :subdomain,
275
286
  pathname_slugs: :pathname_slugs,
287
+ role_ids: :role_ids,
276
288
  }
277
289
  @unauthorized_response = { error: 'Authentication required' }
278
290
  @forbidden_response = { error: 'Access denied' }
@@ -280,12 +292,13 @@ module RackJwtAegis
280
292
 
281
293
  def validate!
282
294
  validate_jwt_settings!
295
+ validate_payload_mapping!
283
296
  validate_cache_settings!
284
297
  validate_multi_tenant_settings!
285
298
  end
286
299
 
287
300
  def validate_jwt_settings!
288
- raise ConfigurationError, 'jwt_secret is required' if jwt_secret.nil? || jwt_secret.empty?
301
+ raise ConfigurationError, 'jwt_secret is required' if jwt_secret.to_s.strip.empty?
289
302
 
290
303
  valid_algorithms = ['HS256', 'HS384', 'HS512', 'RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512']
291
304
  return if valid_algorithms.include?(jwt_algorithm)
@@ -293,6 +306,24 @@ module RackJwtAegis
293
306
  raise ConfigurationError, "Unsupported JWT algorithm: #{jwt_algorithm}"
294
307
  end
295
308
 
309
+ def validate_payload_mapping!
310
+ # Allow nil payload_mapping (will use defaults)
311
+ return if payload_mapping.nil?
312
+
313
+ raise ConfigurationError, 'payload_mapping must be a Hash' unless payload_mapping.is_a?(Hash)
314
+
315
+ # Validate all values are symbols
316
+ invalid_values = payload_mapping.reject { |_key, value| value.is_a?(Symbol) }
317
+ return if invalid_values.empty?
318
+
319
+ raise ConfigurationError, "payload_mapping values must be symbols, invalid: #{invalid_values.inspect}"
320
+
321
+ # NOTE: We don't validate required keys because users may provide
322
+ # partial mappings that are intended to override defaults. The payload_key method
323
+ # handles missing keys by returning the standard key as fallback.
324
+ # This includes RBAC keys - if :role_ids is not mapped, it falls back to 'role_ids'.
325
+ end
326
+
296
327
  def validate_cache_settings!
297
328
  return unless rbac_enabled?
298
329
 
@@ -318,17 +349,23 @@ module RackJwtAegis
318
349
  end
319
350
 
320
351
  def validate_multi_tenant_settings!
321
- if validate_pathname_slug? && pathname_slug_pattern.nil?
322
- raise ConfigurationError, 'pathname_slug_pattern is required when validate_pathname_slug is true'
323
- end
324
-
325
352
  if validate_subdomain? && !payload_mapping.key?(:subdomain)
326
353
  raise ConfigurationError, 'payload_mapping must include :subdomain when validate_subdomain is true'
327
354
  end
328
355
 
329
- return unless validate_pathname_slug? && !payload_mapping.key?(:pathname_slugs)
356
+ if validate_tenant_id?
357
+ error_msg = []
358
+ error_msg << 'payload_mapping must include :tenant_id' unless payload_mapping.key?(:tenant_id)
359
+ error_msg << 'tenant_id_header_name is required' if tenant_id_header_name.to_s.strip.empty?
360
+ raise ConfigurationError, "#{error_msg.join(' and ')} when validate_tenant_id is true" if error_msg.any?
361
+ end
362
+
363
+ return unless validate_pathname_slug?
330
364
 
331
- raise ConfigurationError, 'payload_mapping must include :pathname_slugs when validate_pathname_slug is true'
365
+ error_msg = []
366
+ error_msg << 'payload_mapping must include :pathname_slugs' unless payload_mapping.key?(:pathname_slugs)
367
+ error_msg << 'pathname_slug_pattern is required' if pathname_slug_pattern.to_s.empty?
368
+ raise ConfigurationError, "#{error_msg.join(' and ')} when validate_pathname_slug is true" if error_msg.any?
332
369
  end
333
370
  end
334
371
  end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RackJwtAegis
4
+ # Shared debug logging functionality
5
+ #
6
+ # Provides consistent debug logging across all RackJwtAegis components
7
+ # with configurable log levels and automatic timestamp formatting.
8
+ #
9
+ # @author Ken Camajalan Demanawa
10
+ # @since 1.0.0
11
+ module DebugLogger
12
+ # Log debug message if debug mode is enabled
13
+ #
14
+ # @param message [String] the message to log
15
+ # @param level [Symbol] the log level (:info, :warn, :error) (default: :info)
16
+ # @param component [String] the component name for log prefixing (optional)
17
+ def debug_log(message, level = :info, component = nil)
18
+ return unless @config.debug_mode?
19
+
20
+ timestamp = Time.now.strftime('%Y-%m-%d %H:%M:%S.%L')
21
+
22
+ # Determine component name for log prefix
23
+ component_name = component || infer_component_name
24
+
25
+ formatted_message = "[#{timestamp}] #{component_name}: #{message}"
26
+
27
+ case level
28
+ when :error, :warn
29
+ warn formatted_message
30
+ else
31
+ puts formatted_message
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ # Infer component name from class name
38
+ #
39
+ # @return [String] the inferred component name
40
+ def infer_component_name
41
+ case self.class.name
42
+ when /Middleware/
43
+ 'RackJwtAegis'
44
+ when /RbacManager/
45
+ 'RbacManager'
46
+ else
47
+ self.class.name.split('::').last || 'RackJwtAegis'
48
+ end
49
+ end
50
+ end
51
+ end
@@ -25,6 +25,8 @@ module RackJwtAegis
25
25
  # skip_paths: ['/health', '/api/public/*']
26
26
  # }
27
27
  class Middleware
28
+ include DebugLogger
29
+
28
30
  # Initialize the middleware
29
31
  #
30
32
  # @param app [#call] the Rack application
@@ -65,7 +67,7 @@ module RackJwtAegis
65
67
  token = extract_jwt_token(request)
66
68
  payload = @jwt_validator.validate(token)
67
69
 
68
- debug_log("JWT validation successful for user: #{payload[@config.payload_key(:user_id)]}")
70
+ debug_log("JWT validation successful for user: #{payload[@config.payload_key(:user_id).to_s]}")
69
71
 
70
72
  # Step 3: Multi-tenant validation (if enabled)
71
73
  if multi_tenant_enabled?
@@ -159,8 +161,12 @@ module RackJwtAegis
159
161
  # @param payload [Hash] the JWT payload
160
162
  # @return [Array] array of user role IDs
161
163
  def extract_user_roles(payload)
162
- # Try multiple common role field names
163
- roles = payload['roles'] || payload['role'] || payload['user_roles'] || payload['role_ids']
164
+ # Use configured payload mapping for role_ids, with fallback to common field names
165
+ role_key = @config.payload_key(:role_ids).to_s
166
+ roles = payload[role_key]
167
+
168
+ # If mapped key doesn't exist, try common fallback field names
169
+ roles = payload['roles'] || payload['role'] || payload['user_roles'] || payload['role_ids'] if roles.nil?
164
170
 
165
171
  case roles
166
172
  when Array
@@ -168,19 +174,10 @@ module RackJwtAegis
168
174
  when String, Integer
169
175
  [roles.to_s] # Single role as array
170
176
  else
171
- debug_log("Warning: No valid roles found in JWT payload. Available fields: #{payload.keys}")
177
+ debug_log("Warning: No valid roles found in JWT payload. Looking for '#{role_key}' field. \
178
+ Available fields: #{payload.keys}".squeeze)
172
179
  []
173
180
  end
174
181
  end
175
-
176
- # Log debug message if debug mode is enabled
177
- #
178
- # @param message [String] the message to log
179
- def debug_log(message)
180
- return unless @config.debug_mode?
181
-
182
- timestamp = Time.now.strftime('%Y-%m-%d %H:%M:%S.%L')
183
- puts "[#{timestamp}] RackJwtAegis: #{message}"
184
- end
185
182
  end
186
183
  end
@@ -33,83 +33,72 @@ module RackJwtAegis
33
33
  # @param payload [Hash] the JWT payload containing tenant information
34
34
  # @raise [AuthorizationError] if tenant validation fails
35
35
  def validate(request, payload)
36
- validate_subdomain(request, payload) if @config.validate_subdomain?
37
- validate_pathname_slug(request, payload) if @config.validate_pathname_slug?
38
- validate_company_header(request, payload) if @config.tenant_id_header_name
36
+ validate_subdomain(request, payload)
37
+ validate_pathname_slug(request, payload)
38
+ validate_tenant_id_header(request, payload)
39
39
  end
40
40
 
41
41
  private
42
42
 
43
43
  # Level 1 Multi-Tenant: Top-level tenant (Company-Group) validation via subdomain
44
44
  def validate_subdomain(request, payload)
45
+ return unless @config.validate_subdomain?
46
+
45
47
  request_host = request.host
46
- return if request_host.nil? || request_host.empty?
48
+ return if request_host.to_s.empty?
47
49
 
48
50
  # Extract subdomain from request host
49
- req_subdomain = extract_subdomain(request_host)
51
+ req_subdomain = extract_subdomain(request_host).to_s.downcase
50
52
 
51
53
  # Get JWT domain claim
52
- jwt_domain_key = @config.payload_key(:subdomain).to_s
53
- jwt_domain = payload[jwt_domain_key]
54
-
55
- if jwt_domain.nil? || jwt_domain.empty?
56
- raise AuthorizationError, 'JWT payload missing subdomain for subdomain validation'
57
- end
58
-
59
- # Extract subdomain from JWT domain
60
- jwt_subdomain = extract_subdomain(jwt_domain)
54
+ jwt_claim = payload[@config.payload_key(:subdomain).to_s].to_s.strip.downcase
55
+ raise AuthorizationError, 'JWT payload missing subdomain for subdomain validation' if jwt_claim.empty?
61
56
 
62
57
  # Compare subdomains
63
- return if subdomains_match?(req_subdomain, jwt_subdomain)
58
+ return if req_subdomain.eql?(jwt_claim)
64
59
 
65
60
  raise AuthorizationError,
66
61
  "Subdomain access denied: request subdomain '#{req_subdomain}' " \
67
- "does not match JWT subdomain '#{jwt_subdomain}'"
62
+ "does not match JWT subdomain '#{jwt_claim}'"
68
63
  end
69
64
 
70
65
  # Level 2 Multi-Tenant: Sub-level tenant (Company) validation via URL path
71
66
  def validate_pathname_slug(request, payload)
67
+ return unless @config.validate_pathname_slug?
68
+
72
69
  # Extract company slug from URL path
73
- company_slug = extract_company_slug_from_path(request.path)
70
+ pathname_slug = extract_slug_from_path(request.path)
74
71
 
75
- return if company_slug.nil? # No company slug in path
72
+ return if pathname_slug.nil? # No company slug in path
76
73
 
77
74
  # Get accessible company slugs from JWT
78
- jwt_slugs_key = @config.payload_key(:pathname_slugs).to_s
79
- accessible_slugs = payload[jwt_slugs_key]
75
+ accessible_slugs = payload[@config.payload_key(:pathname_slugs).to_s]
80
76
 
81
77
  if accessible_slugs.nil? || !accessible_slugs.is_a?(Array) || accessible_slugs.empty?
82
78
  raise AuthorizationError, 'JWT payload missing or invalid pathname_slugs for company access validation'
83
79
  end
84
80
 
85
81
  # Check if requested company slug is in user's accessible list
86
- return if accessible_slugs.include?(company_slug)
82
+ return if accessible_slugs.map(&:downcase).include?(pathname_slug)
87
83
 
88
84
  raise AuthorizationError,
89
- "Company access denied: '#{company_slug}' not in accessible companies #{accessible_slugs}"
85
+ "Company access denied: '#{pathname_slug}' not in accessible companies #{accessible_slugs}"
90
86
  end
91
87
 
92
88
  # Company Group header validation (additional security layer)
93
- def validate_company_header(request, payload)
94
- header_name = "HTTP_#{@config.tenant_id_header_name.upcase.tr('-', '_')}"
95
- header_value = request.get_header(header_name)
96
-
97
- return if header_value.nil? # Header not present, skip validation
98
-
99
- # Get company group ID from JWT
100
- jwt_company_group_key = @config.payload_key(:tenant_id).to_s
101
- jwt_tenant_id = payload[jwt_company_group_key]
89
+ def validate_tenant_id_header(request, payload)
90
+ return unless @config.validate_tenant_id?
102
91
 
103
- raise AuthorizationError, 'JWT payload missing tenant_id for header validation' if jwt_tenant_id.nil?
92
+ # Get tenant id from request header
93
+ header_value = request.get_header("HTTP_#{@config.tenant_id_header_name.upcase.tr('-', '_')}").to_s.downcase
94
+ # Get tenant id from JWT payload
95
+ jwt_claim = payload[@config.payload_key(:tenant_id).to_s].to_s.strip.downcase
96
+ raise AuthorizationError, 'JWT payload missing tenant_id for header validation' if jwt_claim.empty?
104
97
 
105
- # Normalize values for comparison (both as strings)
106
- header_value_str = header_value.to_s
107
- jwt_value_str = jwt_tenant_id.to_s
108
-
109
- return if header_value_str == jwt_value_str
98
+ return if !header_value.empty? && header_value.eql?(jwt_claim)
110
99
 
111
100
  raise AuthorizationError,
112
- "Company group header mismatch: header '#{header_value_str}' does not match JWT '#{jwt_value_str}'"
101
+ "Tenant id header mismatch: header '#{header_value}' does not match JWT '#{jwt_claim}'"
113
102
  end
114
103
 
115
104
  def extract_subdomain(host)
@@ -131,24 +120,9 @@ module RackJwtAegis
131
120
  parts.first
132
121
  end
133
122
 
134
- def extract_company_slug_from_path(path)
135
- return nil if path.nil? || path.empty?
136
-
123
+ def extract_slug_from_path(path)
137
124
  # Use configured pattern to extract company slug
138
- match = @config.pathname_slug_pattern.match(path)
139
- return nil unless match && match[1]
140
-
141
- # Return captured group (company slug)
142
- match[1]
143
- end
144
-
145
- def subdomains_match?(first_subdomain, second_subdomain)
146
- # Handle nil cases
147
- return true if first_subdomain.nil? && second_subdomain.nil?
148
- return false if first_subdomain.nil? || second_subdomain.nil?
149
-
150
- # Case-insensitive comparison
151
- first_subdomain.downcase == second_subdomain.downcase
125
+ @config.pathname_slug_pattern.match(path.to_s.strip.downcase)&.to_a&.last
152
126
  end
153
127
  end
154
128
  end
@@ -15,6 +15,8 @@ module RackJwtAegis
15
15
  # manager = RbacManager.new(config)
16
16
  # manager.authorize(request, jwt_payload)
17
17
  class RbacManager
18
+ include DebugLogger
19
+
18
20
  CACHE_TTL = 300 # 5 minutes default cache TTL
19
21
 
20
22
  # Initialize the RBAC manager
@@ -122,14 +124,12 @@ module RackJwtAegis
122
124
  end
123
125
 
124
126
  # Permission is fresh
125
- if @config.debug_mode?
126
- debug_log("Cache hit: #{permission_key} (permission age: \
127
- #{permission_age}s, RBAC age: #{rbac_update_age || 'unknown'}s)".squeeze)
128
- end
127
+ debug_log("Cache hit: #{permission_key} (permission age: \
128
+ #{permission_age}s, RBAC age: #{rbac_update_age || 'unknown'}s)".squeeze)
129
129
  true
130
130
  rescue CacheError => e
131
131
  # Log cache error but don't fail the request
132
- warn "RbacManager cache read error: #{e.message}" if @config.debug_mode?
132
+ debug_log("RbacManager cache read error: #{e.message}", :warn)
133
133
  nil
134
134
  end
135
135
  end
@@ -146,7 +146,7 @@ module RackJwtAegis
146
146
  false
147
147
  rescue CacheError => e
148
148
  # Cache error - fail secure (deny access)
149
- warn "RbacManager RBAC cache error: #{e.message}" if @config.debug_mode?
149
+ debug_log("RbacManager RBAC cache error: #{e.message}", :warn)
150
150
  false
151
151
  end
152
152
 
@@ -166,10 +166,10 @@ module RackJwtAegis
166
166
  # Write back to cache
167
167
  @permission_cache.write('user_permissions', user_permissions, expires_in: CACHE_TTL)
168
168
 
169
- debug_log("Cached permission: #{permission_key} => #{current_time}") if @config.debug_mode?
169
+ debug_log("Cached permission: #{permission_key} => #{current_time}")
170
170
  rescue CacheError => e
171
171
  # Log cache error but don't fail the request
172
- warn "RbacManager permission cache write error: #{e.message}" if @config.debug_mode?
172
+ debug_log("RbacManager permission cache write error: #{e.message}", :warn)
173
173
  end
174
174
  end
175
175
 
@@ -177,7 +177,7 @@ module RackJwtAegis
177
177
  # Extract user roles from JWT payload
178
178
  user_roles = extract_user_roles_from_request(request)
179
179
  if user_roles.nil? || user_roles.empty?
180
- warn 'RbacManager: No user roles found in request context' if @config.debug_mode?
180
+ debug_log('RbacManager: No user roles found in request context', :warn)
181
181
  return false
182
182
  end
183
183
 
@@ -214,7 +214,7 @@ module RackJwtAegis
214
214
 
215
215
  nil
216
216
  rescue CacheError => e
217
- warn "RbacManager RBAC last-update read error: #{e.message}" if @config.debug_mode?
217
+ debug_log("RbacManager RBAC last-update read error: #{e.message}", :warn)
218
218
  nil
219
219
  end
220
220
  end
@@ -233,14 +233,14 @@ module RackJwtAegis
233
233
  # If no permissions remain, remove the entire cache
234
234
  if user_permissions.empty?
235
235
  @permission_cache.delete('user_permissions')
236
- debug_log("Removed last permission, cleared entire cache: #{reason}") if @config.debug_mode?
236
+ debug_log("Removed last permission, cleared entire cache: #{reason}")
237
237
  else
238
238
  # Update the cache with the modified permissions
239
239
  @permission_cache.write('user_permissions', user_permissions, expires_in: CACHE_TTL)
240
- debug_log("Removed stale permission #{permission_key}: #{reason}") if @config.debug_mode?
240
+ debug_log("Removed stale permission #{permission_key}: #{reason}")
241
241
  end
242
242
  rescue CacheError => e
243
- warn "RbacManager stale permission removal error: #{e.message}" if @config.debug_mode?
243
+ debug_log("RbacManager stale permission removal error: #{e.message}", :warn)
244
244
  end
245
245
  end
246
246
 
@@ -250,9 +250,9 @@ module RackJwtAegis
250
250
 
251
251
  begin
252
252
  @permission_cache.delete('user_permissions')
253
- debug_log("Nuked user permissions cache: #{reason}") if @config.debug_mode?
253
+ debug_log("Nuked user permissions cache: #{reason}")
254
254
  rescue CacheError => e
255
- warn "RbacManager cache nuke error: #{e.message}" if @config.debug_mode?
255
+ debug_log("RbacManager cache nuke error: #{e.message}", :warn)
256
256
  end
257
257
  end
258
258
 
@@ -313,10 +313,10 @@ module RackJwtAegis
313
313
  # Write back to cache
314
314
  @permission_cache.write('user_permissions', user_permissions, expires_in: CACHE_TTL)
315
315
 
316
- debug_log("Cached user permission: #{permission_key} => #{current_time}") if @config.debug_mode?
316
+ debug_log("Cached user permission: #{permission_key} => #{current_time}")
317
317
  rescue CacheError => e
318
318
  # Log cache error but don't fail the request
319
- warn "RbacManager permission cache write error: #{e.message}" if @config.debug_mode?
319
+ debug_log("RbacManager permission cache write error: #{e.message}", :warn)
320
320
  end
321
321
  end
322
322
 
@@ -380,7 +380,7 @@ module RackJwtAegis
380
380
  regex = Regexp.new(regex_pattern)
381
381
  return regex.match?(resource_path)
382
382
  rescue RegexpError => e
383
- warn "RbacManager: Invalid regex pattern '#{regex_pattern}': #{e.message}" if @config.debug_mode?
383
+ debug_log("RbacManager: Invalid regex pattern '#{regex_pattern}': #{e.message}", :warn)
384
384
  return false
385
385
  end
386
386
  end
@@ -422,6 +422,7 @@ module RackJwtAegis
422
422
  # Each permission should be a string in format "endpoint:method"
423
423
  role_permissions.each do |permission|
424
424
  return false unless permission.is_a?(String)
425
+ # Permission must include ':' (resource:method format)
425
426
  return false unless permission.include?(':')
426
427
  end
427
428
  end
@@ -429,16 +430,8 @@ module RackJwtAegis
429
430
 
430
431
  true
431
432
  rescue StandardError => e
432
- warn "RbacManager: Cache format validation error: #{e.message}" if @config.debug_mode?
433
+ debug_log("RbacManager: Cache format validation error: #{e.message}", :warn)
433
434
  false
434
435
  end
435
-
436
- # Log debug message if debug mode is enabled
437
- def debug_log(message)
438
- return unless @config.debug_mode?
439
-
440
- timestamp = Time.now.strftime('%Y-%m-%d %H:%M:%S.%L')
441
- puts "[#{timestamp}] RbacManager: #{message}"
442
- end
443
436
  end
444
437
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RackJwtAegis
4
- VERSION = '1.0.0'
4
+ VERSION = '1.0.2'
5
5
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative 'rack_jwt_aegis/version'
4
4
  require_relative 'rack_jwt_aegis/configuration'
5
+ require_relative 'rack_jwt_aegis/debug_logger'
5
6
  require_relative 'rack_jwt_aegis/middleware'
6
7
  require_relative 'rack_jwt_aegis/jwt_validator'
7
8
  require_relative 'rack_jwt_aegis/multi_tenant_validator'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rack_jwt_aegis
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ken C. Demanawa
@@ -44,7 +44,7 @@ dependencies:
44
44
  - !ruby/object:Gem::Version
45
45
  version: '3.2'
46
46
  description: |-
47
- JWT authentication midleware with multi-tenant suport,\
47
+ JWT authentication and authorization midleware with multi-tenant suport,\
48
48
  company validation, and subdomain isolation.
49
49
  email:
50
50
  - kenneth.c.demanawa@gmail.com
@@ -54,7 +54,6 @@ extensions: []
54
54
  extra_rdoc_files: []
55
55
  files:
56
56
  - ".rubocop.yml"
57
- - ".yard/yard_gfm_config.rb"
58
57
  - ".yardopts"
59
58
  - CHANGELOG.md
60
59
  - CODE_OF_CONDUCT.md
@@ -68,6 +67,7 @@ files:
68
67
  - lib/rack_jwt_aegis.rb
69
68
  - lib/rack_jwt_aegis/cache_adapter.rb
70
69
  - lib/rack_jwt_aegis/configuration.rb
70
+ - lib/rack_jwt_aegis/debug_logger.rb
71
71
  - lib/rack_jwt_aegis/jwt_validator.rb
72
72
  - lib/rack_jwt_aegis/middleware.rb
73
73
  - lib/rack_jwt_aegis/multi_tenant_validator.rb
@@ -100,5 +100,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
100
100
  requirements: []
101
101
  rubygems_version: 3.6.9
102
102
  specification_version: 4
103
- summary: JWT authentication middleware for multi-tenant Rack applications
103
+ summary: JWT authentication and authorization middleware for multi-tenant Rack applications
104
104
  test_files: []
@@ -1,21 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Custom YARD configuration for GitHub Flavored Markdown support
4
- #
5
- # This configuration ensures the kramdown-parser-gfm gem is available for
6
- # proper rendering of fenced code blocks (```language) in YARD documentation.
7
- #
8
- # The actual GFM parsing integration is handled by YARD when kramdown is
9
- # configured with the GFM input parser.
10
-
11
- begin
12
- require 'kramdown'
13
- require 'kramdown-parser-gfm'
14
-
15
- # Ensure GFM parser is available - YARD will use it automatically
16
- # when kramdown processes markdown with input: 'GFM'
17
-
18
- rescue LoadError => e
19
- # Fallback gracefully if GFM parser is not available
20
- puts "Warning: kramdown-parser-gfm not available, fenced code blocks may not render properly: #{e.message}"
21
- end