jwt_auth_cognito 1.0.0.pre.beta.14 → 1.0.0.pre.beta.15
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 +3 -16
- data/CHANGELOG.md +30 -0
- data/jwt_auth_cognito.gemspec +1 -0
- data/lib/jwt_auth_cognito/configuration.rb +8 -0
- data/lib/jwt_auth_cognito/jwt_validator.rb +9 -0
- data/lib/jwt_auth_cognito/redis_service.rb +97 -57
- data/lib/jwt_auth_cognito/user_data_service.rb +87 -16
- data/lib/jwt_auth_cognito/version.rb +1 -1
- metadata +16 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5478b2beaffce3e103aa915b73735e2b78ae39d4f802fb663fd0aad79a721207
|
|
4
|
+
data.tar.gz: 2023c50ebe2234f58243820118edce9e8d413dc348b0798642e4378adc356898
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e8a0c2b0bb5897f15bc83b065ccfa060f18d509788cd1406325792caa74ac1d23bc9910c4c81d3f1c4923e21efd6aa0e35655bffb8f44dc1f74767a3ed3e51d9
|
|
7
|
+
data.tar.gz: 5bbb8e162f0150c06730a8c63723e6458b50f32404bc12661622d8b670fa3901f1dac9c93a909d59f77d7f63ff0e1d2b9592c2df69247feb9b9b3aac5786db1b
|
data/.rubocop.yml
CHANGED
|
@@ -6,16 +6,12 @@ AllCops:
|
|
|
6
6
|
SuggestExtensions: false
|
|
7
7
|
TargetRubyVersion: 2.7
|
|
8
8
|
|
|
9
|
-
#
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
# Limits for code metrics that remain enabled (legacy codebase).
|
|
10
|
+
# NOTE: ClassLength, MethodLength, AbcSize, CyclomaticComplexity and PerceivedComplexity
|
|
11
|
+
# are disabled further below — that block is their single source of truth.
|
|
13
12
|
Metrics/ModuleLength:
|
|
14
13
|
Max: 300
|
|
15
14
|
|
|
16
|
-
Metrics/MethodLength:
|
|
17
|
-
Max: 30
|
|
18
|
-
|
|
19
15
|
Metrics/BlockLength:
|
|
20
16
|
Max: 50
|
|
21
17
|
Exclude:
|
|
@@ -23,15 +19,6 @@ Metrics/BlockLength:
|
|
|
23
19
|
- 'lib/tasks/**/*'
|
|
24
20
|
- 'Rakefile'
|
|
25
21
|
|
|
26
|
-
Metrics/AbcSize:
|
|
27
|
-
Max: 30
|
|
28
|
-
|
|
29
|
-
Metrics/CyclomaticComplexity:
|
|
30
|
-
Max: 15
|
|
31
|
-
|
|
32
|
-
Metrics/PerceivedComplexity:
|
|
33
|
-
Max: 15
|
|
34
|
-
|
|
35
22
|
Metrics/ParameterLists:
|
|
36
23
|
Max: 8
|
|
37
24
|
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.0.0-beta.16] - 2026-06-25
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **Blacklist key now matches auth-service (revocations were being missed).** The gem
|
|
15
|
+
looked up revoked tokens under `jwt_blacklist:{jti | sha256(token)[0,16]}`, but
|
|
16
|
+
auth-service writes them under `revoked:{base64(last 32 chars of token)}` (the same
|
|
17
|
+
scheme the Node validator reads). The prefix and the id derivation both differed, so
|
|
18
|
+
a token revoked via `/auth/logout` was **never detected** by Ruby consumers and kept
|
|
19
|
+
validating until it expired on its own. `BLACKLIST_PREFIX` is now `revoked:` and
|
|
20
|
+
`#generate_token_id` derives `Base64.strict_encode64(token[-32..])`, matching
|
|
21
|
+
auth-service and the npm package exactly.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- **Thread-safe shared Redis connection pool.** `RedisService` previously held a single,
|
|
26
|
+
non-thread-safe connection per instance (and the validator builds several instances),
|
|
27
|
+
which races under Puma/Sidekiq and makes connection count grow with threads/instances.
|
|
28
|
+
It now uses one process-wide `ConnectionPool` (new `connection_pool` dependency) shared
|
|
29
|
+
across all instances, sized via `redis_pool_size` (defaults to `RAILS_MAX_THREADS`,
|
|
30
|
+
env `REDIS_POOL_SIZE`) with `redis_pool_timeout` (env `REDIS_POOL_TIMEOUT`, default 5s).
|
|
31
|
+
This bounds connections to the pool size per process regardless of how many validators
|
|
32
|
+
or threads exist. Forking servers should call `JwtAuthCognito::RedisService.reset_pool!`
|
|
33
|
+
in their `after_fork` hook.
|
|
34
|
+
- **`#is_token_revoked?` fails open on any Redis/pool error.** Previously only
|
|
35
|
+
`Redis::BaseError` was rescued (a connection failure or pool-exhaustion error would
|
|
36
|
+
propagate and could block validation). It now rescues `StandardError` and returns
|
|
37
|
+
`false`, so a saturated or unreachable Redis never locks every user out — matching the
|
|
38
|
+
Node validator and auth-service's `RedisService.isTokenRevoked`.
|
|
39
|
+
|
|
10
40
|
## [1.0.0-beta.14] - 2026-06-18
|
|
11
41
|
|
|
12
42
|
### Changed
|
data/jwt_auth_cognito.gemspec
CHANGED
|
@@ -44,6 +44,7 @@ Gem::Specification.new do |spec|
|
|
|
44
44
|
# Dependencies - compatible with llegando-neo (Ruby 2.7.5, Rails 5.2.6)
|
|
45
45
|
spec.add_dependency 'aws-sdk-cognitoidentityprovider', '~> 1.0' # For GetUser identity enrichment
|
|
46
46
|
spec.add_dependency 'aws-sdk-ssm', '~> 1.0' # For AWS Parameter Store support
|
|
47
|
+
spec.add_dependency 'connection_pool', '~> 2.2' # Shared, thread-safe Redis connection pool
|
|
47
48
|
spec.add_dependency 'json', '~> 2.0'
|
|
48
49
|
spec.add_dependency 'jwt', '~> 2.0'
|
|
49
50
|
spec.add_dependency 'redis', '>= 4.2.5', '< 6.0' # Compatible with llegando-neo redis version
|
|
@@ -6,6 +6,7 @@ module JwtAuthCognito
|
|
|
6
6
|
:redis_host, :redis_port, :redis_password, :redis_db,
|
|
7
7
|
:redis_ssl, :redis_timeout, :redis_connect_timeout, :redis_read_timeout,
|
|
8
8
|
:redis_ca_cert_path, :redis_ca_cert_name, :redis_verify_mode,
|
|
9
|
+
:redis_pool_size, :redis_pool_timeout,
|
|
9
10
|
:jwks_cache_ttl, :validation_mode, :environment,
|
|
10
11
|
:enable_api_key_validation, :enable_user_data_retrieval,
|
|
11
12
|
:enable_user_identity_enrichment, :identity_cache_timeout, :identity_attributes
|
|
@@ -26,6 +27,13 @@ module JwtAuthCognito
|
|
|
26
27
|
@redis_connect_timeout = (ENV['REDIS_CONNECT_TIMEOUT'] || 10).to_i
|
|
27
28
|
@redis_read_timeout = (ENV['REDIS_READ_TIMEOUT'] || 10).to_i
|
|
28
29
|
|
|
30
|
+
# Shared connection pool sizing. Defaults to the web server's thread count
|
|
31
|
+
# (RAILS_MAX_THREADS) so the pool matches concurrency without over-allocating
|
|
32
|
+
# connections. redis_pool_timeout is the max seconds to wait for a free
|
|
33
|
+
# connection before raising ConnectionPool::TimeoutError.
|
|
34
|
+
@redis_pool_size = (ENV['REDIS_POOL_SIZE'] || ENV['RAILS_MAX_THREADS'] || 5).to_i
|
|
35
|
+
@redis_pool_timeout = (ENV['REDIS_POOL_TIMEOUT'] || 5).to_i
|
|
36
|
+
|
|
29
37
|
# TLS specific configuration (compatible with auth-service)
|
|
30
38
|
@redis_ca_cert_path = ENV.fetch('REDIS_CA_CERT_PATH', nil)
|
|
31
39
|
@redis_ca_cert_name = ENV.fetch('REDIS_CA_CERT_NAME', nil)
|
|
@@ -305,6 +305,15 @@ module JwtAuthCognito
|
|
|
305
305
|
PermissionChecker.permission_in_list?(permission, result[:permissions])
|
|
306
306
|
end
|
|
307
307
|
|
|
308
|
+
# Resolve the user's effective ABAC access scope for (app, org).
|
|
309
|
+
# Hash keyed by "<resourceType>:<action>" => { 'match' =>, 'include' =>, 'exclude' => }.
|
|
310
|
+
# The consuming app filters its data with this. Returns {} when there is no scope.
|
|
311
|
+
def get_access_scope(user_id, app_id, org_id)
|
|
312
|
+
return {} unless @user_data_service
|
|
313
|
+
|
|
314
|
+
@user_data_service.resolve_access_scope(user_id, app_id, org_id)
|
|
315
|
+
end
|
|
316
|
+
|
|
308
317
|
def is_token_expired?(token)
|
|
309
318
|
payload = decode_token(token)
|
|
310
319
|
return true if payload.is_a?(Hash) && payload[:error]
|
|
@@ -1,27 +1,57 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'redis'
|
|
4
|
+
require 'connection_pool'
|
|
4
5
|
require 'digest'
|
|
5
6
|
require 'openssl'
|
|
7
|
+
require 'base64'
|
|
6
8
|
|
|
7
9
|
module JwtAuthCognito
|
|
8
10
|
class RedisService
|
|
9
|
-
|
|
11
|
+
# auth-service revokes tokens under `revoked:{base64(last 32 chars of token)}`
|
|
12
|
+
# (see tokenBlacklist.ts#getTokenHash). This prefix and the id derivation in
|
|
13
|
+
# #generate_token_id MUST match it exactly, otherwise revocations performed by
|
|
14
|
+
# auth-service (/auth/logout) are looked up under a key that never exists and are
|
|
15
|
+
# silently missed. The Node validator uses this same scheme.
|
|
16
|
+
BLACKLIST_PREFIX = 'revoked:'
|
|
10
17
|
USER_TOKENS_PREFIX = 'user_tokens:'
|
|
11
18
|
|
|
19
|
+
# One shared, thread-safe connection pool per process — independent of how many
|
|
20
|
+
# RedisService instances exist (the blacklist, user-data and api-key services each
|
|
21
|
+
# build their own instance, yet they all share this pool). This bounds Redis
|
|
22
|
+
# connections to `redis_pool_size` per process instead of growing with the number
|
|
23
|
+
# of instances/threads, and makes the service safe under Puma/Sidekiq.
|
|
24
|
+
class << self
|
|
25
|
+
# Builds (once) and returns the shared pool. The factory is a callable that
|
|
26
|
+
# produces a connected Redis client; it runs lazily as the pool grows.
|
|
27
|
+
def pool(config, factory)
|
|
28
|
+
@pool ||= ConnectionPool.new(
|
|
29
|
+
size: config.redis_pool_size,
|
|
30
|
+
timeout: config.redis_pool_timeout
|
|
31
|
+
) { factory.call }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Drops the shared pool. Call after forking (Puma/Unicorn `after_fork`, Sidekiq)
|
|
35
|
+
# so each child builds its own connections instead of reusing parent sockets.
|
|
36
|
+
# Also used to isolate tests.
|
|
37
|
+
def reset_pool!
|
|
38
|
+
@pool = nil
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
12
42
|
def initialize(config = JwtAuthCognito.configuration)
|
|
13
43
|
@config = config
|
|
14
|
-
@redis = nil
|
|
15
44
|
end
|
|
16
45
|
|
|
17
46
|
def save_revoked_token(token_id, ttl = nil)
|
|
18
|
-
connect_redis
|
|
19
47
|
key = "#{BLACKLIST_PREFIX}#{token_id}"
|
|
20
48
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
49
|
+
with_redis do |redis|
|
|
50
|
+
if ttl
|
|
51
|
+
redis.setex(key, ttl, 'revoked')
|
|
52
|
+
else
|
|
53
|
+
redis.set(key, 'revoked')
|
|
54
|
+
end
|
|
25
55
|
end
|
|
26
56
|
|
|
27
57
|
true
|
|
@@ -30,52 +60,53 @@ module JwtAuthCognito
|
|
|
30
60
|
end
|
|
31
61
|
|
|
32
62
|
def is_token_revoked?(token_id)
|
|
33
|
-
connect_redis
|
|
34
63
|
key = "#{BLACKLIST_PREFIX}#{token_id}"
|
|
35
|
-
result =
|
|
64
|
+
result = with_redis { |redis| redis.exists?(key) }
|
|
36
65
|
result.is_a?(Integer) ? result.positive? : result
|
|
37
|
-
rescue
|
|
38
|
-
#
|
|
66
|
+
rescue StandardError
|
|
67
|
+
# Fail-open graceful degradation: if Redis is unreachable or the pool is
|
|
68
|
+
# exhausted, do not block validation. Mirrors the Node validator and
|
|
69
|
+
# auth-service (RedisService.isTokenRevoked), which also treat errors as
|
|
70
|
+
# "not revoked" so an infrastructure issue never locks every user out.
|
|
39
71
|
false
|
|
40
72
|
end
|
|
41
73
|
|
|
42
74
|
def clear_revoked_tokens
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
75
|
+
with_redis do |redis|
|
|
76
|
+
keys = redis.keys("#{BLACKLIST_PREFIX}*")
|
|
77
|
+
redis.del(*keys) if keys.any?
|
|
78
|
+
keys.length
|
|
79
|
+
end
|
|
47
80
|
rescue Redis::BaseError => e
|
|
48
81
|
raise BlacklistError, "Failed to clear revoked tokens: #{e.message}"
|
|
49
82
|
end
|
|
50
83
|
|
|
51
84
|
def invalidate_user_tokens(user_id)
|
|
52
|
-
connect_redis
|
|
53
|
-
|
|
54
|
-
# Get all tokens for the user
|
|
55
85
|
user_key = "#{USER_TOKENS_PREFIX}#{user_id}"
|
|
56
|
-
token_ids = @redis.smembers(user_key)
|
|
57
86
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
save_revoked_token(token_id)
|
|
61
|
-
end
|
|
87
|
+
with_redis do |redis|
|
|
88
|
+
token_ids = redis.smembers(user_key)
|
|
62
89
|
|
|
63
|
-
|
|
64
|
-
|
|
90
|
+
# Persist a revocation marker for each tracked token, then clear the set.
|
|
91
|
+
# Inlined (instead of calling #save_revoked_token) to reuse this single
|
|
92
|
+
# pooled connection and avoid a nested pool checkout.
|
|
93
|
+
token_ids.each { |token_id| redis.set("#{BLACKLIST_PREFIX}#{token_id}", 'revoked') }
|
|
94
|
+
redis.del(user_key)
|
|
65
95
|
|
|
66
|
-
|
|
96
|
+
token_ids.length
|
|
97
|
+
end
|
|
67
98
|
rescue Redis::BaseError => e
|
|
68
99
|
raise BlacklistError, "Failed to invalidate user tokens: #{e.message}"
|
|
69
100
|
end
|
|
70
101
|
|
|
71
102
|
def track_user_token(user_id, token_id, ttl = nil)
|
|
72
|
-
connect_redis
|
|
73
|
-
|
|
74
103
|
user_key = "#{USER_TOKENS_PREFIX}#{user_id}"
|
|
75
|
-
@redis.sadd(user_key, token_id)
|
|
76
104
|
|
|
77
|
-
|
|
78
|
-
|
|
105
|
+
with_redis do |redis|
|
|
106
|
+
redis.sadd(user_key, token_id)
|
|
107
|
+
# Set expiration on the user's token set
|
|
108
|
+
redis.expire(user_key, ttl) if ttl
|
|
109
|
+
end
|
|
79
110
|
|
|
80
111
|
true
|
|
81
112
|
rescue Redis::BaseError
|
|
@@ -84,34 +115,31 @@ module JwtAuthCognito
|
|
|
84
115
|
end
|
|
85
116
|
|
|
86
117
|
def generate_token_id(token)
|
|
87
|
-
#
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
# Generate hash-based ID
|
|
96
|
-
Digest::SHA256.hexdigest(token)[0, 16]
|
|
118
|
+
# Must match auth-service's key derivation (tokenBlacklist.ts#getTokenHash):
|
|
119
|
+
# base64 of the last 32 characters of the raw token. Using the JTI or a SHA256
|
|
120
|
+
# here would look up a key auth-service never writes, so revocations would be
|
|
121
|
+
# missed. The Node validator (token-blacklist-service.ts#getTokenHash) is identical.
|
|
122
|
+
suffix = token.to_s
|
|
123
|
+
suffix = suffix[-32..] || suffix
|
|
124
|
+
Base64.strict_encode64(suffix)
|
|
97
125
|
end
|
|
98
126
|
|
|
99
127
|
def get(key)
|
|
100
|
-
|
|
101
|
-
@redis.get(key)
|
|
128
|
+
with_redis { |redis| redis.get(key) }
|
|
102
129
|
rescue Redis::BaseError => e
|
|
103
130
|
raise BlacklistError, "Failed to get key '#{key}': #{e.message}"
|
|
104
131
|
end
|
|
105
132
|
|
|
106
133
|
def set(key, value, ttl = nil)
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
134
|
+
with_redis do |redis|
|
|
135
|
+
# Only attach an expiry for a positive TTL. A nil or non-positive ttl (note:
|
|
136
|
+
# 0 is truthy in Ruby) means a persistent key — `SETEX key 0` is rejected by
|
|
137
|
+
# Redis with "ERR invalid expire time", so fall back to a plain SET.
|
|
138
|
+
if ttl&.positive?
|
|
139
|
+
redis.setex(key, ttl, value)
|
|
140
|
+
else
|
|
141
|
+
redis.set(key, value)
|
|
142
|
+
end
|
|
115
143
|
end
|
|
116
144
|
true
|
|
117
145
|
rescue Redis::BaseError => e
|
|
@@ -120,19 +148,24 @@ module JwtAuthCognito
|
|
|
120
148
|
|
|
121
149
|
private
|
|
122
150
|
|
|
123
|
-
|
|
124
|
-
|
|
151
|
+
# Checks out a connection from the shared pool for the duration of the block.
|
|
152
|
+
# The pool is built lazily on first use and reused across all instances.
|
|
153
|
+
def with_redis(&block)
|
|
154
|
+
self.class.pool(@config, method(:build_connection)).with(&block)
|
|
155
|
+
end
|
|
125
156
|
|
|
157
|
+
# Connection factory used by the pool. Verifies connectivity with PING and retries
|
|
158
|
+
# with exponential backoff, mirroring the previous single-connection behavior.
|
|
159
|
+
def build_connection
|
|
126
160
|
redis_options = build_redis_options
|
|
127
161
|
|
|
128
|
-
# Retry logic with exponential backoff (similar to Node.js implementation)
|
|
129
162
|
max_retries = 3
|
|
130
163
|
retry_count = 0
|
|
131
164
|
|
|
132
165
|
begin
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
166
|
+
redis = Redis.new(redis_options)
|
|
167
|
+
redis.ping # Test connection
|
|
168
|
+
redis
|
|
136
169
|
rescue Redis::BaseError => e
|
|
137
170
|
retry_count += 1
|
|
138
171
|
raise BlacklistError, "Failed to connect to Redis after #{max_retries} retries: #{e.message}" unless retry_count <= max_retries
|
|
@@ -142,6 +175,13 @@ module JwtAuthCognito
|
|
|
142
175
|
end
|
|
143
176
|
end
|
|
144
177
|
|
|
178
|
+
# Kept for backward compatibility: the `jwt_auth_cognito:test_redis` rake task
|
|
179
|
+
# calls this via #send. Validates connectivity by checking out a pooled connection
|
|
180
|
+
# and issuing a PING.
|
|
181
|
+
def connect_redis
|
|
182
|
+
with_redis(&:ping)
|
|
183
|
+
end
|
|
184
|
+
|
|
145
185
|
def build_redis_options
|
|
146
186
|
options = {
|
|
147
187
|
host: @config.redis_host,
|
|
@@ -349,32 +349,60 @@ module JwtAuthCognito
|
|
|
349
349
|
@stats[:connection_status] = 'disconnected'
|
|
350
350
|
end
|
|
351
351
|
|
|
352
|
-
|
|
352
|
+
# Resolves the effective ABAC access scope for (user, app, org).
|
|
353
|
+
# = union of role default accessScope (R2) + user accessScope (override/extend)
|
|
354
|
+
# + legacy resourceRestrictions (= include without match).
|
|
355
|
+
# Keyed by "<resourceType>:<action>" (R3). Expired roles (roleExpiry) are skipped (R1).
|
|
356
|
+
# Returns {} when there is no scope (open access to everything).
|
|
357
|
+
def resolve_access_scope(user_id, app_id, organization_id)
|
|
358
|
+
raw = @redis_service.get("user:permissions:#{user_id}")
|
|
359
|
+
return {} unless raw
|
|
353
360
|
|
|
354
|
-
|
|
355
|
-
|
|
361
|
+
data = JSON.parse(raw)
|
|
362
|
+
org_perms = data.dig('permissions', app_id, organization_id)
|
|
363
|
+
return {} unless org_perms.is_a?(Hash) && org_perms['status'] == 'active'
|
|
364
|
+
|
|
365
|
+
now = (Time.now.to_f * 1000).to_i
|
|
366
|
+
expiry = org_perms['roleExpiry'].is_a?(Hash) ? org_perms['roleExpiry'] : {}
|
|
367
|
+
roles = org_perms['roles'].is_a?(Array) ? org_perms['roles'] : []
|
|
368
|
+
active_roles = roles.reject { |role_id| expiry[role_id] && expiry[role_id] <= now }
|
|
369
|
+
|
|
370
|
+
effective = {}
|
|
371
|
+
|
|
372
|
+
# 1) Role default scope (R2)
|
|
373
|
+
unless active_roles.empty?
|
|
374
|
+
app_roles = get_app_roles(app_id, organization_id)
|
|
375
|
+
app_roles = {} unless app_roles.is_a?(Hash)
|
|
376
|
+
active_roles.each do |role_id|
|
|
377
|
+
role = app_roles[role_id]
|
|
378
|
+
next unless role.is_a?(Hash)
|
|
379
|
+
next if role['isActive'] == false
|
|
380
|
+
|
|
381
|
+
merge_scope_into(effective, role['accessScope'])
|
|
382
|
+
end
|
|
383
|
+
end
|
|
356
384
|
|
|
357
|
-
|
|
358
|
-
|
|
385
|
+
# 2) User override/extend (R2)
|
|
386
|
+
merge_scope_into(effective, org_perms['accessScope'])
|
|
359
387
|
|
|
360
|
-
#
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
388
|
+
# 3) Legacy resourceRestrictions = include (no match)
|
|
389
|
+
restrictions = org_perms['resourceRestrictions']
|
|
390
|
+
if restrictions.is_a?(Hash)
|
|
391
|
+
restrictions.each do |key, ids|
|
|
392
|
+
merge_scope_into(effective, { key => { 'include' => ids } })
|
|
393
|
+
end
|
|
365
394
|
end
|
|
366
395
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
@cache[key] = value
|
|
372
|
-
@cache_timestamps[key] = Time.now.to_i
|
|
396
|
+
effective
|
|
397
|
+
rescue StandardError => e
|
|
398
|
+
puts "Error resolving access scope for #{user_id}:#{app_id}:#{organization_id}: #{e.message}"
|
|
399
|
+
{}
|
|
373
400
|
end
|
|
374
401
|
|
|
375
402
|
# Resolve permissions the user has over a specific resource instance (ReBAC).
|
|
376
403
|
# Applies resourceRestrictions per-permission: open mode if none configured.
|
|
377
404
|
# Returns nil if the user has no active membership in that org.
|
|
405
|
+
# NOTE: public because JwtValidator calls it with an explicit receiver.
|
|
378
406
|
def resolve_resource_permissions(user_id, app_id, organization_id, resource_id)
|
|
379
407
|
effective = resolve_effective_permissions(user_id, app_id, organization_id)
|
|
380
408
|
return nil unless effective
|
|
@@ -401,6 +429,49 @@ module JwtAuthCognito
|
|
|
401
429
|
nil
|
|
402
430
|
end
|
|
403
431
|
|
|
432
|
+
private
|
|
433
|
+
|
|
434
|
+
def get_from_cache(key)
|
|
435
|
+
return nil unless @cache.key?(key)
|
|
436
|
+
|
|
437
|
+
timestamp = @cache_timestamps[key]
|
|
438
|
+
return nil unless timestamp
|
|
439
|
+
|
|
440
|
+
# Check if cache entry has expired
|
|
441
|
+
if Time.now.to_i - timestamp > @config[:cache_timeout]
|
|
442
|
+
@cache.delete(key)
|
|
443
|
+
@cache_timestamps.delete(key)
|
|
444
|
+
return nil
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
@cache[key]
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def set_in_cache(key, value, _ttl = nil)
|
|
451
|
+
@cache[key] = value
|
|
452
|
+
@cache_timestamps[key] = Time.now.to_i
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
# Additively merge a scope map into another: union match per dimension, and include/exclude.
|
|
456
|
+
def merge_scope_into(into, from)
|
|
457
|
+
return unless from.is_a?(Hash)
|
|
458
|
+
|
|
459
|
+
from.each do |key, scope|
|
|
460
|
+
next unless scope.is_a?(Hash)
|
|
461
|
+
|
|
462
|
+
target = into[key] || {}
|
|
463
|
+
if scope['match'].is_a?(Hash)
|
|
464
|
+
target['match'] ||= {}
|
|
465
|
+
scope['match'].each do |dim, values|
|
|
466
|
+
target['match'][dim] = ((target['match'][dim] || []) + Array(values)).uniq
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
target['include'] = ((target['include'] || []) + Array(scope['include'])).uniq if scope['include']
|
|
470
|
+
target['exclude'] = ((target['exclude'] || []) + Array(scope['exclude'])).uniq if scope['exclude']
|
|
471
|
+
into[key] = target
|
|
472
|
+
end
|
|
473
|
+
end
|
|
474
|
+
|
|
404
475
|
def compute_permissions_from_roles(app_id, organization_id, role_names)
|
|
405
476
|
roles_data = get_app_roles(app_id, organization_id)
|
|
406
477
|
return [] unless roles_data.is_a?(Hash)
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: jwt_auth_cognito
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.0.0.pre.beta.
|
|
4
|
+
version: 1.0.0.pre.beta.15
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- The Optimal
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-06-
|
|
11
|
+
date: 2026-06-26 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: aws-sdk-cognitoidentityprovider
|
|
@@ -38,6 +38,20 @@ dependencies:
|
|
|
38
38
|
- - "~>"
|
|
39
39
|
- !ruby/object:Gem::Version
|
|
40
40
|
version: '1.0'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: connection_pool
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '2.2'
|
|
48
|
+
type: :runtime
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '2.2'
|
|
41
55
|
- !ruby/object:Gem::Dependency
|
|
42
56
|
name: json
|
|
43
57
|
requirement: !ruby/object:Gem::Requirement
|