flagkit 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 +7 -0
- data/LICENSE +21 -0
- data/README.md +196 -0
- data/lib/flagkit/client.rb +443 -0
- data/lib/flagkit/core/cache.rb +162 -0
- data/lib/flagkit/core/encrypted_cache.rb +227 -0
- data/lib/flagkit/core/event_persistence.rb +513 -0
- data/lib/flagkit/core/event_queue.rb +190 -0
- data/lib/flagkit/core/polling_manager.rb +112 -0
- data/lib/flagkit/core/streaming_manager.rb +469 -0
- data/lib/flagkit/error/error_code.rb +98 -0
- data/lib/flagkit/error/error_sanitizer.rb +48 -0
- data/lib/flagkit/error/flagkit_error.rb +95 -0
- data/lib/flagkit/http/circuit_breaker.rb +145 -0
- data/lib/flagkit/http/http_client.rb +312 -0
- data/lib/flagkit/options.rb +222 -0
- data/lib/flagkit/types/evaluation_context.rb +121 -0
- data/lib/flagkit/types/evaluation_reason.rb +22 -0
- data/lib/flagkit/types/evaluation_result.rb +77 -0
- data/lib/flagkit/types/flag_state.rb +100 -0
- data/lib/flagkit/types/flag_type.rb +46 -0
- data/lib/flagkit/utils/security.rb +528 -0
- data/lib/flagkit/utils/version.rb +116 -0
- data/lib/flagkit/version.rb +5 -0
- data/lib/flagkit.rb +166 -0
- metadata +200 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FlagKit
|
|
4
|
+
module Core
|
|
5
|
+
# Thread-safe in-memory cache with TTL and LRU eviction.
|
|
6
|
+
class Cache
|
|
7
|
+
# Represents a cached entry with expiration.
|
|
8
|
+
class Entry
|
|
9
|
+
attr_reader :value, :expires_at
|
|
10
|
+
attr_accessor :last_accessed_at
|
|
11
|
+
|
|
12
|
+
def initialize(value, ttl_seconds)
|
|
13
|
+
@value = value
|
|
14
|
+
@expires_at = Time.now + ttl_seconds
|
|
15
|
+
@last_accessed_at = Time.now
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def expired?
|
|
19
|
+
Time.now > expires_at
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
attr_reader :ttl, :max_size
|
|
24
|
+
|
|
25
|
+
# @param ttl [Integer] Time to live in seconds
|
|
26
|
+
# @param max_size [Integer] Maximum number of entries
|
|
27
|
+
def initialize(ttl: 300, max_size: 1000)
|
|
28
|
+
@ttl = ttl
|
|
29
|
+
@max_size = max_size
|
|
30
|
+
@entries = {}
|
|
31
|
+
@mutex = Mutex.new
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Gets a value from the cache.
|
|
35
|
+
#
|
|
36
|
+
# @param key [String] The cache key
|
|
37
|
+
# @return [Object, nil] The cached value or nil
|
|
38
|
+
def get(key)
|
|
39
|
+
@mutex.synchronize do
|
|
40
|
+
entry = @entries[key]
|
|
41
|
+
return nil unless entry
|
|
42
|
+
|
|
43
|
+
if entry.expired?
|
|
44
|
+
@entries.delete(key)
|
|
45
|
+
return nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
entry.last_accessed_at = Time.now
|
|
49
|
+
entry.value
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Sets a value in the cache.
|
|
54
|
+
#
|
|
55
|
+
# @param key [String] The cache key
|
|
56
|
+
# @param value [Object] The value to cache
|
|
57
|
+
def set(key, value)
|
|
58
|
+
@mutex.synchronize do
|
|
59
|
+
evict_if_needed
|
|
60
|
+
@entries[key] = Entry.new(value, ttl)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Checks if a key exists in the cache.
|
|
65
|
+
#
|
|
66
|
+
# @param key [String] The cache key
|
|
67
|
+
# @return [Boolean]
|
|
68
|
+
def has?(key)
|
|
69
|
+
@mutex.synchronize do
|
|
70
|
+
entry = @entries[key]
|
|
71
|
+
return false unless entry
|
|
72
|
+
|
|
73
|
+
if entry.expired?
|
|
74
|
+
@entries.delete(key)
|
|
75
|
+
return false
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
true
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Deletes a value from the cache.
|
|
83
|
+
#
|
|
84
|
+
# @param key [String] The cache key
|
|
85
|
+
# @return [Boolean] Whether the key existed
|
|
86
|
+
def delete(key)
|
|
87
|
+
@mutex.synchronize do
|
|
88
|
+
!@entries.delete(key).nil?
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Clears all entries from the cache.
|
|
93
|
+
def clear
|
|
94
|
+
@mutex.synchronize do
|
|
95
|
+
@entries.clear
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Returns the number of entries in the cache.
|
|
100
|
+
#
|
|
101
|
+
# @return [Integer]
|
|
102
|
+
def size
|
|
103
|
+
@mutex.synchronize do
|
|
104
|
+
cleanup_expired
|
|
105
|
+
@entries.size
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Returns all keys in the cache.
|
|
110
|
+
#
|
|
111
|
+
# @return [Array<String>]
|
|
112
|
+
def keys
|
|
113
|
+
@mutex.synchronize do
|
|
114
|
+
cleanup_expired
|
|
115
|
+
@entries.keys.dup
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Gets all values from the cache.
|
|
120
|
+
#
|
|
121
|
+
# @return [Hash]
|
|
122
|
+
def to_h
|
|
123
|
+
@mutex.synchronize do
|
|
124
|
+
cleanup_expired
|
|
125
|
+
@entries.transform_values(&:value)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Sets multiple values in the cache.
|
|
130
|
+
#
|
|
131
|
+
# @param hash [Hash] The key-value pairs to cache
|
|
132
|
+
def set_all(hash)
|
|
133
|
+
@mutex.synchronize do
|
|
134
|
+
hash.each do |key, value|
|
|
135
|
+
evict_if_needed
|
|
136
|
+
@entries[key] = Entry.new(value, ttl)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
private
|
|
142
|
+
|
|
143
|
+
def cleanup_expired
|
|
144
|
+
@entries.delete_if { |_, entry| entry.expired? }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def evict_if_needed
|
|
148
|
+
return if @entries.size < max_size
|
|
149
|
+
|
|
150
|
+
cleanup_expired
|
|
151
|
+
return if @entries.size < max_size
|
|
152
|
+
|
|
153
|
+
# LRU eviction
|
|
154
|
+
lru_key = @entries.min_by { |_, entry| entry.last_accessed_at }&.first
|
|
155
|
+
@entries.delete(lru_key) if lru_key
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Alias for backward compatibility
|
|
161
|
+
Cache = Core::Cache
|
|
162
|
+
end
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "base64"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module FlagKit
|
|
8
|
+
module Core
|
|
9
|
+
# Encrypted cache wrapper using AES-256-GCM.
|
|
10
|
+
#
|
|
11
|
+
# Wraps the standard Cache and encrypts/decrypts values transparently.
|
|
12
|
+
# Uses PBKDF2 to derive encryption key from API key.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# cache = EncryptedCache.new(api_key: "sdk_abc123", ttl: 300)
|
|
16
|
+
# cache.set("key", "secret value")
|
|
17
|
+
# cache.get("key") # => "secret value"
|
|
18
|
+
class EncryptedCache
|
|
19
|
+
# Encryption constants
|
|
20
|
+
ENCRYPTION_VERSION = 1
|
|
21
|
+
IV_LENGTH = 12 # 96 bits for GCM
|
|
22
|
+
TAG_LENGTH = 16 # 128 bits for GCM
|
|
23
|
+
KEY_LENGTH = 32 # 256 bits for AES-256
|
|
24
|
+
PBKDF2_ITERATIONS = 100_000
|
|
25
|
+
SALT = "FlagKit-v1-cache"
|
|
26
|
+
|
|
27
|
+
attr_reader :ttl, :max_size
|
|
28
|
+
|
|
29
|
+
# @param api_key [String] The API key used to derive encryption key
|
|
30
|
+
# @param ttl [Integer] Time to live in seconds
|
|
31
|
+
# @param max_size [Integer] Maximum number of entries
|
|
32
|
+
# @param logger [Object, nil] Optional logger instance
|
|
33
|
+
def initialize(api_key:, ttl: 300, max_size: 1000, logger: nil)
|
|
34
|
+
@ttl = ttl
|
|
35
|
+
@max_size = max_size
|
|
36
|
+
@logger = logger
|
|
37
|
+
@cache = Cache.new(ttl: ttl, max_size: max_size)
|
|
38
|
+
@derived_key = derive_key(api_key)
|
|
39
|
+
@encryption_available = !@derived_key.nil?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Gets a value from the cache (decrypted).
|
|
43
|
+
#
|
|
44
|
+
# @param key [String] The cache key
|
|
45
|
+
# @return [Object, nil] The decrypted cached value or nil
|
|
46
|
+
def get(key)
|
|
47
|
+
encrypted = @cache.get(key)
|
|
48
|
+
return nil unless encrypted
|
|
49
|
+
|
|
50
|
+
decrypt(encrypted)
|
|
51
|
+
rescue StandardError => e
|
|
52
|
+
log(:warn, "Decryption failed for key '#{key}': #{e.message}")
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Sets a value in the cache (encrypted).
|
|
57
|
+
#
|
|
58
|
+
# @param key [String] The cache key
|
|
59
|
+
# @param value [Object] The value to cache (will be JSON serialized)
|
|
60
|
+
def set(key, value)
|
|
61
|
+
encrypted = encrypt(value)
|
|
62
|
+
@cache.set(key, encrypted)
|
|
63
|
+
rescue StandardError => e
|
|
64
|
+
log(:warn, "Encryption failed for key '#{key}': #{e.message}")
|
|
65
|
+
# Fall back to unencrypted storage
|
|
66
|
+
@cache.set(key, JSON.generate({ _unencrypted: true, data: value }))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Checks if a key exists in the cache.
|
|
70
|
+
#
|
|
71
|
+
# @param key [String] The cache key
|
|
72
|
+
# @return [Boolean]
|
|
73
|
+
def has?(key)
|
|
74
|
+
@cache.has?(key)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Deletes a value from the cache.
|
|
78
|
+
#
|
|
79
|
+
# @param key [String] The cache key
|
|
80
|
+
# @return [Boolean] Whether the key existed
|
|
81
|
+
def delete(key)
|
|
82
|
+
@cache.delete(key)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Clears all entries from the cache.
|
|
86
|
+
def clear
|
|
87
|
+
@cache.clear
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Returns the number of entries in the cache.
|
|
91
|
+
#
|
|
92
|
+
# @return [Integer]
|
|
93
|
+
def size
|
|
94
|
+
@cache.size
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Returns all keys in the cache.
|
|
98
|
+
#
|
|
99
|
+
# @return [Array<String>]
|
|
100
|
+
def keys
|
|
101
|
+
@cache.keys
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Gets all values from the cache (decrypted).
|
|
105
|
+
#
|
|
106
|
+
# @return [Hash]
|
|
107
|
+
def to_h
|
|
108
|
+
result = {}
|
|
109
|
+
keys.each do |key|
|
|
110
|
+
value = get(key)
|
|
111
|
+
result[key] = value unless value.nil?
|
|
112
|
+
end
|
|
113
|
+
result
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Sets multiple values in the cache (encrypted).
|
|
117
|
+
#
|
|
118
|
+
# @param hash [Hash] The key-value pairs to cache
|
|
119
|
+
def set_all(hash)
|
|
120
|
+
hash.each { |key, value| set(key, value) }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Checks if encryption is available.
|
|
124
|
+
#
|
|
125
|
+
# @return [Boolean]
|
|
126
|
+
def encryption_available?
|
|
127
|
+
@encryption_available
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
# Derives the encryption key from the API key using PBKDF2.
|
|
133
|
+
#
|
|
134
|
+
# @param api_key [String] The API key
|
|
135
|
+
# @return [String, nil] The derived key or nil if derivation fails
|
|
136
|
+
def derive_key(api_key)
|
|
137
|
+
OpenSSL::KDF.pbkdf2_hmac(
|
|
138
|
+
api_key,
|
|
139
|
+
salt: SALT,
|
|
140
|
+
iterations: PBKDF2_ITERATIONS,
|
|
141
|
+
length: KEY_LENGTH,
|
|
142
|
+
hash: "SHA256"
|
|
143
|
+
)
|
|
144
|
+
rescue StandardError => e
|
|
145
|
+
log(:warn, "Key derivation failed: #{e.message}")
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Encrypts data using AES-256-GCM.
|
|
150
|
+
#
|
|
151
|
+
# @param data [Object] The data to encrypt (will be JSON serialized)
|
|
152
|
+
# @return [String] The encrypted data as JSON string
|
|
153
|
+
def encrypt(data)
|
|
154
|
+
unless @encryption_available
|
|
155
|
+
# Fall back to unencrypted
|
|
156
|
+
return JSON.generate({ _unencrypted: true, data: data })
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
plaintext = JSON.generate(data)
|
|
160
|
+
|
|
161
|
+
cipher = OpenSSL::Cipher.new("aes-256-gcm")
|
|
162
|
+
cipher.encrypt
|
|
163
|
+
cipher.key = @derived_key
|
|
164
|
+
iv = cipher.random_iv
|
|
165
|
+
cipher.iv = iv
|
|
166
|
+
|
|
167
|
+
ciphertext = cipher.update(plaintext) + cipher.final
|
|
168
|
+
tag = cipher.auth_tag(TAG_LENGTH)
|
|
169
|
+
|
|
170
|
+
encrypted_data = {
|
|
171
|
+
iv: Base64.strict_encode64(iv),
|
|
172
|
+
data: Base64.strict_encode64(ciphertext),
|
|
173
|
+
tag: Base64.strict_encode64(tag),
|
|
174
|
+
version: ENCRYPTION_VERSION
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
JSON.generate(encrypted_data)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Decrypts data using AES-256-GCM.
|
|
181
|
+
#
|
|
182
|
+
# @param encrypted [String] The encrypted data as JSON string
|
|
183
|
+
# @return [Object] The decrypted data
|
|
184
|
+
def decrypt(encrypted)
|
|
185
|
+
parsed = JSON.parse(encrypted)
|
|
186
|
+
|
|
187
|
+
# Handle unencrypted fallback data
|
|
188
|
+
if parsed["_unencrypted"]
|
|
189
|
+
return parsed["data"]
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Verify encryption version
|
|
193
|
+
unless parsed["version"] == ENCRYPTION_VERSION
|
|
194
|
+
log(:warn, "Unsupported encryption version: #{parsed['version']}")
|
|
195
|
+
return nil
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
unless @encryption_available
|
|
199
|
+
log(:warn, "Cannot decrypt: encryption not available")
|
|
200
|
+
return nil
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
iv = Base64.strict_decode64(parsed["iv"])
|
|
204
|
+
ciphertext = Base64.strict_decode64(parsed["data"])
|
|
205
|
+
tag = Base64.strict_decode64(parsed["tag"])
|
|
206
|
+
|
|
207
|
+
cipher = OpenSSL::Cipher.new("aes-256-gcm")
|
|
208
|
+
cipher.decrypt
|
|
209
|
+
cipher.key = @derived_key
|
|
210
|
+
cipher.iv = iv
|
|
211
|
+
cipher.auth_tag = tag
|
|
212
|
+
|
|
213
|
+
plaintext = cipher.update(ciphertext) + cipher.final
|
|
214
|
+
JSON.parse(plaintext)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def log(level, message)
|
|
218
|
+
return unless @logger
|
|
219
|
+
|
|
220
|
+
@logger.send(level, "[FlagKit::EncryptedCache] #{message}")
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Alias for backward compatibility
|
|
226
|
+
EncryptedCache = Core::EncryptedCache
|
|
227
|
+
end
|