rack_jwt_aegis 0.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +9 -0
- data/.yard/yard_gfm_config.rb +21 -0
- data/.yardopts +16 -0
- data/CHANGELOG.md +243 -0
- data/README.md +408 -53
- data/Rakefile +52 -0
- data/bin/console +11 -0
- data/bin/docs +20 -0
- data/bin/setup +8 -0
- data/exe/rack_jwt_aegis +235 -0
- data/lib/rack_jwt_aegis/configuration.rb +225 -44
- data/lib/rack_jwt_aegis/debug_logger.rb +51 -0
- data/lib/rack_jwt_aegis/jwt_validator.rb +56 -14
- data/lib/rack_jwt_aegis/middleware.rb +75 -8
- data/lib/rack_jwt_aegis/multi_tenant_validator.rb +43 -18
- data/lib/rack_jwt_aegis/rbac_manager.rb +320 -80
- data/lib/rack_jwt_aegis/request_context.rb +64 -23
- data/lib/rack_jwt_aegis/version.rb +1 -1
- data/lib/rack_jwt_aegis.rb +37 -1
- metadata +25 -13
- data/examples/basic_usage.rb +0 -85
- /data/sig/{rack_jwt_bastion.rbs → rack_jwt_aegis.rbs} +0 -0
data/README.md
CHANGED
@@ -1,16 +1,22 @@
|
|
1
1
|
# Rack JWT Aegis
|
2
2
|
|
3
|
-
|
3
|
+
[](https://badge.fury.io/rb/rack_jwt_aegis)
|
4
|
+
[](https://github.com/kanutocd/rack_jwt_aegis/actions)
|
5
|
+
[](https://codecov.io/gh/kanutocd/rack_jwt_aegis)
|
6
|
+
[](https://www.ruby-lang.org/en/)
|
7
|
+
[](https://opensource.org/licenses/MIT)
|
4
8
|
|
5
|
-
|
9
|
+
JWT authentication middleware for hierarchical multi-tenant Rack applications with 2-level tenant support.
|
6
10
|
|
7
11
|
## Features
|
8
12
|
|
9
13
|
- JWT token validation with configurable algorithms
|
10
|
-
- 2-level multi-tenant support (Company-Group → Company, Organization → Department, etc.)
|
14
|
+
- 2-level multi-tenant support (Example: Company-Group → Company, Organization → Department, etc.)
|
11
15
|
- Subdomain-based tenant isolation for top-level tenants
|
12
|
-
-
|
16
|
+
- URL pathname slug access control for sub-level tenants
|
17
|
+
- **RBAC (Role-Based Access Control)** with flexible role extraction from JWT payloads
|
13
18
|
- Configurable path exclusions for public endpoints
|
19
|
+
- **Flexible payload mapping** for custom JWT claim names
|
14
20
|
- Custom payload validation
|
15
21
|
- Debug mode for development
|
16
22
|
|
@@ -18,61 +24,95 @@ JWT authentication middleware for hierarchical multi-tenant Rack applications wi
|
|
18
24
|
|
19
25
|
Add this line to your application's Gemfile:
|
20
26
|
|
21
|
-
```
|
22
|
-
gem 'rack_jwt_aegis'
|
27
|
+
```bash
|
28
|
+
gem 'rack_jwt_aegis'
|
23
29
|
```
|
24
30
|
|
25
31
|
And then execute:
|
26
32
|
|
27
33
|
```bash
|
28
|
-
bundle install
|
34
|
+
bundle install
|
29
35
|
```
|
30
36
|
|
31
37
|
Or install it yourself as:
|
32
38
|
|
33
39
|
```bash
|
34
|
-
gem install rack_jwt_aegis
|
40
|
+
gem install rack_jwt_aegis
|
35
41
|
```
|
36
42
|
|
43
|
+
## CLI Tool
|
44
|
+
|
45
|
+
Rack JWT Aegis includes a command-line tool for generating secure JWT secrets:
|
46
|
+
|
47
|
+
```bash
|
48
|
+
# Generate a secure JWT secret
|
49
|
+
rack-jwt-aegis secret
|
50
|
+
|
51
|
+
# Generate base64-encoded secret
|
52
|
+
rack-jwt-aegis secret --format base64
|
53
|
+
|
54
|
+
# Generate secret in environment variable format
|
55
|
+
rack-jwt-aegis secret --env
|
56
|
+
|
57
|
+
# Generate multiple secrets
|
58
|
+
rack-jwt-aegis secret --count 3
|
59
|
+
|
60
|
+
# Quiet mode (secret only)
|
61
|
+
rack-jwt-aegis secret --quiet
|
62
|
+
|
63
|
+
# Custom length (32 bytes)
|
64
|
+
rack-jwt-aegis secret --length 32
|
65
|
+
|
66
|
+
# Show help
|
67
|
+
rack-jwt-aegis --help
|
68
|
+
```
|
69
|
+
|
70
|
+
### Security Features
|
71
|
+
|
72
|
+
- Uses `SecureRandom` for cryptographically secure generation
|
73
|
+
- Default 64-byte secrets provide ~512 bits of entropy
|
74
|
+
- Multiple output formats: hex, base64, raw
|
75
|
+
- Environment variable formatting for easy setup
|
76
|
+
|
37
77
|
## Quick Start
|
38
78
|
|
39
79
|
### Rails Application
|
40
80
|
|
41
81
|
```ruby
|
42
|
-
# config/application.rb
|
43
|
-
config.middleware.insert_before 0, RackJwtAegis::Middleware, {
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
}
|
82
|
+
# config/application.rb
|
83
|
+
config.middleware.insert_before 0, RackJwtAegis::Middleware, {
|
84
|
+
jwt_secret: ENV['JWT_SECRET'],
|
85
|
+
tenant_id_header_name: 'X-Tenant-Id',
|
86
|
+
skip_paths: ['/api/v1/login', '/health']
|
87
|
+
}
|
48
88
|
```
|
49
89
|
|
50
90
|
### Sinatra Application
|
51
91
|
|
52
92
|
```ruby
|
53
|
-
require 'rack_jwt_aegis'
|
93
|
+
require 'rack_jwt_aegis'
|
54
94
|
|
55
|
-
use RackJwtAegis::Middleware, {
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
}
|
95
|
+
use RackJwtAegis::Middleware, {
|
96
|
+
jwt_secret: ENV['JWT_SECRET'],
|
97
|
+
tenant_id_header_name: 'X-Tenant-Id',
|
98
|
+
skip_paths: ['/login', '/health']
|
99
|
+
}
|
60
100
|
```
|
61
101
|
|
62
102
|
### Pure Rack Application
|
63
103
|
|
64
104
|
```ruby
|
65
|
-
require 'rack_jwt_aegis'
|
105
|
+
require 'rack_jwt_aegis'
|
66
106
|
|
67
|
-
app = Rack::Builder.new do
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
107
|
+
app = Rack::Builder.new do
|
108
|
+
use RackJwtAegis::Middleware, {
|
109
|
+
jwt_secret: ENV['JWT_SECRET'],
|
110
|
+
validate_subdomain: true,
|
111
|
+
validate_pathname_slug: true
|
112
|
+
}
|
73
113
|
|
74
|
-
|
75
|
-
end
|
114
|
+
run YourApp.new
|
115
|
+
end
|
76
116
|
```
|
77
117
|
|
78
118
|
## Configuration Options
|
@@ -86,13 +126,30 @@ RackJwtAegis::Middleware.new(app, {
|
|
86
126
|
jwt_algorithm: 'HS256', # Default: 'HS256'
|
87
127
|
|
88
128
|
# Multi-Tenant Settings
|
89
|
-
|
129
|
+
tenant_id_header_name: 'X-Tenant-Id', # Default: 'X-Tenant-Id'
|
90
130
|
validate_subdomain: true, # Default: false
|
91
|
-
|
131
|
+
validate_pathname_slug: true, # Default: false
|
92
132
|
|
93
133
|
# Path Configuration
|
94
|
-
skip_paths: ['/health', '/api/v1/login'
|
95
|
-
|
134
|
+
skip_paths: ['/health', '/api/v1/login'],
|
135
|
+
pathname_slug_pattern: /^\/api\/v1\/([^\/]+)\//, # Default pattern
|
136
|
+
|
137
|
+
# RBAC Configuration
|
138
|
+
rbac_enabled: true, # Default: false
|
139
|
+
rbac_cache_store: :redis, # Required when RBAC enabled
|
140
|
+
rbac_cache_options: { url: ENV['REDIS_URL'] },
|
141
|
+
user_permissions_ttl: 3600, # Default: 1800 (30 minutes) - TTL for cached user permissions
|
142
|
+
|
143
|
+
# Cache Store Configuration (choose one approach)
|
144
|
+
# Option 1: Shared cache for both RBAC and permissions
|
145
|
+
cache_store: :memory, # :memory, :redis, :memcached, :solid_cache
|
146
|
+
cache_options: { url: ENV['REDIS_URL'] },
|
147
|
+
|
148
|
+
# Option 2: Separate cache stores for RBAC and permissions
|
149
|
+
rbac_cache_store: :redis, # For RBAC permissions data
|
150
|
+
rbac_cache_options: { url: ENV['REDIS_URL'] },
|
151
|
+
permission_cache_store: :memory, # For cached user permissions
|
152
|
+
permission_cache_options: {},
|
96
153
|
|
97
154
|
# Response Customization
|
98
155
|
unauthorized_response: { error: 'Authentication required' },
|
@@ -118,16 +175,10 @@ RackJwtAegis::Middleware.new(app, {
|
|
118
175
|
# Flexible Payload Mapping
|
119
176
|
payload_mapping: {
|
120
177
|
user_id: :sub, # Map 'sub' claim to user_id
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
# Custom Tenant Extraction
|
127
|
-
tenant_strategy: :custom,
|
128
|
-
tenant_extractor: ->(request) {
|
129
|
-
# Extract tenant from custom header or logic
|
130
|
-
request.get_header('HTTP_X_TENANT_ID')
|
178
|
+
tenant_id: :company_group_id, # Map 'company_group_id' claim
|
179
|
+
subdomain: :company_group_domain_name, # Map 'company_group_domain_name' claim
|
180
|
+
pathname_slugs: :accessible_company_slugs, # Map array of accessible companies
|
181
|
+
role_ids: :user_roles # Map 'user_roles' claim for RBAC authorization
|
131
182
|
}
|
132
183
|
})
|
133
184
|
```
|
@@ -147,17 +198,79 @@ config.validate_subdomain = true
|
|
147
198
|
|
148
199
|
```ruby
|
149
200
|
# Validates that the requested company slug is accessible to the user
|
150
|
-
config.
|
151
|
-
config.
|
201
|
+
config.validate_pathname_slug = true
|
202
|
+
config.pathname_slug_pattern = /^\/api\/v1\/([^\/]+)\//
|
152
203
|
```
|
153
204
|
|
154
205
|
### Header-Based Validation
|
155
206
|
|
156
207
|
```ruby
|
157
|
-
# Validates the X-
|
158
|
-
config.
|
208
|
+
# Validates the X-Tenant-Id header against JWT payload
|
209
|
+
config.tenant_id_header_name = 'X-Tenant-Id'
|
210
|
+
```
|
211
|
+
|
212
|
+
The value from this request header entry will be used to verify the JWT's mapped `tenant_id` claim.
|
213
|
+
|
214
|
+
## Request Context Access
|
215
|
+
|
216
|
+
After successful JWT authentication, the middleware stores user context in the Rack environment for easy access in your application:
|
217
|
+
|
218
|
+
### Basic Usage
|
219
|
+
|
220
|
+
```ruby
|
221
|
+
# In your controllers or middleware
|
222
|
+
class UsersController < ApplicationController
|
223
|
+
def index
|
224
|
+
# Check if request is authenticated
|
225
|
+
return unauthorized unless RackJwtAegis::RequestContext.authenticated?(request.env)
|
226
|
+
|
227
|
+
# Get user information
|
228
|
+
user_id = RackJwtAegis::RequestContext.user_id(request.env)
|
229
|
+
tenant_id = RackJwtAegis::RequestContext.tenant_id(request.env)
|
230
|
+
|
231
|
+
# Access full JWT payload
|
232
|
+
payload = RackJwtAegis::RequestContext.payload(request.env)
|
233
|
+
roles = payload['roles']
|
234
|
+
|
235
|
+
# Your business logic here
|
236
|
+
users = User.where(tenant_id: tenant_id)
|
237
|
+
render json: users
|
238
|
+
end
|
239
|
+
end
|
159
240
|
```
|
160
241
|
|
242
|
+
### Multi-Tenant Context
|
243
|
+
|
244
|
+
```ruby
|
245
|
+
# Access subdomain information
|
246
|
+
subdomain = RackJwtAegis::RequestContext.subdomain(request.env)
|
247
|
+
# => "acme-group-of-companies"
|
248
|
+
|
249
|
+
# Check pathname slug access
|
250
|
+
accessible_companies = RackJwtAegis::RequestContext.pathname_slugs(request.env)
|
251
|
+
# => ["company-a", "company-b"]
|
252
|
+
|
253
|
+
# Check if user has access to specific company
|
254
|
+
has_access = RackJwtAegis::RequestContext.has_company_access?(request.env, "company-a")
|
255
|
+
# => true
|
256
|
+
|
257
|
+
# Helper methods for request objects
|
258
|
+
user_id = RackJwtAegis::RequestContext.current_user_id(request)
|
259
|
+
tenant_id = RackJwtAegis::RequestContext.current_tenant_id(request)
|
260
|
+
```
|
261
|
+
|
262
|
+
### Available Context Methods
|
263
|
+
|
264
|
+
- `authenticated?(env)` - Check if request is authenticated
|
265
|
+
- `payload(env)` - Get full JWT payload hash
|
266
|
+
- `user_id(env)` - Get authenticated user ID
|
267
|
+
- `tenant_id(env)` - Get tenant/company group ID
|
268
|
+
- `subdomain(env)` - Get subdomain from JWT
|
269
|
+
- `pathname_slugs(env)` - Get array of accessible company slugs
|
270
|
+
- `current_user_id(request)` - Helper for request objects
|
271
|
+
- `current_tenant_id(request)` - Helper for request objects
|
272
|
+
- `has_company_access?(env, slug)` - Check company access
|
273
|
+
|
161
274
|
## JWT Payload Structure
|
162
275
|
|
163
276
|
The middleware expects JWT payloads with the following structure:
|
@@ -165,10 +278,11 @@ The middleware expects JWT payloads with the following structure:
|
|
165
278
|
```json
|
166
279
|
{
|
167
280
|
"user_id": 12345,
|
168
|
-
"
|
169
|
-
"
|
170
|
-
"
|
171
|
-
"
|
281
|
+
"tenant_id": 67890,
|
282
|
+
"subdomain": "acme-group-of-companies", # the subdomain part of the host of the request url, e.g. `http://acme-group-of-companies.example.com`
|
283
|
+
"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/
|
284
|
+
"role_ids": ["123", "456"], # Role IDs for RBAC authorization (can also be integers)
|
285
|
+
"roles": ["admin", "user"], # Legacy role names (kept for backward compatibility)
|
172
286
|
"exp": 1640995200,
|
173
287
|
"iat": 1640991600
|
174
288
|
}
|
@@ -176,6 +290,69 @@ The middleware expects JWT payloads with the following structure:
|
|
176
290
|
|
177
291
|
You can customize the payload mapping using the `payload_mapping` configuration option.
|
178
292
|
|
293
|
+
### RBAC Role Extraction
|
294
|
+
|
295
|
+
When RBAC is enabled, the middleware extracts user roles from the JWT payload for authorization. The default payload mapping includes:
|
296
|
+
|
297
|
+
```ruby
|
298
|
+
payload_mapping: {
|
299
|
+
user_id: :user_id,
|
300
|
+
tenant_id: :tenant_id,
|
301
|
+
subdomain: :subdomain,
|
302
|
+
pathname_slugs: :pathname_slugs,
|
303
|
+
role_ids: :role_ids # Default field for user roles
|
304
|
+
}
|
305
|
+
```
|
306
|
+
|
307
|
+
#### Role Field Resolution
|
308
|
+
|
309
|
+
The middleware looks for roles in the following priority order:
|
310
|
+
|
311
|
+
1. **Configured Field**: Uses the `role_ids` mapping (e.g., if mapped to `:user_roles`, looks for `user_roles` field)
|
312
|
+
2. **Fallback Fields**: If the mapped field is not found, tries these common alternatives:
|
313
|
+
- `roles` - Array of role identifiers
|
314
|
+
- `role` - Single role identifier
|
315
|
+
- `user_roles` - Array of user role identifiers
|
316
|
+
- `role_ids` - Array of role IDs (numeric or string)
|
317
|
+
|
318
|
+
#### Custom Role Field Mapping
|
319
|
+
|
320
|
+
You can customize the role field using payload mapping:
|
321
|
+
|
322
|
+
```ruby
|
323
|
+
# Use a custom field name for roles
|
324
|
+
payload_mapping: {
|
325
|
+
role_ids: :user_permissions # Look for roles in 'user_permissions' field
|
326
|
+
}
|
327
|
+
|
328
|
+
# JWT payload would contain:
|
329
|
+
{
|
330
|
+
"user_id": 123,
|
331
|
+
"user_permissions": ["admin", "manager"],
|
332
|
+
...
|
333
|
+
}
|
334
|
+
```
|
335
|
+
|
336
|
+
#### Role Format Support
|
337
|
+
|
338
|
+
The middleware supports flexible role formats:
|
339
|
+
|
340
|
+
```ruby
|
341
|
+
# Array of strings (recommended)
|
342
|
+
"role_ids": ["123", "456", "admin"]
|
343
|
+
|
344
|
+
# Array of integers
|
345
|
+
"role_ids": [123, 456]
|
346
|
+
|
347
|
+
# Single string
|
348
|
+
"role_ids": "admin"
|
349
|
+
|
350
|
+
# Single integer
|
351
|
+
"role_ids": 123
|
352
|
+
```
|
353
|
+
|
354
|
+
All role values are normalized to strings internally for consistent matching against RBAC cache permissions.
|
355
|
+
|
179
356
|
## Security Features
|
180
357
|
|
181
358
|
- JWT signature verification
|
@@ -184,10 +361,165 @@ You can customize the payload mapping using the `payload_mapping` configuration
|
|
184
361
|
- Subdomain validation
|
185
362
|
- Request path filtering for public endpoints
|
186
363
|
|
187
|
-
## Performance
|
364
|
+
## Performance & Caching
|
188
365
|
|
189
366
|
- Skip paths are checked before JWT processing
|
190
367
|
- Low memory footprint
|
368
|
+
- Multi-tier permission caching system for RBAC performance
|
369
|
+
- TTL-based cache invalidation for user permissions
|
370
|
+
- Support for multiple cache stores: `:memory`, `:redis`, `:memcached`, `:solid_cache`
|
371
|
+
|
372
|
+
### Cache Store Configuration
|
373
|
+
|
374
|
+
#### Supported Cache Stores
|
375
|
+
|
376
|
+
1. **Memory Cache** (`:memory`) - For development and testing
|
377
|
+
2. **Redis Cache** (`:redis`) - For production with high availability
|
378
|
+
3. **Memcached Cache** (`:memcached`) - For distributed caching
|
379
|
+
4. **Solid Cache** (`:solid_cache`) - For Rails 8+ applications
|
380
|
+
|
381
|
+
#### Configuration Examples
|
382
|
+
|
383
|
+
```ruby
|
384
|
+
# Memory cache (development/testing)
|
385
|
+
config.cache_store = :memory
|
386
|
+
|
387
|
+
# Redis cache
|
388
|
+
config.cache_store = :redis
|
389
|
+
config.cache_options = { url: ENV['REDIS_URL'] }
|
390
|
+
|
391
|
+
# Memcached cache
|
392
|
+
config.cache_store = :memcached
|
393
|
+
config.cache_options = { servers: ['localhost:11211'] }
|
394
|
+
|
395
|
+
# Solid Cache (Rails 8+)
|
396
|
+
config.cache_store = :solid_cache
|
397
|
+
```
|
398
|
+
|
399
|
+
#### Separate Cache Stores
|
400
|
+
|
401
|
+
You can configure separate cache stores for RBAC permissions data and cached user permissions:
|
402
|
+
|
403
|
+
```ruby
|
404
|
+
# Use Redis for RBAC data (shared across instances)
|
405
|
+
config.rbac_cache_store = :redis
|
406
|
+
config.rbac_cache_options = { url: ENV['REDIS_URL'] }
|
407
|
+
|
408
|
+
# Use memory for user permission cache (faster local access)
|
409
|
+
config.permission_cache_store = :memory
|
410
|
+
config.permission_cache_options = {}
|
411
|
+
```
|
412
|
+
|
413
|
+
## RBAC Cache Format
|
414
|
+
|
415
|
+
When RBAC is enabled, the middleware expects permissions to be stored in the cache with this exact format:
|
416
|
+
|
417
|
+
```json
|
418
|
+
{
|
419
|
+
"last_update": 1640995200,
|
420
|
+
"permissions": [
|
421
|
+
{
|
422
|
+
"123": [
|
423
|
+
"sales/invoices:get",
|
424
|
+
"sales/invoices:post",
|
425
|
+
"%r{sales/invoices/\\d+}:get",
|
426
|
+
"%r{sales/invoices/\\d+}:put",
|
427
|
+
"users/*:get"
|
428
|
+
]
|
429
|
+
},
|
430
|
+
{
|
431
|
+
"456": ["admin/*:*", "reports:get"]
|
432
|
+
}
|
433
|
+
]
|
434
|
+
}
|
435
|
+
```
|
436
|
+
|
437
|
+
### Format Specification
|
438
|
+
|
439
|
+
- **last_update**: Timestamp for cache invalidation
|
440
|
+
- **permissions**: Array of role permission objects
|
441
|
+
- **Role ID**: String or numeric identifier for user roles
|
442
|
+
- **Permission Format**: `"resource-endpoint:http-method"`
|
443
|
+
- **resource-endpoint**: API path (literal string or regex pattern)
|
444
|
+
- **http-method**: `get`, `post`, `put`, `delete`, or `*` (wildcard)
|
445
|
+
|
446
|
+
### Permission Examples
|
447
|
+
|
448
|
+
```ruby
|
449
|
+
# Literal path matching
|
450
|
+
"sales/invoices:get" # GET /api/v1/company/sales/invoices
|
451
|
+
"users/profile:put" # PUT /api/v1/company/users/profile
|
452
|
+
|
453
|
+
# Regex pattern matching
|
454
|
+
"%r{ sales/invoices/\\d+}:get" # GET /api/v1/company/sales/invoices/123
|
455
|
+
"%r{ users/\\d+/orders}:*" # Any method on /api/v1/company/users/123/orders
|
456
|
+
|
457
|
+
# Wildcard method
|
458
|
+
"reports:*" # Any method on reports endpoint
|
459
|
+
"admin/*:*" # Full admin access
|
460
|
+
```
|
461
|
+
|
462
|
+
### Request Authorization Flow
|
463
|
+
|
464
|
+
1. **Check User Permissions Cache**: Fast lookup in middleware cache
|
465
|
+
|
466
|
+
- **RBAC Update Check**: If RBAC permissions updated within TTL → **Nuke entire cache**
|
467
|
+
- **TTL Check**: If individual permission older than configured TTL → Remove only that permission
|
468
|
+
- If cache valid and permission found: **✅ Authorized**
|
469
|
+
- If cache valid but no permission: **❌ 403 Forbidden**
|
470
|
+
|
471
|
+
2. **RBAC Permissions Validation**: Full permission evaluation
|
472
|
+
|
473
|
+
- Extract user roles from JWT payload using configurable field mapping (default: `role_ids`)
|
474
|
+
- Fallback to common fields: `roles`, `role`, `user_roles`, or `role_ids` if mapped field not found
|
475
|
+
- Load RBAC permissions collection and validate format
|
476
|
+
- For each user role, check if any permission matches:
|
477
|
+
- Extract resource path from request URL (removes subdomain/pathname slug)
|
478
|
+
- Match against permission patterns (literal or regex)
|
479
|
+
- Validate HTTP method (exact match or wildcard)
|
480
|
+
- If authorized: Cache permission for future requests
|
481
|
+
- Return 403 Forbidden if no matching permissions found
|
482
|
+
|
483
|
+
3. **Cache Storage**: Successful permissions cached with simple key-value format:
|
484
|
+
```json
|
485
|
+
{
|
486
|
+
"12345:acme-group.localhost.local/api/v1/company/sales/invoices:get": 1640995200,
|
487
|
+
"12345:acme-group.localhost.local/api/v1/company/sales/invoices:post": 1640995200
|
488
|
+
}
|
489
|
+
```
|
490
|
+
TTL configurable via `user_permissions_ttl` option (default: 30 minutes)
|
491
|
+
|
492
|
+
### Cache Invalidation Strategy
|
493
|
+
|
494
|
+
**RBAC Collection Updated** (e.g., role permissions changed):
|
495
|
+
|
496
|
+
- **Condition**: RBAC `last_update` within configured TTL
|
497
|
+
- **Action**: **Nuke entire cache** (all users, all permissions)
|
498
|
+
- **Reason**: Any permission could have changed, safer to re-evaluate everything
|
499
|
+
|
500
|
+
**Individual Permission TTL Expired**:
|
501
|
+
|
502
|
+
- **Condition**: Specific permission older than configured TTL
|
503
|
+
- **Action**: **Remove only that permission** (preserve others)
|
504
|
+
- **Reason**: Permission naturally aged out, other permissions still valid
|
505
|
+
|
506
|
+
### Example Authorization
|
507
|
+
|
508
|
+
Request: `POST https://acme-group.localhost/api/v1/an-acme-company/sales/invoices`
|
509
|
+
|
510
|
+
- User has role: `123`
|
511
|
+
- Role `123` permissions: `["sales/invoices:get", "sales/invoices:post"]`
|
512
|
+
- Extracted resource path: `sales/invoices`
|
513
|
+
- Request method: `POST`
|
514
|
+
- Result: **✅ Authorized** (matches `sales/invoices:post`)
|
515
|
+
|
516
|
+
Request: `DELETE https://acme-group.localhost/api/v1/an-acme-company/sales/invoices/456`
|
517
|
+
|
518
|
+
- User has role: `123`
|
519
|
+
- Role `123` permissions: `["sales/invoices:get", "%r{sales/invoices/\\d+}:put"]`
|
520
|
+
- Extracted resource path: `sales/invoices/456`
|
521
|
+
- Request method: `DELETE`
|
522
|
+
- Result: **❌ 403 Forbidden** (no DELETE permission)
|
191
523
|
|
192
524
|
## Error Handling
|
193
525
|
|
@@ -196,11 +528,34 @@ The middleware returns appropriate HTTP status codes:
|
|
196
528
|
- **401 Unauthorized** - Missing, invalid, or expired JWT
|
197
529
|
- **403 Forbidden** - Valid JWT but insufficient permissions/access
|
198
530
|
|
531
|
+
## Documentation
|
532
|
+
|
533
|
+
API documentation is available online and is automatically generated from the source code:
|
534
|
+
|
535
|
+
- **📚 [Online Documentation](https://kanutocd.github.io/rack_jwt_aegis/)** - Auto-deployed from the main branch
|
536
|
+
- **🔧 Generate Locally**: `bundle exec yard doc` and open `doc/index.html`
|
537
|
+
|
538
|
+
### Documentation Features
|
539
|
+
|
540
|
+
- Complete API reference for all classes and modules
|
541
|
+
- Code examples and usage patterns
|
542
|
+
- Architecture decision records (ADRs)
|
543
|
+
- Integration examples for common use cases
|
544
|
+
|
199
545
|
## Development
|
200
546
|
|
201
547
|
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
202
548
|
|
203
|
-
To install this gem onto your local machine, run `bundle exec rake install`.
|
549
|
+
To install this gem onto your local machine, run `bundle exec rake install`.
|
550
|
+
|
551
|
+
### Test Coverage
|
552
|
+
|
553
|
+
This project maintains high test coverage:
|
554
|
+
|
555
|
+
- **Line Coverage**: 97.81% (670/685 lines)
|
556
|
+
- **Branch Coverage**: 87.13% (264/303 branches)
|
557
|
+
|
558
|
+
Run tests with coverage: `bundle exec rake test`
|
204
559
|
|
205
560
|
## Contributing
|
206
561
|
|
data/Rakefile
CHANGED
@@ -9,4 +9,56 @@ Rake::TestTask.new(:test) do |t|
|
|
9
9
|
t.test_files = FileList['test/**/*_test.rb']
|
10
10
|
end
|
11
11
|
|
12
|
+
# YARD documentation tasks
|
13
|
+
begin
|
14
|
+
require 'yard'
|
15
|
+
require 'yard/rake/yardoc_task'
|
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
|
+
YARD::Rake::YardocTask.new do |t|
|
25
|
+
t.files = ['lib/**/*.rb']
|
26
|
+
t.options = [
|
27
|
+
'--output-dir', 'doc',
|
28
|
+
'--readme', 'README.md',
|
29
|
+
'--markup-provider', 'kramdown',
|
30
|
+
'--markup', 'markdown'
|
31
|
+
]
|
32
|
+
t.stats_options = ['--list-undoc']
|
33
|
+
end
|
34
|
+
|
35
|
+
desc 'Generate YARD documentation and open in browser'
|
36
|
+
task :docs do
|
37
|
+
Rake::Task['yard'].invoke
|
38
|
+
case RbConfig::CONFIG['host_os']
|
39
|
+
when /mswin|mingw|cygwin/
|
40
|
+
system 'start doc/index.html'
|
41
|
+
when /darwin/
|
42
|
+
system 'open doc/index.html'
|
43
|
+
when /linux|bsd/
|
44
|
+
system 'xdg-open doc/index.html'
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
desc 'Run YARD documentation server'
|
49
|
+
task 'docs:server' do
|
50
|
+
puts 'Starting YARD documentation server at http://localhost:8808'
|
51
|
+
puts 'Press Ctrl+C to stop the server'
|
52
|
+
system 'yard server --reload --port 8808'
|
53
|
+
end
|
54
|
+
|
55
|
+
desc 'Check documentation coverage'
|
56
|
+
task 'docs:coverage' do
|
57
|
+
puts 'Generating YARD documentation coverage report...'
|
58
|
+
system 'yard stats --list-undoc'
|
59
|
+
end
|
60
|
+
rescue LoadError
|
61
|
+
puts 'YARD gem is not available. Install it with: gem install yard'
|
62
|
+
end
|
63
|
+
|
12
64
|
task default: :test
|
data/bin/console
ADDED
@@ -0,0 +1,11 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require 'bundler/setup'
|
5
|
+
require 'rack_jwt_aegis'
|
6
|
+
|
7
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
8
|
+
# with your gem easier. You can also use a different console, if you like.
|
9
|
+
|
10
|
+
require 'irb'
|
11
|
+
IRB.start(__FILE__)
|
data/bin/docs
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require 'fileutils'
|
5
|
+
|
6
|
+
puts '🔧 Generating documentation...'
|
7
|
+
system('bundle exec yard doc')
|
8
|
+
|
9
|
+
puts "\n📊 Documentation coverage:"
|
10
|
+
system('bundle exec yard stats --list-undoc')
|
11
|
+
|
12
|
+
puts "\n✅ Documentation generated successfully!"
|
13
|
+
puts '📂 Open doc/index.html in your browser to view the documentation'
|
14
|
+
|
15
|
+
# Check if we're in a git repository and suggest deployment
|
16
|
+
if system('git rev-parse --git-dir > /dev/null 2>&1')
|
17
|
+
puts "\n💡 To deploy to GitHub Pages:"
|
18
|
+
puts " git add . && git commit -m 'Update documentation' && git push"
|
19
|
+
puts ' Or trigger manually via GitHub Actions workflow dispatch'
|
20
|
+
end
|