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 +4 -4
- data/.yardopts +2 -4
- data/CHANGELOG.md +49 -0
- data/README.md +105 -22
- data/Rakefile +0 -8
- data/lib/rack_jwt_aegis/configuration.rb +44 -7
- data/lib/rack_jwt_aegis/debug_logger.rb +51 -0
- data/lib/rack_jwt_aegis/middleware.rb +11 -14
- data/lib/rack_jwt_aegis/multi_tenant_validator.rb +29 -55
- data/lib/rack_jwt_aegis/rbac_manager.rb +20 -27
- data/lib/rack_jwt_aegis/version.rb +1 -1
- data/lib/rack_jwt_aegis.rb +1 -0
- metadata +4 -4
- data/.yard/yard_gfm_config.rb +0 -21
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 85f1445f011d0e42c075e3777164d61b215aa04b6f78e535db987c3f0e239c6f
|
4
|
+
data.tar.gz: 94fa6e5dd9041709d394e1c42dc5cc938aeb4fb9325218a94cab6352a4a7cf47
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
[](https://www.ruby-lang.org/en/)
|
7
7
|
[](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
|
-
|
49
|
+
rack_jwt_aegis secret
|
48
50
|
|
49
51
|
# Generate base64-encoded secret
|
50
|
-
|
52
|
+
rack_jwt_aegis secret --format base64
|
51
53
|
|
52
54
|
# Generate secret in environment variable format
|
53
|
-
|
55
|
+
rack_jwt_aegis secret --env
|
54
56
|
|
55
57
|
# Generate multiple secrets
|
56
|
-
|
58
|
+
rack_jwt_aegis secret --count 3
|
57
59
|
|
58
60
|
# Quiet mode (secret only)
|
59
|
-
|
61
|
+
rack_jwt_aegis secret --quiet
|
60
62
|
|
61
63
|
# Custom length (32 bytes)
|
62
|
-
|
64
|
+
rack_jwt_aegis secret --length 32
|
63
65
|
|
64
66
|
# Show help
|
65
|
-
|
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
|
-
#
|
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
|
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
|
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
|
-
"
|
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
|
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.
|
495
|
-
- **Branch Coverage**:
|
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.
|
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
|
-
|
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
|
-
|
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
|
-
#
|
163
|
-
|
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.
|
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)
|
37
|
-
validate_pathname_slug(request, payload)
|
38
|
-
|
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.
|
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
|
-
|
53
|
-
|
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
|
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 '#{
|
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
|
-
|
70
|
+
pathname_slug = extract_slug_from_path(request.path)
|
74
71
|
|
75
|
-
return if
|
72
|
+
return if pathname_slug.nil? # No company slug in path
|
76
73
|
|
77
74
|
# Get accessible company slugs from JWT
|
78
|
-
|
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?(
|
82
|
+
return if accessible_slugs.map(&:downcase).include?(pathname_slug)
|
87
83
|
|
88
84
|
raise AuthorizationError,
|
89
|
-
"Company access denied: '#{
|
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
|
94
|
-
|
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
|
-
|
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
|
-
|
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
|
-
"
|
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
|
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
|
-
|
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
|
-
|
126
|
-
|
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
|
-
|
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
|
-
|
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}")
|
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
|
-
|
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
|
-
|
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
|
-
|
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}")
|
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}")
|
240
|
+
debug_log("Removed stale permission #{permission_key}: #{reason}")
|
241
241
|
end
|
242
242
|
rescue CacheError => e
|
243
|
-
|
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}")
|
253
|
+
debug_log("Nuked user permissions cache: #{reason}")
|
254
254
|
rescue CacheError => e
|
255
|
-
|
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}")
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
data/lib/rack_jwt_aegis.rb
CHANGED
@@ -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.
|
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: []
|
data/.yard/yard_gfm_config.rb
DELETED
@@ -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
|