rack_jwt_aegis 1.1.0 → 1.1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0e776985ff922d83b9ac75821698125a565dce342be61bd4bf6012fc97dfd82c
4
- data.tar.gz: 7bbb4006689d39ea9ec47ad869b01468bbfbddfb0ecbaa430d28ac96f1fd33b7
3
+ metadata.gz: 355b1a9e875355d5a3c5c7ca9c83dd24af066e1c69e89c9030fab8bb2c5a3f71
4
+ data.tar.gz: e151caf3879396e067aaf2c7f5ba0228fece3de07c5bdf41ac3df3e37d271ab9
5
5
  SHA512:
6
- metadata.gz: 64eb785ccd161bf7f7a7e0ce2b9840007885778b47d35a5528fe586ebcc160aa2f3d356e92868f8931c0789603a78fbc1feef56d6141dbeb322611330404b77a
7
- data.tar.gz: 84f45c9c58aed4eadbcfd8a1d78d1a3e0cdd47c9f602ae9047e847738c671c0513dacfc9298207456fa969a44045ec6b8b8ff29fea3e0c29f76357aa4933ddaf
6
+ metadata.gz: e9709c05464dade807e95a135d6c5fe7d4e301127c4ea23209086298bc3078882cd1c5454262b1bc1759d1b48b27ac7cd630690e735a210f354bbb20fea808e8
7
+ data.tar.gz: 505983dd1f954880f7ad1a9a6e33beaf3ff83734c5034f90f5515d1ac0d48bac7c6b4e75842aeb7860695bf10efd7aba24c012cafd97b7e1089270d853c0bb23
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 3.2
data/.yardopts CHANGED
@@ -1,14 +1,8 @@
1
- --output-dir doc
2
- --readme README.md
3
- --main README.md
4
- --markup=markdown
1
+ --title "RackJwtAegis Rack Middleware API Documentation""
5
2
  --protected
6
- --private
7
- --no-private
8
- --title "RackJwtAegis Rack Middleware API Documentation"
9
- --charset utf-8
10
- lib/**/*.rb
3
+ --markup markdown
4
+ --readme README.md
5
+ 'lib/**/*.rb'
11
6
  -
12
- README.md
13
- LICENSE.txt
14
- CODE_OF_CONDUCT.md
7
+ --files CHANGELOG.md LICENSE.txt CODE_OF_CONDUCT.md
8
+
data/CHANGELOG.md CHANGED
@@ -1,9 +1,94 @@
1
1
  # Changelog
2
2
 
3
- All notable changes to this project will be documented in this file.
3
+ ## Unreleased
4
4
 
5
- The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
- and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
+ ## [1.1.1] - 2026-06-13
6
+
7
+ ### 🚀 Added
8
+
9
+ #### Method-Aware Skip Routes
10
+
11
+ - Added `skip_routes` to support request-aware public route matching by path and HTTP verb.
12
+ - Kept `skip_paths` as a backward-compatible alias for all-method skips.
13
+ - Normalizes route rules once at configuration time so middleware checks stay fast per request.
14
+
15
+ #### Route Skipping Behavior
16
+
17
+ - Middleware now evaluates `METHOD + path` for skip decisions, so `POST /login` can be public while `GET /login` remains protected.
18
+ - Added support for blank verb lists to mean all methods on a route entry.
19
+ - Updated the Pink House API initializer to use the new `skip_routes` shape.
20
+
21
+ ### 🔧 Fixed
22
+
23
+ #### Simplified RBAC Cache Strategy
24
+
25
+ - **Streamlined Configuration**: Removed legacy cache configuration options to eliminate confusion
26
+ - **Removed**: `cache_store`, `cache_options`, and `cache_write_enabled` (deprecated in favor of dedicated RBAC cache stores)
27
+ - **Required**: When `rbac_enabled: true`, both `rbac_cache_store` and `permissions_cache_store` must be explicitly configured
28
+ - **Simplified**: Consistent naming with `rbac_cache_store_options` and `permissions_cache_store_options`
29
+
30
+ #### Cache Store Validation
31
+
32
+ - **Mandatory Configuration**: RBAC cache stores are now required when RBAC is enabled
33
+ - Prevents runtime errors from missing cache configuration
34
+ - Provides clear error messages during initialization: "rbac_cache_store and permissions_cache_store are required when RBAC is enabled"
35
+ - Ensures production deployments are properly configured for performance
36
+
37
+ #### Code Cleanup
38
+
39
+ - **Removed Complexity**: Eliminated dual cache mode logic and fallback mechanisms
40
+ - Simplified `RbacManager#setup_cache_adapters` method
41
+ - Removed conditional cache write logic that added complexity
42
+ - Cleaner permission checking flow with consistent cache behavior
43
+
44
+ #### Developer Experience
45
+
46
+ - **Clear Configuration**: Explicit cache store requirements eliminate guesswork
47
+ - **Better Error Messages**: Configuration validation happens at startup, not at runtime
48
+ - **Consistent Naming**: Standardized cache option parameter names across all adapters
49
+
50
+ ### 🏗️ Technical Details
51
+
52
+ #### Breaking Changes (Minor - Configuration Only)
53
+
54
+ - **Removed Configuration Options**:
55
+ - `cache_store` → Use `rbac_cache_store` and `permissions_cache_store` instead
56
+ - `cache_options` → Use `rbac_cache_store_options` and `permissions_cache_store_options` instead
57
+ - `cache_write_enabled` → Cache writing is now always enabled when RBAC is active
58
+
59
+ #### Migration Guide
60
+
61
+ **Before (v1.1.0):**
62
+ ```ruby
63
+ use RackJwtAegis::Middleware, {
64
+ jwt_secret: ENV['JWT_SECRET'],
65
+ rbac_enabled: true,
66
+ cache_store: :redis,
67
+ cache_options: { url: ENV['REDIS_URL'] },
68
+ cache_write_enabled: true
69
+ }
70
+ ```
71
+
72
+ **After (v1.1.1):**
73
+ ```ruby
74
+ use RackJwtAegis::Middleware, {
75
+ jwt_secret: ENV['JWT_SECRET'],
76
+ rbac_enabled: true,
77
+ rbac_cache_store: :redis,
78
+ rbac_cache_store_options: { url: ENV['REDIS_URL'] },
79
+ permissions_cache_store: :redis,
80
+ permissions_cache_store_options: { url: ENV['REDIS_URL'] }
81
+ }
82
+ ```
83
+
84
+ #### Benefits
85
+
86
+ - **🚀 Simpler Configuration**: No more dual cache modes or conditional logic
87
+ - **🛡️ Fail-Fast Validation**: Configuration errors caught at startup
88
+ - **📈 Better Performance**: Dedicated cache stores optimized for their specific use cases
89
+ - **🧹 Cleaner Code**: Reduced complexity in cache management logic
90
+
91
+ ---
7
92
 
8
93
  ## [1.1.0] - 2025-08-14
9
94
 
@@ -71,11 +156,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
71
156
  #### Developer Experience
72
157
 
73
158
  - **Better Error Messages**: Clear configuration error descriptions
159
+
74
160
  ```ruby
75
161
  # Example error message:
76
- "RBAC permissions must be a Hash with role-id keys, not Array.
162
+ "RBAC permissions must be a Hash with role-id keys, not Array.
77
163
  Expected format: {\"role-id\": [\"resource:method\", ...]}, but got: Array"
78
164
  ```
165
+
79
166
  - **Migration Support**: Catches common mistakes when upgrading cache format
80
167
  - **Improved Documentation**: Cleaner YARD output with better Markdown rendering
