rack_jwt_aegis 1.0.1 → 1.1.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.
- checksums.yaml +4 -4
- data/.yardopts +2 -4
- data/CHANGELOG.md +134 -2
- data/README.md +53 -34
- data/Rakefile +0 -8
- data/lib/rack_jwt_aegis/configuration.rb +24 -7
- data/lib/rack_jwt_aegis/debug_logger.rb +1 -17
- data/lib/rack_jwt_aegis/jwt_validator.rb +16 -23
- data/lib/rack_jwt_aegis/middleware.rb +5 -2
- data/lib/rack_jwt_aegis/multi_tenant_validator.rb +29 -55
- data/lib/rack_jwt_aegis/rbac_manager.rb +43 -41
- data/lib/rack_jwt_aegis/request_context.rb +7 -26
- data/lib/rack_jwt_aegis/version.rb +1 -1
- metadata +3 -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: 0e776985ff922d83b9ac75821698125a565dce342be61bd4bf6012fc97dfd82c
|
4
|
+
data.tar.gz: 7bbb4006689d39ea9ec47ad869b01468bbfbddfb0ecbaa430d28ac96f1fd33b7
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 64eb785ccd161bf7f7a7e0ce2b9840007885778b47d35a5528fe586ebcc160aa2f3d356e92868f8931c0789603a78fbc1feef56d6141dbeb322611330404b77a
|
7
|
+
data.tar.gz: 84f45c9c58aed4eadbcfd8a1d78d1a3e0cdd47c9f602ae9047e847738c671c0513dacfc9298207456fa969a44045ec6b8b8ff29fea3e0c29f76357aa4933ddaf
|
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,136 @@ 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.1.0] - 2025-08-14
|
9
|
+
|
10
|
+
### 🚀 Added
|
11
|
+
|
12
|
+
#### Enhanced RBAC Cache Format
|
13
|
+
|
14
|
+
- **Improved Performance**: Changed RBAC cache format from array-based to flat object structure for O(1) role lookup
|
15
|
+
- **Before**: `"permissions": [{ "role-id": ["resource:method"] }]` (O(n) array iteration)
|
16
|
+
- **After**: `"permissions": { "role-id": ["resource:method"] }` (O(1) direct access)
|
17
|
+
- **Developer Experience**: Enhanced error detection with descriptive ConfigurationError exceptions
|
18
|
+
- Catches common migration mistakes when upgrading from array to object format
|
19
|
+
- Provides clear error messages with expected format examples
|
20
|
+
- Helps developers quickly identify and fix RBAC configuration issues
|
21
|
+
|
22
|
+
#### Documentation Improvements
|
23
|
+
|
24
|
+
- **Clean Documentation**: Fixed YARD documentation rendering issues with improved Markdown processing
|
25
|
+
- Added Redcarpet gem for better Markdown parsing in YARD
|
26
|
+
- Removed complex Jekyll integration that was causing rendering issues
|
27
|
+
- Maintained YARD for comprehensive API documentation with cleaner output
|
28
|
+
- **API Documentation**: Enhanced code comments for better YARD rendering
|
29
|
+
- Updated key method documentation with clearer parameter descriptions
|
30
|
+
- Improved examples and usage patterns in documentation
|
31
|
+
- Better integration with YARD's HTML generation
|
32
|
+
|
33
|
+
### 🔧 Fixed
|
34
|
+
|
35
|
+
#### RBAC System Improvements
|
36
|
+
|
37
|
+
- **Cache Format Validation**: Added explicit validation for RBAC permissions format
|
38
|
+
- Raises `ConfigurationError` when permissions is not a Hash
|
39
|
+
- Provides helpful error messages for developers
|
40
|
+
- Maintains backward compatibility for other validation scenarios
|
41
|
+
- **Role Lookup Logic**: Optimized role permission checking with direct hash access
|
42
|
+
- Eliminated unnecessary array iteration in permission validation
|
43
|
+
- Improved performance for applications with many roles
|
44
|
+
- Maintains support for both string and integer role keys
|
45
|
+
|
46
|
+
#### Bug Fixes
|
47
|
+
|
48
|
+
- **Test Coverage**: Fixed `test_check_rbac_format?` test that was using old array format
|
49
|
+
- **Key Resolution**: Fixed JWT payload key resolution bug in `rbac_last_update_timestamp` method
|
50
|
+
- **Validation Logic**: Updated all validation tests to use new flat object format
|
51
|
+
|
52
|
+
### 🏗️ Technical Details
|
53
|
+
|
54
|
+
#### Architecture Changes
|
55
|
+
|
56
|
+
- **RBAC Manager**: Updated `validate_rbac_cache_format` and `check_rbac_format?` methods
|
57
|
+
- Direct hash lookup: `permissions_data[role_id]` instead of array iteration
|
58
|
+
- Improved error handling with specific ConfigurationError exceptions
|
59
|
+
- Enhanced validation with clear developer feedback
|
60
|
+
- **Exception Handling**: Re-raises ConfigurationError while preserving other error handling
|
61
|
+
- Developer errors bubble up for immediate attention
|
62
|
+
- Runtime errors (cache issues) are still handled gracefully
|
63
|
+
- Maintains existing debug logging for troubleshooting
|
64
|
+
|
65
|
+
#### Performance Improvements
|
66
|
+
|
67
|
+
- **O(1) Role Lookup**: Direct hash access for role permissions
|
68
|
+
- **Reduced Memory**: Eliminated nested array structures
|
69
|
+
- **Faster Validation**: Simplified permission checking logic
|
70
|
+
|
71
|
+
#### Developer Experience
|
72
|
+
|
73
|
+
- **Better Error Messages**: Clear configuration error descriptions
|
74
|
+
```ruby
|
75
|
+
# Example error message:
|
76
|
+
"RBAC permissions must be a Hash with role-id keys, not Array.
|
77
|
+
Expected format: {\"role-id\": [\"resource:method\", ...]}, but got: Array"
|
78
|
+
```
|
79
|
+
- **Migration Support**: Catches common mistakes when upgrading cache format
|
80
|
+
- **Improved Documentation**: Cleaner YARD output with better Markdown rendering
|
81
|
+
|
82
|
+
### 📚 Documentation Updates
|
83
|
+
|
84
|
+
- **README.md**: Updated RBAC cache format examples and specifications
|
85
|
+
- **YARD Integration**: Fixed documentation rendering with Redcarpet Markdown processor
|
86
|
+
- **Code Examples**: Updated all examples to use new flat object format
|
87
|
+
|
88
|
+
### 🧪 Testing
|
89
|
+
|
90
|
+
- **Test Coverage Maintained**: All tests updated to use new cache format
|
91
|
+
- **Enhanced Validation**: Added tests for new configuration error scenarios
|
92
|
+
- **Comprehensive Coverage**: Validated migration scenarios and edge cases
|
93
|
+
|
94
|
+
### ⚠️ Migration Guide
|
95
|
+
|
96
|
+
#### Updating RBAC Cache Format
|
97
|
+
|
98
|
+
**Before (v1.0.x):**
|
99
|
+
```ruby
|
100
|
+
Rails.cache.write("permissions", {
|
101
|
+
'last_update' => Time.now.to_i,
|
102
|
+
'permissions' => [
|
103
|
+
{ '123' => ['sales/invoices:get', 'sales/invoices:post'] },
|
104
|
+
{ '456' => ['admin/*:*'] }
|
105
|
+
]
|
106
|
+
})
|
107
|
+
```
|
108
|
+
|
109
|
+
**After (v1.1.0+):**
|
110
|
+
```ruby
|
111
|
+
Rails.cache.write("permissions", {
|
112
|
+
'last_update' => Time.now.to_i,
|
113
|
+
'permissions' => {
|
114
|
+
'123' => ['sales/invoices:get', 'sales/invoices:post'],
|
115
|
+
'456' => ['admin/*:*']
|
116
|
+
}
|
117
|
+
})
|
118
|
+
```
|
119
|
+
|
120
|
+
#### Benefits of Migration
|
121
|
+
|
122
|
+
- **🚀 Better Performance**: O(1) role lookup instead of O(n) iteration
|
123
|
+
- **🛠️ Easier Management**: Direct role access for permission updates
|
124
|
+
- **📝 Cleaner Code**: Simpler data structure that's easier to understand and maintain
|
125
|
+
|
126
|
+
---
|
127
|
+
|
128
|
+
## [1.0.2] - 2025-08-13
|
129
|
+
|
130
|
+
### 🔧 Fixed
|
131
|
+
|
132
|
+
- Add :validate_tenant_id configuration and incorporate this to the MultiTenantValidator#validate_tenant_id_header
|
133
|
+
|
134
|
+
#### Code Quality & Maintenance
|
135
|
+
|
136
|
+
- Refactor the Configuration and MultiTenantValidator
|
137
|
+
|
8
138
|
## [1.0.1] - 2025-08-13
|
9
139
|
|
10
140
|
### 🔧 Fixed
|
@@ -23,7 +153,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
23
153
|
#### Developer Experience
|
24
154
|
|
25
155
|
- **Consistent Logging**: Unified debug log format across all components with automatic timestamp formatting
|
26
|
-
- **Component Identification**: Automatic component name inference for better log traceability
|
156
|
+
- **Component Identification**: Automatic component name inference for better log traceability
|
27
157
|
- **Configurable Log Levels**: Support for info, warn, and error log levels with appropriate output streams
|
28
158
|
|
29
159
|
### 🏗️ Technical Details
|
@@ -37,7 +167,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
37
167
|
#### Testing & Quality
|
38
168
|
|
39
169
|
- **Test Suite**: All 340 tests pass with 975 assertions
|
40
|
-
- **Coverage Maintained**: 98.17% line coverage, 92.83% branch coverage
|
170
|
+
- **Coverage Maintained**: 98.17% line coverage, 92.83% branch coverage
|
41
171
|
- **RBAC Integration**: Verified all role-based authorization tests pass after refactoring
|
42
172
|
- **Zero Regression**: No functional changes, only structural improvements
|
43
173
|
|
@@ -239,5 +369,7 @@ This 1.0.0 release represents a production-ready JWT authentication middleware w
|
|
239
369
|
|
240
370
|
**Deprecations**: None (initial release).
|
241
371
|
|
372
|
+
[1.1.0]: https://github.com/kanutocd/rack_jwt_aegis/releases/tag/v1.1.0
|
373
|
+
[1.0.2]: https://github.com/kanutocd/rack_jwt_aegis/releases/tag/v1.0.2
|
242
374
|
[1.0.1]: https://github.com/kanutocd/rack_jwt_aegis/releases/tag/v1.0.1
|
243
375
|
[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
|
|
@@ -46,25 +46,25 @@ Rack JWT Aegis includes a command-line tool for generating secure JWT secrets:
|
|
46
46
|
|
47
47
|
```bash
|
48
48
|
# Generate a secure JWT secret
|
49
|
-
|
49
|
+
rack_jwt_aegis secret
|
50
50
|
|
51
51
|
# Generate base64-encoded secret
|
52
|
-
|
52
|
+
rack_jwt_aegis secret --format base64
|
53
53
|
|
54
54
|
# Generate secret in environment variable format
|
55
|
-
|
55
|
+
rack_jwt_aegis secret --env
|
56
56
|
|
57
57
|
# Generate multiple secrets
|
58
|
-
|
58
|
+
rack_jwt_aegis secret --count 3
|
59
59
|
|
60
60
|
# Quiet mode (secret only)
|
61
|
-
|
61
|
+
rack_jwt_aegis secret --quiet
|
62
62
|
|
63
63
|
# Custom length (32 bytes)
|
64
|
-
|
64
|
+
rack_jwt_aegis secret --length 32
|
65
65
|
|
66
66
|
# Show help
|
67
|
-
|
67
|
+
rack_jwt_aegis --help
|
68
68
|
```
|
69
69
|
|
70
70
|
### Security Features
|
@@ -82,6 +82,7 @@ Rack JWT Aegis includes a command-line tool for generating secure JWT secrets:
|
|
82
82
|
# config/application.rb
|
83
83
|
config.middleware.insert_before 0, RackJwtAegis::Middleware, {
|
84
84
|
jwt_secret: ENV['JWT_SECRET'],
|
85
|
+
validate_tenant_id: true,
|
85
86
|
tenant_id_header_name: 'X-Tenant-Id',
|
86
87
|
skip_paths: ['/api/v1/login', '/health']
|
87
88
|
}
|
@@ -94,6 +95,7 @@ Rack JWT Aegis includes a command-line tool for generating secure JWT secrets:
|
|
94
95
|
|
95
96
|
use RackJwtAegis::Middleware, {
|
96
97
|
jwt_secret: ENV['JWT_SECRET'],
|
98
|
+
validate_tenant_id: true,
|
97
99
|
tenant_id_header_name: 'X-Tenant-Id',
|
98
100
|
skip_paths: ['/login', '/health']
|
99
101
|
}
|
@@ -126,10 +128,20 @@ RackJwtAegis::Middleware.new(app, {
|
|
126
128
|
jwt_algorithm: 'HS256', # Default: 'HS256'
|
127
129
|
|
128
130
|
# Multi-Tenant Settings
|
131
|
+
validate_tenant_id: true, # Default: false
|
129
132
|
tenant_id_header_name: 'X-Tenant-Id', # Default: 'X-Tenant-Id'
|
130
133
|
validate_subdomain: true, # Default: false
|
131
134
|
validate_pathname_slug: true, # Default: false
|
132
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
|
+
|
133
145
|
# Path Configuration
|
134
146
|
skip_paths: ['/health', '/api/v1/login'],
|
135
147
|
pathname_slug_pattern: /^\/api\/v1\/([^\/]+)\//, # Default pattern
|
@@ -172,7 +184,16 @@ RackJwtAegis::Middleware.new(app, {
|
|
172
184
|
payload['role'] == 'admin' || payload['permissions'].include?('read')
|
173
185
|
},
|
174
186
|
|
175
|
-
#
|
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:
|
176
197
|
payload_mapping: {
|
177
198
|
user_id: :sub, # Map 'sub' claim to user_id
|
178
199
|
tenant_id: :company_group_id, # Map 'company_group_id' claim
|
@@ -190,7 +211,7 @@ Rack JWT Aegis provides multiple strategies for multi-tenant authentication:
|
|
190
211
|
### Subdomain Validation
|
191
212
|
|
192
213
|
```ruby
|
193
|
-
# Validates that the JWT's
|
214
|
+
# Validates that the JWT's subdomain claim matches the request's host subdomain
|
194
215
|
config.validate_subdomain = true
|
195
216
|
```
|
196
217
|
|
@@ -206,6 +227,7 @@ config.pathname_slug_pattern = /^\/api\/v1\/([^\/]+)\//
|
|
206
227
|
|
207
228
|
```ruby
|
208
229
|
# Validates the X-Tenant-Id header against JWT payload
|
230
|
+
config.validate_tenant_id = true
|
209
231
|
config.tenant_id_header_name = 'X-Tenant-Id'
|
210
232
|
```
|
211
233
|
|
@@ -251,7 +273,7 @@ accessible_companies = RackJwtAegis::RequestContext.pathname_slugs(request.env)
|
|
251
273
|
# => ["company-a", "company-b"]
|
252
274
|
|
253
275
|
# Check if user has access to specific company
|
254
|
-
has_access = RackJwtAegis::RequestContext.
|
276
|
+
has_access = RackJwtAegis::RequestContext.has_pathname_slug_access?(request.env, "company-a")
|
255
277
|
# => true
|
256
278
|
|
257
279
|
# Helper methods for request objects
|
@@ -269,7 +291,7 @@ tenant_id = RackJwtAegis::RequestContext.current_tenant_id(request)
|
|
269
291
|
- `pathname_slugs(env)` - Get array of accessible company slugs
|
270
292
|
- `current_user_id(request)` - Helper for request objects
|
271
293
|
- `current_tenant_id(request)` - Helper for request objects
|
272
|
-
- `
|
294
|
+
- `has_pathname_slug_access?(env, slug)` - Check company access
|
273
295
|
|
274
296
|
## JWT Payload Structure
|
275
297
|
|
@@ -297,7 +319,7 @@ When RBAC is enabled, the middleware extracts user roles from the JWT payload fo
|
|
297
319
|
```ruby
|
298
320
|
payload_mapping: {
|
299
321
|
user_id: :user_id,
|
300
|
-
tenant_id: :tenant_id,
|
322
|
+
tenant_id: :tenant_id,
|
301
323
|
subdomain: :subdomain,
|
302
324
|
pathname_slugs: :pathname_slugs,
|
303
325
|
role_ids: :role_ids # Default field for user roles
|
@@ -311,7 +333,7 @@ The middleware looks for roles in the following priority order:
|
|
311
333
|
1. **Configured Field**: Uses the `role_ids` mapping (e.g., if mapped to `:user_roles`, looks for `user_roles` field)
|
312
334
|
2. **Fallback Fields**: If the mapped field is not found, tries these common alternatives:
|
313
335
|
- `roles` - Array of role identifiers
|
314
|
-
- `role` - Single role identifier
|
336
|
+
- `role` - Single role identifier
|
315
337
|
- `user_roles` - Array of user role identifiers
|
316
338
|
- `role_ids` - Array of role IDs (numeric or string)
|
317
339
|
|
@@ -341,7 +363,7 @@ The middleware supports flexible role formats:
|
|
341
363
|
# Array of strings (recommended)
|
342
364
|
"role_ids": ["123", "456", "admin"]
|
343
365
|
|
344
|
-
# Array of integers
|
366
|
+
# Array of integers
|
345
367
|
"role_ids": [123, 456]
|
346
368
|
|
347
369
|
# Single string
|
@@ -417,31 +439,28 @@ When RBAC is enabled, the middleware expects permissions to be stored in the cac
|
|
417
439
|
```json
|
418
440
|
{
|
419
441
|
"last_update": 1640995200,
|
420
|
-
"permissions":
|
421
|
-
|
422
|
-
"
|
423
|
-
|
424
|
-
|
425
|
-
|
426
|
-
|
427
|
-
|
428
|
-
|
429
|
-
|
430
|
-
{
|
431
|
-
"456": ["admin/*:*", "reports:get"]
|
432
|
-
}
|
433
|
-
]
|
442
|
+
"permissions": {
|
443
|
+
"123": [
|
444
|
+
"sales/invoices:get",
|
445
|
+
"sales/invoices:post",
|
446
|
+
"%r{sales/invoices/\\d+}:get",
|
447
|
+
"%r{sales/invoices/\\d+}:put",
|
448
|
+
"users/*:get"
|
449
|
+
],
|
450
|
+
"456": ["admin/*:*", "reports:get"]
|
451
|
+
}
|
434
452
|
}
|
435
453
|
```
|
436
454
|
|
437
455
|
### Format Specification
|
438
456
|
|
439
457
|
- **last_update**: Timestamp for cache invalidation
|
440
|
-
- **permissions**:
|
441
|
-
- **Role ID
|
442
|
-
- **
|
443
|
-
- **resource-endpoint
|
444
|
-
|
458
|
+
- **permissions**: Object containing direct role-to-permissions mapping:
|
459
|
+
- **Role ID** (key): String or numeric identifier
|
460
|
+
- **Permissions** (value): Array of permission strings
|
461
|
+
- **Permission Format**: `"resource-endpoint:http-method"`
|
462
|
+
- **resource-endpoint**: API path (literal string or regex pattern)
|
463
|
+
- **http-method**: `get`, `post`, `put`, `delete`, or `*` (wildcard)
|
445
464
|
|
446
465
|
### Permission Examples
|
447
466
|
|
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/([^/]+)/}
|
@@ -287,7 +298,7 @@ module RackJwtAegis
|
|
287
298
|
end
|
288
299
|
|
289
300
|
def validate_jwt_settings!
|
290
|
-
raise ConfigurationError, 'jwt_secret is required' if jwt_secret.
|
301
|
+
raise ConfigurationError, 'jwt_secret is required' if jwt_secret.to_s.strip.empty?
|
291
302
|
|
292
303
|
valid_algorithms = ['HS256', 'HS384', 'HS512', 'RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512']
|
293
304
|
return if valid_algorithms.include?(jwt_algorithm)
|
@@ -338,17 +349,23 @@ module RackJwtAegis
|
|
338
349
|
end
|
339
350
|
|
340
351
|
def validate_multi_tenant_settings!
|
341
|
-
if validate_pathname_slug? && pathname_slug_pattern.nil?
|
342
|
-
raise ConfigurationError, 'pathname_slug_pattern is required when validate_pathname_slug is true'
|
343
|
-
end
|
344
|
-
|
345
352
|
if validate_subdomain? && !payload_mapping.key?(:subdomain)
|
346
353
|
raise ConfigurationError, 'payload_mapping must include :subdomain when validate_subdomain is true'
|
347
354
|
end
|
348
355
|
|
349
|
-
|
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?
|
350
364
|
|
351
|
-
|
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?
|
352
369
|
end
|
353
370
|
end
|
354
371
|
end
|
@@ -20,7 +20,7 @@ module RackJwtAegis
|
|
20
20
|
timestamp = Time.now.strftime('%Y-%m-%d %H:%M:%S.%L')
|
21
21
|
|
22
22
|
# Determine component name for log prefix
|
23
|
-
component_name = component ||
|
23
|
+
component_name = component || self.class.name.split('::').last || 'RackJwtAegis'
|
24
24
|
|
25
25
|
formatted_message = "[#{timestamp}] #{component_name}: #{message}"
|
26
26
|
|
@@ -31,21 +31,5 @@ module RackJwtAegis
|
|
31
31
|
puts formatted_message
|
32
32
|
end
|
33
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
34
|
end
|
51
35
|
end
|
@@ -19,6 +19,7 @@ module RackJwtAegis
|
|
19
19
|
# @example With multi-tenant validation
|
20
20
|
# config = Configuration.new(
|
21
21
|
# jwt_secret: 'your-secret',
|
22
|
+
# validate_tenant_id: true,
|
22
23
|
# validate_subdomain: true,
|
23
24
|
# validate_pathname_slug: true
|
24
25
|
# )
|
@@ -95,21 +96,14 @@ module RackJwtAegis
|
|
95
96
|
# @param payload [Hash] the JWT payload to validate
|
96
97
|
# @raise [AuthenticationError] if required claims are missing
|
97
98
|
def validate_required_claims(payload)
|
98
|
-
required_claims = []
|
99
|
-
|
100
99
|
# Always require user identification
|
101
|
-
required_claims
|
102
|
-
|
103
|
-
|
104
|
-
if @config.validate_subdomain?
|
105
|
-
required_claims << @config.payload_key(:tenant_id)
|
106
|
-
required_claims << @config.payload_key(:subdomain)
|
107
|
-
end
|
108
|
-
|
100
|
+
required_claims = [@config.payload_key(:user_id)]
|
101
|
+
required_claims << @config.payload_key(:subdomain) if @config.validate_subdomain?
|
102
|
+
required_claims << @config.payload_key(:tenant_id) if @config.validate_tenant_id?
|
109
103
|
required_claims << @config.payload_key(:pathname_slugs) if @config.validate_pathname_slug?
|
104
|
+
required_claims << @config.payload_key(:role_ids) if @config.rbac_enabled?
|
110
105
|
|
111
|
-
missing_claims = required_claims.select { |claim| payload[claim.to_s].
|
112
|
-
|
106
|
+
missing_claims = required_claims.select { |claim| payload[claim.to_s].to_s.empty? }
|
113
107
|
return if missing_claims.empty?
|
114
108
|
|
115
109
|
raise AuthenticationError, "JWT payload missing required claims: #{missing_claims.join(', ')}"
|
@@ -120,25 +114,24 @@ module RackJwtAegis
|
|
120
114
|
# @param payload [Hash] the JWT payload to validate
|
121
115
|
# @raise [AuthenticationError] if claim types are invalid
|
122
116
|
def validate_claim_types(payload)
|
123
|
-
|
124
|
-
|
117
|
+
user_id = payload[@config.payload_key(:user_id).to_s]
|
125
118
|
# User ID should be numeric or string
|
126
|
-
if
|
119
|
+
if user_id.to_s.empty? || (!user_id.is_a?(Numeric) && !user_id.is_a?(String))
|
127
120
|
raise AuthenticationError, 'Invalid user_id format in JWT payload'
|
128
121
|
end
|
129
122
|
|
130
|
-
#
|
131
|
-
if @config.
|
132
|
-
|
133
|
-
if
|
123
|
+
# Tenant ID should be numeric or string (if present)
|
124
|
+
if @config.validate_tenant_id?
|
125
|
+
tenant_id = payload[@config.payload_key(:tenant_id).to_s]
|
126
|
+
if tenant_id.to_s.empty? || (!tenant_id.is_a?(Numeric) && !tenant_id.is_a?(String))
|
134
127
|
raise AuthenticationError, 'Invalid tenant_id format in JWT payload'
|
135
128
|
end
|
136
129
|
end
|
137
130
|
|
138
131
|
# Company group domain should be string (if present)
|
139
132
|
if @config.validate_subdomain?
|
140
|
-
|
141
|
-
if
|
133
|
+
subdomain = payload[@config.payload_key(:subdomain).to_s]
|
134
|
+
if subdomain.to_s.empty? || !subdomain.is_a?(String)
|
142
135
|
raise AuthenticationError, 'Invalid subdomain format in JWT payload'
|
143
136
|
end
|
144
137
|
end
|
@@ -146,8 +139,8 @@ module RackJwtAegis
|
|
146
139
|
# Company slugs should be array (if present)
|
147
140
|
return unless @config.validate_pathname_slug?
|
148
141
|
|
149
|
-
|
150
|
-
return unless
|
142
|
+
pathname_slugs = payload[@config.payload_key(:pathname_slugs).to_s]
|
143
|
+
return unless pathname_slugs && !pathname_slugs.is_a?(Array)
|
151
144
|
|
152
145
|
raise AuthenticationError, 'Invalid pathname_slugs format in JWT payload - must be array'
|
153
146
|
end
|
@@ -19,6 +19,8 @@ module RackJwtAegis
|
|
19
19
|
# @example Advanced usage
|
20
20
|
# use RackJwtAegis::Middleware, {
|
21
21
|
# jwt_secret: ENV['JWT_SECRET'],
|
22
|
+
# validate_tenant_id: true,
|
23
|
+
# validate_pathname_slug: true,
|
22
24
|
# validate_subdomain: true,
|
23
25
|
# rbac_enabled: true,
|
24
26
|
# cache_store: :redis,
|
@@ -142,7 +144,7 @@ module RackJwtAegis
|
|
142
144
|
#
|
143
145
|
# @return [Boolean] true if subdomain or pathname slug validation is enabled
|
144
146
|
def multi_tenant_enabled?
|
145
|
-
@config.validate_subdomain? || @config.validate_pathname_slug?
|
147
|
+
@config.validate_tenant_id? || @config.validate_subdomain? || @config.validate_pathname_slug?
|
146
148
|
end
|
147
149
|
|
148
150
|
# Generate a string describing enabled features for logging
|
@@ -150,8 +152,9 @@ module RackJwtAegis
|
|
150
152
|
# @return [String] comma-separated list of enabled features
|
151
153
|
def enabled_features
|
152
154
|
features = ['JWT']
|
155
|
+
features << 'TenantId' if @config.validate_tenant_id?
|
153
156
|
features << 'Subdomain' if @config.validate_subdomain?
|
154
|
-
features << '
|
157
|
+
features << 'PathnameSlug' if @config.validate_pathname_slug?
|
155
158
|
features << 'RBAC' if @config.rbac_enabled?
|
156
159
|
features.join(', ')
|
157
160
|
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
|
@@ -51,7 +51,7 @@ module RackJwtAegis
|
|
51
51
|
has_permission = check_rbac_permission(user_id, request)
|
52
52
|
|
53
53
|
# Cache the result if middleware has write access
|
54
|
-
cache_permission_result(permission_key, has_permission)
|
54
|
+
cache_permission_result(permission_key, has_permission)
|
55
55
|
|
56
56
|
return if has_permission
|
57
57
|
|
@@ -83,23 +83,21 @@ module RackJwtAegis
|
|
83
83
|
end
|
84
84
|
|
85
85
|
def build_permission_key(user_id, request)
|
86
|
-
|
87
|
-
"#{user_id}:#{full_url}:#{request.request_method.downcase}"
|
86
|
+
"#{user_id}:#{request.host}#{request.path}:#{request.request_method.downcase}"
|
88
87
|
end
|
89
88
|
|
90
89
|
def check_cached_permission(permission_key)
|
91
90
|
return nil unless @permission_cache
|
92
91
|
|
93
92
|
begin
|
94
|
-
# Get the user permissions
|
93
|
+
# Get the cached user permissions
|
95
94
|
user_permissions = @permission_cache.read('user_permissions')
|
96
95
|
return nil if user_permissions.nil? || !user_permissions.is_a?(Hash)
|
97
96
|
|
98
97
|
# First check: If RBAC permissions were updated recently, nuke ALL cached permissions
|
99
98
|
rbac_last_update = rbac_last_update_timestamp
|
100
99
|
if rbac_last_update
|
101
|
-
|
102
|
-
rbac_update_age = current_time - rbac_last_update
|
100
|
+
rbac_update_age = Time.now.to_i - rbac_last_update
|
103
101
|
|
104
102
|
# If RBAC was updated within the TTL period, all cached permissions are invalid
|
105
103
|
if rbac_update_age <= @config.user_permissions_ttl
|
@@ -108,12 +106,11 @@ module RackJwtAegis
|
|
108
106
|
end
|
109
107
|
end
|
110
108
|
|
111
|
-
# Check if permission exists in
|
109
|
+
# Check if permission exists in this format: {"user_id:full_url:method" => timestamp}
|
112
110
|
cached_timestamp = user_permissions[permission_key]
|
113
111
|
return nil unless cached_timestamp.is_a?(Integer)
|
114
112
|
|
115
|
-
|
116
|
-
permission_age = current_time - cached_timestamp
|
113
|
+
permission_age = Time.now.to_i - cached_timestamp
|
117
114
|
|
118
115
|
# Second check: TTL expiration
|
119
116
|
if permission_age > @config.user_permissions_ttl
|
@@ -181,22 +178,23 @@ module RackJwtAegis
|
|
181
178
|
return false
|
182
179
|
end
|
183
180
|
|
184
|
-
#
|
185
|
-
rbac_data['permissions']
|
186
|
-
user_roles.each do |role_id|
|
187
|
-
next unless role_permissions.key?(role_id.to_s) || role_permissions.key?(role_id.to_i)
|
181
|
+
# Get permissions object for direct lookup
|
182
|
+
permissions_data = rbac_data['permissions'] || rbac_data[:permissions]
|
188
183
|
|
189
|
-
|
190
|
-
|
184
|
+
# Check permissions for each user role using direct lookup
|
185
|
+
user_roles.each do |role_id|
|
186
|
+
# Try both string and integer keys for role lookup
|
187
|
+
role_permissions = permissions_data[role_id.to_s] || permissions_data[role_id.to_i]
|
188
|
+
next unless role_permissions
|
191
189
|
|
192
|
-
|
190
|
+
matched_permission = find_matching_permission(role_permissions, request)
|
191
|
+
next unless matched_permission
|
193
192
|
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
end
|
198
|
-
return true
|
193
|
+
# Cache this specific permission match for faster future lookups
|
194
|
+
if @permission_cache && @config.cache_write_enabled?
|
195
|
+
cache_permission_match(user_id, request, role_id, matched_permission)
|
199
196
|
end
|
197
|
+
return true
|
200
198
|
end
|
201
199
|
|
202
200
|
false
|
@@ -208,7 +206,7 @@ module RackJwtAegis
|
|
208
206
|
|
209
207
|
begin
|
210
208
|
rbac_data = @rbac_cache.read('permissions')
|
211
|
-
if rbac_data.is_a?(Hash) && (rbac_data
|
209
|
+
if rbac_data.is_a?(Hash) && (rbac_data.key?('last_update') || rbac_data.key?(:last_update))
|
212
210
|
return rbac_data['last_update'] || rbac_data[:last_update]
|
213
211
|
end
|
214
212
|
|
@@ -393,9 +391,9 @@ module RackJwtAegis
|
|
393
391
|
# Expected format:
|
394
392
|
# {
|
395
393
|
# last_update: timestamp,
|
396
|
-
# permissions:
|
397
|
-
#
|
398
|
-
#
|
394
|
+
# permissions: {
|
395
|
+
# "role-id": ["{resource-endpoint}:{http-method}"]
|
396
|
+
# }
|
399
397
|
# }
|
400
398
|
def validate_rbac_cache_format(rbac_data)
|
401
399
|
return false unless rbac_data.is_a?(Hash)
|
@@ -404,31 +402,35 @@ module RackJwtAegis
|
|
404
402
|
return false unless rbac_data.key?('last_update') || rbac_data.key?(:last_update)
|
405
403
|
return false unless rbac_data.key?('permissions') || rbac_data.key?(:permissions)
|
406
404
|
|
407
|
-
# Get permissions
|
405
|
+
# Get permissions object (now expecting a Hash, not Array)
|
408
406
|
permissions = rbac_data['permissions'] || rbac_data[:permissions]
|
409
|
-
return false unless permissions.is_a?(Array)
|
410
407
|
|
411
|
-
#
|
412
|
-
permissions.
|
413
|
-
|
408
|
+
# If permissions is present but not a Hash, raise an exception to help developers
|
409
|
+
if !permissions.nil? && !permissions.is_a?(Hash)
|
410
|
+
raise ConfigurationError, "RBAC permissions must be a Hash with role-id keys, not #{permissions.class}. " \
|
411
|
+
"Expected format: {\"role-id\": [\"resource:method\", ...]}, " \
|
412
|
+
"but got: #{permissions.class}"
|
413
|
+
end
|
414
414
|
|
415
|
-
|
416
|
-
|
415
|
+
# Return false if permissions is nil (should not happen given the key check above, but defensive)
|
416
|
+
return false if permissions.nil?
|
417
417
|
|
418
|
-
|
419
|
-
|
420
|
-
|
418
|
+
# Validate each role's permissions
|
419
|
+
permissions.each_value do |role_permissions|
|
420
|
+
return false unless role_permissions.is_a?(Array)
|
421
421
|
|
422
|
-
|
423
|
-
|
424
|
-
|
425
|
-
|
426
|
-
|
427
|
-
end
|
422
|
+
# Each permission should be a string in format "endpoint:method"
|
423
|
+
role_permissions.each do |permission|
|
424
|
+
return false unless permission.is_a?(String)
|
425
|
+
# Permission must include ':' (resource:method format) or '*' (wildcard)
|
426
|
+
return false unless permission.include?(':') || permission.include?('*')
|
428
427
|
end
|
429
428
|
end
|
430
429
|
|
431
430
|
true
|
431
|
+
rescue ConfigurationError
|
432
|
+
# Re-raise configuration errors so developers see them
|
433
|
+
raise
|
432
434
|
rescue StandardError => e
|
433
435
|
debug_log("RbacManager: Cache format validation error: #{e.message}", :warn)
|
434
436
|
false
|
@@ -100,42 +100,23 @@ module RackJwtAegis
|
|
100
100
|
tenant_id(request.env)
|
101
101
|
end
|
102
102
|
|
103
|
-
def self.
|
104
|
-
pathname_slugs(env).include?(
|
103
|
+
def self.has_pathname_slug_access?(env, pathname_slug)
|
104
|
+
pathname_slugs(env).include?(pathname_slug)
|
105
105
|
end
|
106
106
|
|
107
107
|
private
|
108
108
|
|
109
109
|
def set_user_context(env, payload)
|
110
|
-
|
111
|
-
user_id = payload[user_id_key]
|
112
|
-
|
113
|
-
env[USER_ID_KEY] = user_id
|
110
|
+
env[USER_ID_KEY] = payload[@config.payload_key(:user_id).to_s]
|
114
111
|
end
|
115
112
|
|
116
113
|
def set_tenant_context(env, payload)
|
117
|
-
# Set
|
118
|
-
|
119
|
-
|
120
|
-
tenant_id = payload[tenant_id_key]
|
121
|
-
env[TENANT_ID_KEY] = tenant_id
|
122
|
-
end
|
123
|
-
|
124
|
-
if @config.validate_subdomain?
|
125
|
-
company_domain_key = @config.payload_key(:subdomain).to_s
|
126
|
-
company_domain = payload[company_domain_key]
|
127
|
-
env[SUBDOMAIN_KEY] = company_domain
|
128
|
-
end
|
129
|
-
|
130
|
-
# Set company slugs for sub-level tenant access
|
114
|
+
# Set multi-tenant information
|
115
|
+
env[TENANT_ID_KEY] = payload[@config.payload_key(:tenant_id).to_s] if @config.validate_tenant_id?
|
116
|
+
env[SUBDOMAIN_KEY] = payload[@config.payload_key(:subdomain).to_s] if @config.validate_subdomain?
|
131
117
|
return unless @config.validate_pathname_slug? || @config.payload_mapping.key?(:pathname_slugs)
|
132
118
|
|
133
|
-
|
134
|
-
pathname_slugs = payload[pathname_slugs_key]
|
135
|
-
|
136
|
-
# Ensure it's an array
|
137
|
-
pathname_slugs = Array(pathname_slugs) if pathname_slugs
|
138
|
-
env[PATHNAME_SLUGS_KEY] = pathname_slugs || []
|
119
|
+
env[PATHNAME_SLUGS_KEY] = Array(payload[@config.payload_key(:pathname_slugs).to_s]).flatten
|
139
120
|
end
|
140
121
|
end
|
141
122
|
end
|
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.1.0
|
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
|
@@ -101,5 +100,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
101
100
|
requirements: []
|
102
101
|
rubygems_version: 3.6.9
|
103
102
|
specification_version: 4
|
104
|
-
summary: JWT authentication middleware for multi-tenant Rack applications
|
103
|
+
summary: JWT authentication and authorization middleware for multi-tenant Rack applications
|
105
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
|