81
168
 
@@ -96,6 +183,7 @@ Expected format: {\"role-id\": [\"resource:method\", ...]}, but got: Array"
96
183
  #### Updating RBAC Cache Format
97
184
 
98
185
  **Before (v1.0.x):**
186
+
99
187
  ```ruby
100
188
  Rails.cache.write("permissions", {
101
189
  'last_update' => Time.now.to_i,
@@ -107,6 +195,7 @@ Rails.cache.write("permissions", {
107
195
  ```
108
196
 
109
197
  **After (v1.1.0+):**
198
+
110
199
  ```ruby
111
200
  Rails.cache.write("permissions", {
112
201
  'last_update' => Time.now.to_i,
@@ -294,8 +383,8 @@ use RackJwtAegis::Middleware, {
294
383
  pathname_slug_pattern: /^\/api\/v1\/([^\/]+)\//,
295
384
  rbac_enabled: true,
296
385
  rbac_cache_store: :redis,
297
- permission_cache_store: :memory,
298
- user_permissions_ttl: 300,
386
+ permissions_cache_store: :memory,
387
+ cached_permissions_ttl: 300,
299
388
  cache_write_enabled: true,
300
389
  skip_paths: [/^\/health/, /^\/metrics/, /^\/api\/public/],
301
390
  custom_payload_validation: ->(payload) { payload['active'] == true },
@@ -369,6 +458,7 @@ This 1.0.0 release represents a production-ready JWT authentication middleware w
369
458
 
370
459
  **Deprecations**: None (initial release).
371
460
 
461
+ [1.1.1]: https://github.com/kanutocd/rack_jwt_aegis/releases/tag/v1.1.1
372
462
  [1.1.0]: https://github.com/kanutocd/rack_jwt_aegis/releases/tag/v1.1.0
373
463
  [1.0.2]: https://github.com/kanutocd/rack_jwt_aegis/releases/tag/v1.0.2
374
464
  [1.0.1]: https://github.com/kanutocd/rack_jwt_aegis/releases/tag/v1.0.1
data/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  [![Gem Version](https://badge.fury.io/rb/rack_jwt_aegis.svg)](https://badge.fury.io/rb/rack_jwt_aegis)
4
4
  [![CI](https://github.com/kanutocd/rack_jwt_aegis/workflows/CI/badge.svg)](https://github.com/kanutocd/rack_jwt_aegis/actions)
5
5
  [![Coverage Status](https://codecov.io/gh/kanutocd/rack_jwt_aegis/branch/main/graph/badge.svg)](https://codecov.io/gh/kanutocd/rack_jwt_aegis)
6
- [![Ruby Version](https://img.shields.io/badge/ruby-%3E%3D%203.1.0-ruby.svg)](https://www.ruby-lang.org/en/)
6
+ [![Ruby Version](https://img.shields.io/badge/ruby-%3E%3D%203.2.0-ruby.svg)](https://www.ruby-lang.org/en/)
7
7
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
8
8
 
9
9
  JWT authentication and authorization middleware for hierarchical multi-tenant Rack applications with 2-level tenant support.
@@ -147,28 +147,24 @@ RackJwtAegis::Middleware.new(app, {
147
147
  pathname_slug_pattern: /^\/api\/v1\/([^\/]+)\//, # Default pattern
148
148
 
149
149
  # RBAC Configuration
150
- rbac_enabled: true, # Default: false
151
- rbac_cache_store: :redis, # Required when RBAC enabled
152
- rbac_cache_options: { url: ENV['REDIS_URL'] },
153
- user_permissions_ttl: 3600, # Default: 1800 (30 minutes) - TTL for cached user permissions
154
-
155
- # Cache Store Configuration (choose one approach)
156
- # Option 1: Shared cache for both RBAC and permissions
157
- cache_store: :memory, # :memory, :redis, :memcached, :solid_cache
158
- cache_options: { url: ENV['REDIS_URL'] },
159
-
160
- # Option 2: Separate cache stores for RBAC and permissions
161
- rbac_cache_store: :redis, # For RBAC permissions data
162
- rbac_cache_options: { url: ENV['REDIS_URL'] },
163
- permission_cache_store: :memory, # For cached user permissions
164
- permission_cache_options: {},
150
+ rbac_enabled: true, # Default: false
151
+ rbac_cache_store: :redis, # Required when RBAC enabled. Default: :memory
152
+ rbac_cache_store_options: { url: ENV['REDIS_URL'] }, # Cache Store specific options. Default: {}
153
+
154
+ cached_permissions_ttl: 3600, # Default: 1800 (30 minutes) - TTL for cached user permissions
155
+ permissions_cache_store: :solid_cache, # Required when RBAC enabled. Default: :memory
156
+ permissions_cache_store_options: {}, # Cache Store specific options. Default: {}
157
+
158
+ # Or can also be the same Redis instance
159
+ # permissions_cache_store: :redis, # Required when RBAC enabled. Default: :memory
160
+ # permissions_cache_store_options: { url: ENV['REDIS_URL'] },
165
161
 
166
162
  # Response Customization
167
163
  unauthorized_response: { error: 'Authentication required' },
168
164
  forbidden_response: { error: 'Access denied' },
169
165
 
170
166
  # Debugging
171
- debug_mode: Rails.env.development? # Default: false
167
+ debug_mode: Rails.env.development? # Default: when in Rails, Rails.env.development? otherwise false
172
168
  })
173
169
  ```
174
170
 
@@ -404,18 +400,19 @@ All role values are normalized to strings internally for consistent matching aga
404
400
 
405
401
  ```ruby
406
402
  # Memory cache (development/testing)
407
- config.cache_store = :memory
403
+ config.rbac_cache_store = :memory # Default. When in Rails, it will default to Rails.application.config.cache_store
404
+ config.permissions_cache_store = :memory # Default. When in Rails, it will default to Rails.application.config.cache_store
408
405
 
409
406
  # Redis cache
410
- config.cache_store = :redis
411
- config.cache_options = { url: ENV['REDIS_URL'] }
407
+ config.rbac_cache_store = :redis
408
+ config.rbac_cache_store_options = { url: ENV['REDIS_URL'] }
412
409
 
413
410
  # Memcached cache
414
- config.cache_store = :memcached
415
- config.cache_options = { servers: ['localhost:11211'] }
411
+ config.rbac_cache_store = :memcached
412
+ config.rbac_cache_store_options = { servers: ['localhost:11211'] }
416
413
 
417
414
  # Solid Cache (Rails 8+)
418
- config.cache_store = :solid_cache
415
+ config.rbac_cache_store = :solid_cache
419
416
  ```
420
417
 
421
418
  #### Separate Cache Stores
@@ -425,11 +422,11 @@ You can configure separate cache stores for RBAC permissions data and cached use
425
422
  ```ruby
426
423
  # Use Redis for RBAC data (shared across instances)
427
424
  config.rbac_cache_store = :redis
428
- config.rbac_cache_options = { url: ENV['REDIS_URL'] }
425
+ config.rbac_cache_store_options = { url: ENV['REDIS_URL'] }
429
426
 
430
427
  # Use memory for user permission cache (faster local access)
431
- config.permission_cache_store = :memory
432
- config.permission_cache_options = {}
428
+ config.permissions_cache_store = :memory
429
+ config.permissions_cache_store_options = {}
433
430
  ```
434
431
 
435
432
  ## RBAC Cache Format
@@ -506,7 +503,7 @@ When RBAC is enabled, the middleware expects permissions to be stored in the cac
506
503
  "12345:acme-group.localhost.local/api/v1/company/sales/invoices:post": 1640995200
507
504
  }
508
505
  ```
509
- TTL configurable via `user_permissions_ttl` option (default: 30 minutes)
506
+ TTL configurable via `cached_permissions_ttl` option (default: 30 minutes)
510
507
 
511
508
  ### Cache Invalidation Strategy
512
509
 
@@ -4,13 +4,13 @@ module RackJwtAegis
4
4
  class CacheAdapter
5
5
  def self.build(store_type, options = {})
6
6
  case store_type
7
- when :memory
7
+ when :memory, :memory_store
8
8
  MemoryAdapter.new(options)
9
- when :redis
9
+ when :redis, :redis_cache_store
10
10
  RedisAdapter.new(options)
11
- when :memcached
11
+ when :memcached, :mem_cache_store
12
12
  MemcachedAdapter.new(options)
13
- when :solid_cache
13
+ when :solid_cache, :solid_cache_store
14
14
  SolidCacheAdapter.new(options)
15
15
  else
16
16
  raise ConfigurationError, "Unsupported cache store: #{store_type}"
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ratomic'
4
+
5
+ module RackJwtAegis
6
+ class CircuitBreaker
7
+ STATE_FAILURE_COUNT = 'failure_count'
8
+ STATE_OPENED_AT = 'opened_at'
9
+
10
+ def initialize(failure_threshold:, cooldown_seconds:)
11
+ @failure_threshold = failure_threshold.to_i
12
+ @cooldown_seconds = cooldown_seconds.to_i
13
+ @state = Ratomic::Map.new
14
+ reset
15
+ end
16
+
17
+ def allow_request?
18
+ return true unless open?
19
+ return false unless cooldown_elapsed?
20
+
21
+ reset
22
+ true
23
+ end
24
+
25
+ def record_success
26
+ reset
27
+ end
28
+
29
+ def record_failure
30
+ failures = @state.increment(STATE_FAILURE_COUNT)
31
+ @state[STATE_OPENED_AT] = Time.now.to_f if failures >= @failure_threshold
32
+ failures
33
+ end
34
+
35
+ def open?
36
+ !@state[STATE_OPENED_AT].nil?
37
+ end
38
+
39
+ private
40
+
41
+ def reset
42
+ @state[STATE_FAILURE_COUNT] = 0
43
+ @state.delete(STATE_OPENED_AT)
44
+ end
45
+
46
+ def cooldown_elapsed?
47
+ opened_at = @state[STATE_OPENED_AT]
48
+ opened_at && (Time.now.to_f - opened_at) >= @cooldown_seconds
49
+ end
50
+ end
51
+ end
@@ -19,8 +19,6 @@ module RackJwtAegis
19
19
  # validate_subdomain: true,
20
20
  # validate_pathname_slug: true,
21
21
  # rbac_enabled: true,
22
- # cache_store: :redis,
23
- # cache_write_enabled: true,
24
22
  # skip_paths: ['/health', '/api/public/*'],
25
23
  # debug_mode: Rails.env.development?
26
24
  # )
@@ -53,10 +51,30 @@ module RackJwtAegis
53
51
  # @return [Boolean] true if tenant id validation is enabled
54
52
  attr_accessor :validate_tenant_id
55
53
 
54
+ # Whether authenticated requests must include all identity/tenant headers
55
+ # @return [Boolean] true if strict authenticated request headers are required
56
+ attr_accessor :require_authentication_headers
57
+
58
+ # Whether JWTs must include expiration-related claims
59
+ # @return [Boolean] true if exp and iat claims are required
60
+ attr_accessor :require_expiration_claims
61
+
56
62
  # Whether RBAC (Role-Based Access Control) is enabled
57
63
  # @return [Boolean] true if RBAC is enabled
58
64
  attr_accessor :rbac_enabled
59
65
 
66
+ # Whether unexpected errors should trip a fail-fast circuit breaker
67
+ # @return [Boolean] true if circuit breaker is enabled
68
+ attr_accessor :circuit_breaker_enabled
69
+
70
+ # Number of unexpected failures before the circuit opens
71
+ # @return [Integer] failure threshold
72
+ attr_accessor :circuit_breaker_failure_threshold
73
+
74
+ # Seconds to fail fast before allowing another request attempt
75
+ # @return [Integer] cooldown seconds
76
+ attr_accessor :circuit_breaker_cooldown_seconds
77
+
60
78
  # @!endgroup
61
79
 
62
80
  # @!group Multi-tenant Settings
@@ -65,6 +83,14 @@ module RackJwtAegis
65
83
  # @return [String] the tenant ID header name (default: 'X-Tenant-Id')
66
84
  attr_accessor :tenant_id_header_name
67
85
 
86
+ # The HTTP header name containing the tenant slug
87
+ # @return [String] the tenant slug header name
88
+ attr_accessor :tenant_slug_header_name
89
+
90
+ # The HTTP header name containing the user ID
91
+ # @return [String] the user ID header name
92
+ attr_accessor :user_id_header_name
93
+
68
94
  # The regular expression pattern to extract pathname slugs
69
95
  # @return [Regexp] the pathname slug pattern (default: /^\/api\/v1\/([^\/]+)\//)
70
96
  attr_accessor :pathname_slug_pattern
@@ -79,47 +105,35 @@ module RackJwtAegis
79
105
 
80
106
  # @!group Path Management
81
107
 
82
- # Array of paths that should skip JWT authentication
83
- # @return [Array<String, Regexp>] paths to skip authentication for
108
+ # Array of routes that should skip JWT authentication
109
+ # @return [Array<String, Regexp, Hash>] routes to skip authentication for
84
110
  # @example
85
111
  # ['/health', '/api/public', /^\/assets/]
86
- attr_accessor :skip_paths
112
+ # [{ path: '/api/v1/sessions', verbs: [:post] }]
113
+ attr_reader :skip_paths, :skip_routes
87
114
 
88
115
  # @!endgroup
89
116
 
90
117
  # @!group Cache Configuration
91
-
92
- # The primary cache store adapter type
93
- # @return [Symbol] the cache store type (:memory, :redis, :memcached, :solid_cache)
94
- attr_accessor :cache_store
95
-
96
- # Options passed to the cache store adapter
97
- # @return [Hash] cache store configuration options
98
- attr_accessor :cache_options
99
-
100
- # Whether the middleware can write to cache stores
101
- # @return [Boolean] true if cache writing is enabled
102
- attr_accessor :cache_write_enabled
103
-
104
118
  # The RBAC cache store adapter type (separate from main cache)
105
119
  # @return [Symbol] the RBAC cache store type
106
120
  attr_accessor :rbac_cache_store
107
121
 
108
122
  # Options for the RBAC cache store
109
123
  # @return [Hash] RBAC cache configuration options
110
- attr_accessor :rbac_cache_options
124
+ attr_accessor :rbac_cache_store_options
111
125
 
112
126
  # The permission cache store adapter type
113
127
  # @return [Symbol] the permission cache store type
114
- attr_accessor :permission_cache_store
128
+ attr_accessor :permissions_cache_store
115
129
 
116
130
  # Options for the permission cache store
117
131
  # @return [Hash] permission cache configuration options
118
- attr_accessor :permission_cache_options
132
+ attr_accessor :permissions_cache_store_options
119
133
 
120
134
  # Time-to-live for user permissions cache in seconds
121
135
  # @return [Integer] TTL in seconds (default: 1800 - 30 minutes)
122
- attr_accessor :user_permissions_ttl
136
+ attr_accessor :cached_permissions_ttl
123
137
 
124
138
  # @!endgroup
125
139
 
@@ -168,11 +182,13 @@ module RackJwtAegis
168
182
  # @option options [String] :tenant_id_header_name ('X-Tenant-Id') tenant ID header name
169
183
  # @option options [Regexp] :pathname_slug_pattern default pattern for pathname slugs
170
184
  # @option options [Hash] :payload_mapping mapping of JWT claim names
171
- # @option options [Array<String, Regexp>] :skip_paths ([]) paths to skip authentication
172
- # @option options [Symbol] :cache_store cache adapter type
173
- # @option options [Hash] :cache_options cache configuration options
174
- # @option options [Boolean] :cache_write_enabled (false) enable cache writing
175
- # @option options [Integer] :user_permissions_ttl (1800) user permissions cache TTL in seconds
185
+ # @option options [Array<String, Regexp>] :skip_paths ([]) legacy paths to skip authentication
186
+ # @option options [Array<String, Regexp, Hash>] :skip_routes ([]) routes to skip authentication
187
+ # @option options [Symbol] :rbac_cache_store cache adapter type
188
+ # @option options [Hash] :rbac_cache_store_options cache configuration options
189
+ # @option options [Symbol] :permissions_cache_store cache adapter type
190
+ # @option options [Hash] :permissions_cache_store_options cache configuration options
191
+ # @option options [Integer] :cached_permissions_ttl (1800) user permissions cache TTL in seconds
176
192
  # @option options [Boolean] :debug_mode (false) enable debug logging
177
193
  # @raise [ConfigurationError] if jwt_secret is missing or configuration is invalid
178
194
  def initialize(options = {})
@@ -196,6 +212,12 @@ module RackJwtAegis
196
212
  config_boolean?(rbac_enabled)
197
213
  end
198
214
 
215
+ # Check if circuit breaker is enabled
216
+ # @return [Boolean] true if circuit breaker is enabled
217
+ def circuit_breaker_enabled?
218
+ config_boolean?(circuit_breaker_enabled)
219
+ end
220
+
199
221
  # Check if subdomain validation is enabled
200
222
  # @return [Boolean] true if subdomain validation is enabled
201
223
  def validate_subdomain?
@@ -214,33 +236,50 @@ module RackJwtAegis
214
236
  config_boolean?(validate_tenant_id)
215
237
  end
216
238
 
239
+ # Check if strict authenticated request headers are required
240
+ # @return [Boolean] true if required auth headers are enabled
241
+ def require_authentication_headers?
242
+ config_boolean?(require_authentication_headers)
243
+ end
244
+
245
+ # Check if exp and iat claims are required
246
+ # @return [Boolean] true if expiration claims are required
247
+ def require_expiration_claims?
248
+ config_boolean?(require_expiration_claims)
249
+ end
250
+
217
251
  # Check if debug mode is enabled
218
252
  # @return [Boolean] true if debug mode is enabled
219
253
  def debug_mode?
220
254
  config_boolean?(debug_mode)
221
255
  end
222
256
 
223
- # Check if cache write access is enabled
224
- # @return [Boolean] true if cache writing is enabled
225
- def cache_write_enabled?
226
- config_boolean?(cache_write_enabled)
257
+ def skip_paths=(value)
258
+ @skip_paths = value
259
+ @normalized_skip_routes = nil
260
+ end
261
+
262
+ def skip_routes=(value)
263
+ @skip_routes = value
264
+ @normalized_skip_routes = nil
227
265
  end
228
266
 
229
267
  # Check if the given path should skip JWT authentication
230
268
  # @param path [String] the request path to check
231
- # @return [Boolean] true if the path should be skipped
269
+ # @return [Boolean] true if the path should be skipped for any method
232
270
  def skip_path?(path)
233
- return false if skip_paths.nil? || skip_paths.empty?
234
-
235
- skip_paths.any? do |skip_path|
236
- case skip_path
237
- when String
238
- path == skip_path
239
- when Regexp
240
- skip_path.match?(path)
241
- else
242
- false
243
- end
271
+ skip_request?(path)
272
+ end
273
+
274
+ # Check if the given request should skip JWT authentication
275
+ # @param path [String] the request path to check
276
+ # @param request_method [String, nil] the HTTP method to check
277
+ # @return [Boolean] true if the route should be skipped
278
+ def skip_request?(path, request_method = nil)
279
+ return false if normalized_skip_routes.empty?
280
+
281
+ normalized_skip_routes.any? do |skip_route|
282
+ route_matches?(skip_route, path, request_method)
244
283
  end
245
284
  end
246
285
 
@@ -272,22 +311,39 @@ module RackJwtAegis
272
311
  @validate_subdomain = false
273
312
  @validate_pathname_slug = false
274
313
  @validate_tenant_id = false
275
- @rbac_enabled = false
314
+ @require_authentication_headers = false
315
+ @require_expiration_claims = false
276
316
  @tenant_id_header_name = 'X-Tenant-Id'
317
+ @tenant_slug_header_name = 'X-Tenant-Slug'
318
+ @user_id_header_name = 'X-User-Id'
277
319
  @pathname_slug_pattern = %r{^/api/v1/([^/]+)/}
278
320
  @skip_paths = []
279
- @cache_write_enabled = false
280
- @user_permissions_ttl = 1800 # 30 minutes default
281
- @debug_mode = false
321
+ @skip_routes = []
282
322
  @payload_mapping = {
283
323
  user_id: :user_id,
284
324
  tenant_id: :tenant_id,
325
+ tenant_slug: :subdomain,
285
326
  subdomain: :subdomain,
286
327
  pathname_slugs: :pathname_slugs,
287
328
  role_ids: :role_ids,
288
329
  }
289
330
  @unauthorized_response = { error: 'Authentication required' }
290
331
  @forbidden_response = { error: 'Access denied' }
332
+ @rbac_enabled = false
333
+ @circuit_breaker_enabled = false
334
+ @circuit_breaker_failure_threshold = 5
335
+ @circuit_breaker_cooldown_seconds = 30
336
+ @cached_permissions_ttl = 1800 # 30 minutes default
337
+ @rbac_cache_store = if Object.const_defined?(:Rails) && Rails.const_defined?(:Application)
338
+ @debug_mode = Rails.env.development?
339
+ Rails.application.config.cache_store
340
+ else
341
+ @debug_mode = false
342
+ :memory
343
+ end
344
+ @permissions_cache_store = @rbac_cache_store
345
+ @rbac_cache_store_options = {}
346
+ @permissions_cache_store_options = {}
291
347
  end
292
348
 
293
349
  def validate!
@@ -295,6 +351,8 @@ module RackJwtAegis
295
351
  validate_payload_mapping!
296
352
  validate_cache_settings!
297
353
  validate_multi_tenant_settings!
354
+ validate_authentication_header_settings!
355
+ validate_circuit_breaker_settings!
298
356
  end
299
357
 
300
358
  def validate_jwt_settings!
@@ -325,27 +383,10 @@ module RackJwtAegis
325
383
  end
326
384
 
327
385
  def validate_cache_settings!
328
- return unless rbac_enabled?
329
-
330
- # Validate cache store configuration
331
- if cache_store && !cache_write_enabled?
332
- # Zero trust mode - separate caches required
333
- if rbac_cache_store.nil?
334
- raise ConfigurationError, 'rbac_cache_store is required when cache_write_enabled is false'
335
- end
336
-
337
- if permission_cache_store.nil?
338
- @permission_cache_store = :memory # Default fallback
339
- end
340
- elsif cache_store.nil? && rbac_cache_store.nil?
341
- # Both cache stores are missing - at least one is required for RBAC
342
- raise ConfigurationError, 'cache_store or rbac_cache_store is required when RBAC is enabled'
343
- end
344
-
345
- # Set default fallback for permission_cache_store when rbac_cache_store is provided
346
- return unless !rbac_cache_store.nil? && permission_cache_store.nil?
386
+ return unless rbac_enabled? && (rbac_cache_store.nil? || permissions_cache_store.nil?)
347
387
 
348
- @permission_cache_store = :memory # Default fallback
388
+ raise ConfigurationError,
389
+ 'rbac_cache_store and permissions_cache_store are required when RBAC is enabled'
349
390
  end
350
391
 
351
392
  def validate_multi_tenant_settings!
@@ -367,5 +408,101 @@ module RackJwtAegis
367
408
  error_msg << 'pathname_slug_pattern is required' if pathname_slug_pattern.to_s.empty?
368
409
  raise ConfigurationError, "#{error_msg.join(' and ')} when validate_pathname_slug is true" if error_msg.any?
369
410
  end
411
+
412
+ def normalized_skip_routes
413
+ @normalized_skip_routes ||= normalize_skip_routes
414
+ end
415
+
416
+ def normalize_skip_routes
417
+ normalize_skip_route_entries(skip_paths) + normalize_skip_route_entries(skip_routes)
418
+ end
419
+
420
+ def normalize_skip_route_entries(entries)
421
+ Array(entries).each_with_object([]) do |entry, normalized|
422
+ case entry
423
+ when String, Regexp
424
+ normalized << { path: entry, methods: nil }
425
+ when Hash
426
+ path = entry.key?(:path) ? entry[:path] : entry['path']
427
+ next if path.nil?
428
+
429
+ verbs = if entry.key?(:verbs)
430
+ entry[:verbs]
431
+ elsif entry.key?('verbs')
432
+ entry['verbs']
433
+ elsif entry.key?(:verb)
434
+ entry[:verb]
435
+ elsif entry.key?('verb')
436
+ entry['verb']
437
+ elsif entry.key?(:methods)
438
+ entry[:methods]
439
+ elsif entry.key?('methods')
440
+ entry['methods']
441
+ end
442
+
443
+ normalized << { path: path, verbs: normalize_skip_verbs(verbs) }
444
+ end
445
+ end
446
+ end
447
+
448
+ def normalize_skip_verbs(verbs)
449
+ return nil if verbs.nil?
450
+ return nil if verbs.respond_to?(:empty?) && verbs.empty?
451
+ return nil if verbs.is_a?(String) && verbs.strip.empty?
452
+
453
+ normalized = Array(verbs).flatten.compact.map do |verb|
454
+ verb.to_s.strip.upcase
455
+ end.reject(&:empty?)
456
+
457
+ normalized.empty? ? nil : normalized.uniq
458
+ end
459
+
460
+ def route_matches?(skip_route, path, request_method)
461
+ path_matches = case skip_route[:path]
462
+ when String
463
+ skip_route[:path] == path
464
+ when Regexp
465
+ skip_route[:path].match?(path)
466
+ else
467
+ false
468
+ end
469
+
470
+ return false unless path_matches
471
+
472
+ verbs = skip_route[:verbs]
473
+ return true if verbs.nil?
474
+
475
+ return false if request_method.nil?
476
+
477
+ verbs.include?(request_method.to_s.upcase)
478
+ end
479
+
480
+ def validate_authentication_header_settings!
481
+ return unless require_authentication_headers?
482
+
483
+ error_msg = []
484
+ error_msg << 'payload_mapping must include :user_id' unless payload_mapping.key?(:user_id)
485
+ error_msg << 'payload_mapping must include :tenant_id' unless payload_mapping.key?(:tenant_id)
486
+ error_msg << 'payload_mapping must include :tenant_slug' unless payload_mapping.key?(:tenant_slug)
487
+ error_msg << 'tenant_id_header_name is required' if tenant_id_header_name.to_s.strip.empty?
488
+ error_msg << 'tenant_slug_header_name is required' if tenant_slug_header_name.to_s.strip.empty?
489
+ error_msg << 'user_id_header_name is required' if user_id_header_name.to_s.strip.empty?
490
+ return unless error_msg.any?
491
+
492
+ raise ConfigurationError,
493
+ "#{error_msg.join(' and ')} when require_authentication_headers is true"
494
+ end
495
+
496
+ def validate_circuit_breaker_settings!
497
+ return unless circuit_breaker_enabled?
498
+
499
+ if circuit_breaker_failure_threshold.to_i <= 0
500
+ raise ConfigurationError, 'circuit_breaker_failure_threshold must be greater than 0'
501
+ end
502
+
503
+ return if circuit_breaker_cooldown_seconds.to_i >= 0
504
+
505
+ raise ConfigurationError, 'circuit_breaker_cooldown_seconds must be greater than or equal to 0'
506
+ end
370
507
  end
371
508
  end
@@ -101,7 +101,10 @@ module RackJwtAegis
101
101
  required_claims << @config.payload_key(:subdomain) if @config.validate_subdomain?
102
102
  required_claims << @config.payload_key(:tenant_id) if @config.validate_tenant_id?
103
103
  required_claims << @config.payload_key(:pathname_slugs) if @config.validate_pathname_slug?
104
+ required_claims << @config.payload_key(:tenant_id) if @config.require_authentication_headers?
105
+ required_claims << @config.payload_key(:tenant_slug) if @config.require_authentication_headers?
104
106
  required_claims << @config.payload_key(:role_ids) if @config.rbac_enabled?
107
+ required_claims += [:exp, :iat] if @config.require_expiration_claims?
105
108
 
106
109
  missing_claims = required_claims.select { |claim| payload[claim.to_s].to_s.empty? }
107
110
  return if missing_claims.empty?
@@ -128,6 +131,25 @@ module RackJwtAegis
128
131
  end
129
132
  end
130
133
 
134
+ if @config.require_authentication_headers?
135
+ tenant_id = payload[@config.payload_key(:tenant_id).to_s]
136
+ if tenant_id.to_s.empty? || (!tenant_id.is_a?(Numeric) && !tenant_id.is_a?(String))
137
+ raise AuthenticationError, 'Invalid tenant_id format in JWT payload'
138
+ end
139
+
140
+ tenant_slug = payload[@config.payload_key(:tenant_slug).to_s]
141
+ if tenant_slug.to_s.empty? || !tenant_slug.is_a?(String)
142
+ raise AuthenticationError, 'Invalid tenant_slug format in JWT payload'
143
+ end
144
+ end
145
+
146
+ if @config.require_expiration_claims?
147
+ ['exp', 'iat'].each do |claim|
148
+ value = payload[claim]
149
+ raise AuthenticationError, "Invalid #{claim} format in JWT payload" unless value.is_a?(Numeric)
150
+ end
151
+ end
152
+
131
153
  # Company group domain should be string (if present)
132
154
  if @config.validate_subdomain?
133
155
  subdomain = payload[@config.payload_key(:subdomain).to_s]
@@ -24,7 +24,7 @@ module RackJwtAegis
24
24
  # validate_subdomain: true,
25
25
  # rbac_enabled: true,
26
26
  # cache_store: :redis,
27
- # skip_paths: ['/health', '/api/public/*']
27
+ # skip_routes: [{ path: '/health' }, { path: '/api/v1/sessions', verbs: [:post] }]
28
28
  # }
29
29
  class Middleware
30
30
  include DebugLogger
@@ -43,6 +43,7 @@ module RackJwtAegis
43
43
  @rbac_manager = RbacManager.new(@config) if @config.rbac_enabled?
44
44
  @response_builder = ResponseBuilder.new(@config)
45
45
  @request_context = RequestContext.new(@config)
46
+ @circuit_breaker = build_circuit_breaker
46
47
 
47
48
  debug_log("Middleware initialized with features: #{enabled_features}")
48
49
  end
@@ -58,10 +59,15 @@ module RackJwtAegis
58
59
 
59
60
  debug_log("Processing request: #{request.request_method} #{request.path}")
60
61
 
61
- # Step 1: Check if path should be skipped
62
- if @config.skip_path?(request.path)
63
- debug_log("Skipping authentication for path: #{request.path}")
64
- return @app.call(env)
62
+ unless circuit_allows_request?
63
+ debug_log('Circuit breaker open; failing fast')
64
+ return @response_builder.error_response('Circuit breaker open', 503)
65
+ end
66
+
67
+ # Step 1: Check if route should be skipped
68
+ if @config.skip_request?(request.path, request.request_method)
69
+ debug_log("Skipping authentication for route: #{request.request_method} #{request.path}")
70
+ return call_app(env)
65
71
  end
66
72
 
67
73
  begin
@@ -101,7 +107,7 @@ module RackJwtAegis
101
107
  debug_log('Request context set successfully')
102
108
 
103
109
  # Continue to application
104
- @app.call(env)
110
+ call_app(env)
105
111
  rescue AuthenticationError => e
106
112
  debug_log("Authentication failed: #{e.message}")
107
113
  @response_builder.unauthorized_response(e.message)
@@ -109,6 +115,7 @@ module RackJwtAegis
109
115
  debug_log("Authorization failed: #{e.message}")
110
116
  @response_builder.forbidden_response(e.message)
111
117
  rescue StandardError => e
118
+ record_circuit_failure
112
119
  debug_log("Unexpected error: #{e.message}")
113
120
  if @config.debug_mode?
114
121
  @response_builder.error_response("Internal error: #{e.message}", 500)
@@ -144,7 +151,31 @@ module RackJwtAegis
144
151
  #
145
152
  # @return [Boolean] true if subdomain or pathname slug validation is enabled
146
153
  def multi_tenant_enabled?
147
- @config.validate_tenant_id? || @config.validate_subdomain? || @config.validate_pathname_slug?
154
+ @config.validate_tenant_id? || @config.validate_subdomain? || @config.validate_pathname_slug? ||
155
+ @config.require_authentication_headers?
156
+ end
157
+
158
+ def build_circuit_breaker
159
+ return nil unless @config.circuit_breaker_enabled?
160
+
161
+ CircuitBreaker.new(
162
+ failure_threshold: @config.circuit_breaker_failure_threshold,
163
+ cooldown_seconds: @config.circuit_breaker_cooldown_seconds,
164
+ )
165
+ end
166
+
167
+ def circuit_allows_request?
168
+ @circuit_breaker.nil? || @circuit_breaker.allow_request?
169
+ end
170
+
171
+ def call_app(env)
172
+ response = @app.call(env)
173
+ @circuit_breaker&.record_success
174
+ response
175
+ end
176
+
177
+ def record_circuit_failure
178
+ @circuit_breaker&.record_failure
148
179
  end
149
180
 
150
181
  # Generate a string describing enabled features for logging
@@ -155,6 +186,8 @@ module RackJwtAegis
155
186
  features << 'TenantId' if @config.validate_tenant_id?
156
187
  features << 'Subdomain' if @config.validate_subdomain?
157
188
  features << 'PathnameSlug' if @config.validate_pathname_slug?
189
+ features << 'StrictHeaders' if @config.require_authentication_headers?
190
+ features << 'CircuitBreaker' if @config.circuit_breaker_enabled?
158
191
  features << 'RBAC' if @config.rbac_enabled?
159
192
  features.join(', ')
160
193
  end
@@ -33,6 +33,7 @@ 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_authentication_headers(request, payload)
36
37
  validate_subdomain(request, payload)
37
38
  validate_pathname_slug(request, payload)
38
39
  validate_tenant_id_header(request, payload)
@@ -40,6 +41,34 @@ module RackJwtAegis
40
41
 
41
42
  private
42
43
 
44
+ def validate_authentication_headers(request, payload)
45
+ return unless @config.require_authentication_headers?
46
+
47
+ validate_header_claim_match(
48
+ request,
49
+ payload,
50
+ header_name: @config.tenant_id_header_name,
51
+ claim_name: @config.payload_key(:tenant_id),
52
+ label: 'tenant id',
53
+ )
54
+
55
+ validate_header_claim_match(
56
+ request,
57
+ payload,
58
+ header_name: @config.tenant_slug_header_name,
59
+ claim_name: @config.payload_key(:tenant_slug),
60
+ label: 'tenant slug',
61
+ )
62
+
63
+ validate_header_claim_match(
64
+ request,
65
+ payload,
66
+ header_name: @config.user_id_header_name,
67
+ claim_name: @config.payload_key(:user_id),
68
+ label: 'user id',
69
+ )
70
+ end
71
+
43
72
  # Level 1 Multi-Tenant: Top-level tenant (Company-Group) validation via subdomain
44
73
  def validate_subdomain(request, payload)
45
74
  return unless @config.validate_subdomain?
@@ -75,14 +104,15 @@ module RackJwtAegis
75
104
  accessible_slugs = payload[@config.payload_key(:pathname_slugs).to_s]
76
105
 
77
106
  if accessible_slugs.nil? || !accessible_slugs.is_a?(Array) || accessible_slugs.empty?
78
- raise AuthorizationError, 'JWT payload missing or invalid pathname_slugs for company access validation'
107
+ raise AuthorizationError, 'JWT payload missing or invalid pathname_slugs for pathname slug access validation'
79
108
  end
80
109
 
81
110
  # Check if requested company slug is in user's accessible list
82
111
  return if accessible_slugs.map(&:downcase).include?(pathname_slug)
83
112
 
113
+ # TODO: make this error configurable as well
84
114
  raise AuthorizationError,
85
- "Company access denied: '#{pathname_slug}' not in accessible companies #{accessible_slugs}"
115
+ "Pathname slug access denied: '#{pathname_slug}' not in accessible pathname slugs #{accessible_slugs}"
86
116
  end
87
117
 
88
118
  # Company Group header validation (additional security layer)
@@ -101,6 +131,23 @@ module RackJwtAegis
101
131
  "Tenant id header mismatch: header '#{header_value}' does not match JWT '#{jwt_claim}'"
102
132
  end
103
133
 
134
+ def validate_header_claim_match(request, payload, header_name:, claim_name:, label:)
135
+ header_value = header_value(request, header_name)
136
+ raise AuthorizationError, "Required authentication header missing: #{header_name}" if header_value.empty?
137
+
138
+ jwt_claim = payload[claim_name.to_s].to_s.strip.downcase
139
+ raise AuthorizationError, "JWT payload missing #{claim_name} for #{label} header validation" if jwt_claim.empty?
140
+
141
+ return if header_value.eql?(jwt_claim)
142
+
143
+ raise AuthorizationError,
144
+ "#{label.capitalize} header mismatch: header '#{header_value}' does not match JWT '#{jwt_claim}'"
145
+ end
146
+
147
+ def header_value(request, header_name)
148
+ request.get_header("HTTP_#{header_name.upcase.tr('-', '_')}").to_s.strip.downcase
149
+ end
150
+
104
151
  def extract_subdomain(host)
105
152
  return nil if host.nil? || host.empty?
106
153
 
@@ -17,8 +17,6 @@ module RackJwtAegis
17
17
  class RbacManager
18
18
  include DebugLogger
19
19
 
20
- CACHE_TTL = 300 # 5 minutes default cache TTL
21
-
22
20
  # Initialize the RBAC manager
23
21
  #
24
22
  # @param config [Configuration] the configuration instance
@@ -38,21 +36,12 @@ module RackJwtAegis
38
36
 
39
37
  # Build permission key
40
38
  permission_key = build_permission_key(user_id, request)
41
-
42
- # Check cached permission first (if middleware can write to cache)
43
- if @permission_cache && @config.cache_write_enabled?
44
- cached_permission = check_cached_permission(permission_key)
45
- return if cached_permission == true
46
-
47
- raise AuthorizationError, 'Access denied - cached permission' if cached_permission == false
48
- end
39
+ return if check_cached_permission(permission_key) == true
49
40
 
50
41
  # Permission not cached or cache miss - check RBAC store
51
42
  has_permission = check_rbac_permission(user_id, request)
52
-
53
43
  # Cache the result if middleware has write access
54
44
  cache_permission_result(permission_key, has_permission)
55
-
56
45
  return if has_permission
57
46
 
58
47
  raise AuthorizationError, 'Access denied - insufficient permissions'
@@ -61,25 +50,15 @@ module RackJwtAegis
61
50
  private
62
51
 
63
52
  def setup_cache_adapters
64
- if @config.cache_write_enabled? && @config.cache_store
65
- # Shared cache mode - both RBAC and permission cache use same store
66
- @rbac_cache = CacheAdapter.build(@config.cache_store, @config.cache_options || {})
67
- @permission_cache = @rbac_cache
68
- else
69
- # Separate cache mode - different stores for RBAC and permissions
70
- if @config.rbac_cache_store
71
- @rbac_cache = CacheAdapter.build(@config.rbac_cache_store, @config.rbac_cache_options || {})
72
- end
53
+ return unless @config.rbac_enabled
73
54
 
74
- if @config.permission_cache_store
75
- @permission_cache = CacheAdapter.build(@config.permission_cache_store, @config.permission_cache_options || {})
76
- end
55
+ begin
56
+ @rbac_cache = CacheAdapter.build(@config.rbac_cache_store, @config.rbac_cache_store_options || {})
57
+ @permissions_cache = CacheAdapter.build(@config.permissions_cache_store,
58
+ @config.permissions_cache_store_options || {})
59
+ rescue ConfigurationError
60
+ raise ConfigurationError, 'RBAC cache store not configured'
77
61
  end
78
-
79
- # Ensure we have at least RBAC cache for permission lookups
80
- return if @rbac_cache
81
-
82
- raise ConfigurationError, 'RBAC cache store not configured'
83
62
  end
84
63
 
85
64
  def build_permission_key(user_id, request)
@@ -87,11 +66,11 @@ module RackJwtAegis
87
66
  end
88
67
 
89
68
  def check_cached_permission(permission_key)
90
- return nil unless @permission_cache
69
+ return nil unless @permissions_cache
91
70
 
92
71
  begin
93
72
  # Get the cached user permissions
94
- user_permissions = @permission_cache.read('user_permissions')
73
+ user_permissions = @permissions_cache.read('user_permissions')
95
74
  return nil if user_permissions.nil? || !user_permissions.is_a?(Hash)
96
75
 
97
76
  # First check: If RBAC permissions were updated recently, nuke ALL cached permissions
@@ -100,7 +79,7 @@ module RackJwtAegis
100
79
  rbac_update_age = Time.now.to_i - rbac_last_update
101
80
 
102
81
  # If RBAC was updated within the TTL period, all cached permissions are invalid
103
- if rbac_update_age <= @config.user_permissions_ttl
82
+ if rbac_update_age <= @config.cached_permissions_ttl
104
83
  nuke_user_permissions_cache("RBAC permissions updated recently (#{rbac_update_age}s ago, within TTL)")
105
84
  return nil
106
85
  end
@@ -113,10 +92,10 @@ module RackJwtAegis
113
92
  permission_age = Time.now.to_i - cached_timestamp
114
93
 
115
94
  # Second check: TTL expiration
116
- if permission_age > @config.user_permissions_ttl
95
+ if permission_age > @config.cached_permissions_ttl
117
96
  # This specific permission expired due to TTL
118
97
  remove_stale_permission(permission_key,
119
- "TTL expired (#{permission_age}s > #{@config.user_permissions_ttl}s)")
98
+ "TTL expired (#{permission_age}s > #{@config.cached_permissions_ttl}s)")
120
99
  return nil
121
100
  end
122
101
 
@@ -148,20 +127,20 @@ module RackJwtAegis
148
127
  end
149
128
 
150
129
  def cache_permission_result(permission_key, has_permission)
151
- return unless @permission_cache
130
+ return unless @permissions_cache
152
131
  return unless has_permission # Only cache positive permissions
153
132
 
154
133
  begin
155
134
  current_time = Time.now.to_i
156
135
 
157
136
  # Get existing user permissions cache or create new one
158
- user_permissions = @permission_cache.read('user_permissions') || {}
137
+ user_permissions = @permissions_cache.read('user_permissions') || {}
159
138
 
160
139
  # Store permission with new format: {"user_id:full_url:method" => timestamp}
161
140
  user_permissions[permission_key] = current_time
162
141
 
163
142
  # Write back to cache
164
- @permission_cache.write('user_permissions', user_permissions, expires_in: CACHE_TTL)
143
+ @permissions_cache.write('user_permissions', user_permissions, expires_in: @config.cached_permissions_ttl)
165
144
 
166
145
  debug_log("Cached permission: #{permission_key} => #{current_time}")
167
146
  rescue CacheError => e
@@ -191,9 +170,7 @@ module RackJwtAegis
191
170
  next unless matched_permission
192
171
 
193
172
  # 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)
196
- end
173
+ cache_matched_permission(user_id, request)
197
174
  return true
198
175
  end
199
176
 
@@ -219,10 +196,10 @@ module RackJwtAegis
219
196
 
220
197
  # Remove a specific stale permission
221
198
  def remove_stale_permission(permission_key, reason)
222
- return unless @permission_cache
199
+ return unless @permissions_cache
223
200
 
224
201
  begin
225
- user_permissions = @permission_cache.read('user_permissions')
202
+ user_permissions = @permissions_cache.read('user_permissions')
226
203
  return unless user_permissions.is_a?(Hash)
227
204
 
228
205
  # Remove the specific permission key
@@ -230,11 +207,11 @@ module RackJwtAegis
230
207
 
231
208
  # If no permissions remain, remove the entire cache
232
209
  if user_permissions.empty?
233
- @permission_cache.delete('user_permissions')
210
+ @permissions_cache.delete('user_permissions')
234
211
  debug_log("Removed last permission, cleared entire cache: #{reason}")
235
212
  else
236
213
  # Update the cache with the modified permissions
237
- @permission_cache.write('user_permissions', user_permissions, expires_in: CACHE_TTL)
214
+ @permissions_cache.write('user_permissions', user_permissions, expires_in: @config.cached_permissions_ttl)
238
215
  debug_log("Removed stale permission #{permission_key}: #{reason}")
239
216
  end
240
217
  rescue CacheError => e
@@ -244,10 +221,10 @@ module RackJwtAegis
244
221
 
245
222
  # Nuke (delete) the entire user permissions cache
246
223
  def nuke_user_permissions_cache(reason)
247
- return unless @permission_cache
224
+ return unless @permissions_cache
248
225
 
249
226
  begin
250
- @permission_cache.delete('user_permissions')
227
+ @permissions_cache.delete('user_permissions')
251
228
  debug_log("Nuked user permissions cache: #{reason}")
252
229
  rescue CacheError => e
253
230
  debug_log("RbacManager cache nuke error: #{e.message}", :warn)
@@ -290,27 +267,18 @@ module RackJwtAegis
290
267
 
291
268
  # Cache the specific permission match for faster future lookups
292
269
  # Format: {"user_id:full_url:method" => timestamp}
293
- def cache_permission_match(user_id, request, _role_id, _matched_permission)
294
- return unless @permission_cache
270
+ def cache_matched_permission(user_id, request)
271
+ return unless @permissions_cache
295
272
 
296
273
  begin
297
274
  current_time = Time.now.to_i
298
-
299
- # Build the permission key in new format
300
- host = request.host || 'localhost'
301
- full_url = "#{host}#{request.path}"
302
- method = request.request_method.downcase
303
- permission_key = "#{user_id}:#{full_url}:#{method}"
304
-
275
+ permission_key = "#{user_id}:#{request.host}#{request.path}:#{request.request_method.downcase}"
305
276
  # Get existing user permissions cache or create new one
306
- user_permissions = @permission_cache.read('user_permissions') || {}
307
-
277
+ user_permissions = @permissions_cache.read('user_permissions') || {}
308
278
  # Store permission with new format
309
279
  user_permissions[permission_key] = current_time
310
-
311
280
  # Write back to cache
312
- @permission_cache.write('user_permissions', user_permissions, expires_in: CACHE_TTL)
313
-
281
+ @permissions_cache.write('user_permissions', user_permissions, expires_in: @config.cached_permissions_ttl)
314
282
  debug_log("Cached user permission: #{permission_key} => #{current_time}")
315
283
  rescue CacheError => e
316
284
  # Log cache error but don't fail the request
@@ -24,6 +24,7 @@ module RackJwtAegis
24
24
  USER_ID_KEY = 'rack_jwt_aegis.user_id'
25
25
  TENANT_ID_KEY = 'rack_jwt_aegis.tenant_id'
26
26
  SUBDOMAIN_KEY = 'rack_jwt_aegis.subdomain'
27
+ TENANT_SLUG_KEY = SUBDOMAIN_KEY
27
28
  PATHNAME_SLUGS_KEY = 'rack_jwt_aegis.pathname_slugs'
28
29
  AUTHENTICATED_KEY = 'rack_jwt_aegis.authenticated'
29
30
 
@@ -84,6 +85,10 @@ module RackJwtAegis
84
85
  env[TENANT_ID_KEY]
85
86
  end
86
87
 
88
+ def self.tenant_slug(env)
89
+ env[TENANT_SLUG_KEY]
90
+ end
91
+
87
92
  def self.subdomain(env)
88
93
  env[SUBDOMAIN_KEY]
89
94
  end
@@ -100,6 +105,10 @@ module RackJwtAegis
100
105
  tenant_id(request.env)
101
106
  end
102
107
 
108
+ def self.current_tenant_slug(request)
109
+ tenant_slug(request.env)
110
+ end
111
+
103
112
  def self.has_pathname_slug_access?(env, pathname_slug)
104
113
  pathname_slugs(env).include?(pathname_slug)
105
114
  end
@@ -112,7 +121,11 @@ module RackJwtAegis
112
121
 
113
122
  def set_tenant_context(env, payload)
114
123
  # Set multi-tenant information
115
- env[TENANT_ID_KEY] = payload[@config.payload_key(:tenant_id).to_s] if @config.validate_tenant_id?
124
+ if @config.validate_tenant_id? || @config.require_authentication_headers?
125
+ env[TENANT_ID_KEY] =
126
+ payload[@config.payload_key(:tenant_id).to_s]
127
+ end
128
+ env[SUBDOMAIN_KEY] = payload[@config.payload_key(:tenant_slug).to_s] if @config.require_authentication_headers?
116
129
  env[SUBDOMAIN_KEY] = payload[@config.payload_key(:subdomain).to_s] if @config.validate_subdomain?
117
130
  return unless @config.validate_pathname_slug? || @config.payload_mapping.key?(:pathname_slugs)
118
131
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RackJwtAegis
4
- VERSION = '1.1.0'
4
+ VERSION = '1.1.1'
5
5
  end
@@ -3,6 +3,7 @@
3
3
  require_relative 'rack_jwt_aegis/version'
4
4
  require_relative 'rack_jwt_aegis/configuration'
5
5
  require_relative 'rack_jwt_aegis/debug_logger'
6
+ require_relative 'rack_jwt_aegis/circuit_breaker'
6
7
  require_relative 'rack_jwt_aegis/middleware'
7
8
  require_relative 'rack_jwt_aegis/jwt_validator'
8
9
  require_relative 'rack_jwt_aegis/multi_tenant_validator'
@@ -23,7 +24,7 @@ require_relative 'rack_jwt_aegis/response_builder'
23
24
  # - RBAC with flexible permission caching
24
25
  # - Multiple cache adapter support (Memory, Redis, Memcached, SolidCache)
25
26
  # - Request context management
26
- # - Configurable skip paths and custom validators
27
+ # - Configurable skip routes and custom validators
27
28
  #
28
29
  # @example Basic usage
29
30
  # use RackJwtAegis::Middleware, jwt_secret: ENV['JWT_SECRET']
@@ -34,8 +35,6 @@ require_relative 'rack_jwt_aegis/response_builder'
34
35
  # validate_subdomain: true,
35
36
  # validate_pathname_slug: true,
36
37
  # rbac_enabled: true,
37
- # cache_store: :redis,
38
- # cache_write_enabled: true
39
38
  # }
40
39
  module RackJwtAegis
41
40
  # Base error class for all RackJwtAegis exceptions
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.1.0
4
+ version: 1.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ken C. Demanawa
@@ -43,6 +43,26 @@ dependencies:
43
43
  - - ">="
44
44
  - !ruby/object:Gem::Version
45
45
  version: '3.2'
46
+ - !ruby/object:Gem::Dependency
47
+ name: ratomic
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: 0.4.1
53
+ - - "<"
54
+ - !ruby/object:Gem::Version
55
+ version: '0.5'
56
+ type: :runtime
57
+ prerelease: false
58
+ version_requirements: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: 0.4.1
63
+ - - "<"
64
+ - !ruby/object:Gem::Version
65
+ version: '0.5'
46
66
  description: |-
47
67
  JWT authentication and authorization midleware with multi-tenant suport,\
48
68
  company validation, and subdomain isolation.
@@ -54,6 +74,7 @@ extensions: []
54
74
  extra_rdoc_files: []
55
75
  files:
56
76
  - ".rubocop.yml"
77
+ - ".ruby-version"
57
78
  - ".yardopts"
58
79
  - CHANGELOG.md
59
80
  - CODE_OF_CONDUCT.md
@@ -66,6 +87,7 @@ files:
66
87
  - exe/rack_jwt_aegis
67
88
  - lib/rack_jwt_aegis.rb
68
89
  - lib/rack_jwt_aegis/cache_adapter.rb
90
+ - lib/rack_jwt_aegis/circuit_breaker.rb
69
91
  - lib/rack_jwt_aegis/configuration.rb
70
92
  - lib/rack_jwt_aegis/debug_logger.rb
71
93
  - lib/rack_jwt_aegis/jwt_validator.rb