hati-config 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE +21 -0
- data/README.md +818 -0
- data/hati-config.gemspec +42 -0
- data/lib/hati_config/cache.rb +284 -0
- data/lib/hati_config/configuration.rb +140 -0
- data/lib/hati_config/encryption.rb +244 -0
- data/lib/hati_config/environment.rb +107 -0
- data/lib/hati_config/errors.rb +54 -0
- data/lib/hati_config/hati_configuration.rb +84 -0
- data/lib/hati_config/remote_loader.rb +86 -0
- data/lib/hati_config/schema.rb +213 -0
- data/lib/hati_config/setting.rb +389 -0
- data/lib/hati_config/team.rb +85 -0
- data/lib/hati_config/type_checker.rb +103 -0
- data/lib/hati_config/type_map.rb +72 -0
- data/lib/hati_config/version.rb +5 -0
- data/lib/hati_config.rb +15 -0
- metadata +123 -0
data/hati-config.gemspec
ADDED
@@ -0,0 +1,42 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
lib = File.expand_path('lib', __dir__)
|
4
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
5
|
+
|
6
|
+
require 'hati_config/version'
|
7
|
+
|
8
|
+
Gem::Specification.new do |spec|
|
9
|
+
spec.name = 'hati-config'
|
10
|
+
spec.version = '0.1.0'
|
11
|
+
spec.authors = ['Marie Giy']
|
12
|
+
spec.email = %w[giy.mariya@gmail.com]
|
13
|
+
spec.license = 'MIT'
|
14
|
+
|
15
|
+
spec.summary = 'Ruby configuration management for distributed systems and multi-team environments.'
|
16
|
+
spec.description = 'A practical approach to configuration management with type safety, team isolation, ' \
|
17
|
+
'environment inheritance, encryption, and remote sources. Designed for teams dealing ' \
|
18
|
+
'with configuration complexity at scale.'
|
19
|
+
spec.homepage = "https://github.com/hackico-ai/#{spec.name}"
|
20
|
+
|
21
|
+
spec.required_ruby_version = '>= 3.0.0'
|
22
|
+
|
23
|
+
spec.files = Dir['CHANGELOG.md', 'LICENSE', 'README.md', 'hati-config.gemspec', 'lib/**/*']
|
24
|
+
spec.bindir = 'bin'
|
25
|
+
spec.executables = []
|
26
|
+
spec.require_paths = ['lib']
|
27
|
+
|
28
|
+
spec.metadata['repo_homepage'] = spec.homepage
|
29
|
+
spec.metadata['allowed_push_host'] = 'https://rubygems.org'
|
30
|
+
|
31
|
+
spec.metadata['homepage_uri'] = spec.homepage
|
32
|
+
spec.metadata['changelog_uri'] = "#{spec.homepage}/blob/main/CHANGELOG.md"
|
33
|
+
spec.metadata['source_code_uri'] = spec.homepage
|
34
|
+
spec.metadata['bug_tracker_uri'] = "#{spec.homepage}/issues"
|
35
|
+
|
36
|
+
spec.metadata['rubygems_mfa_required'] = 'true'
|
37
|
+
|
38
|
+
spec.add_dependency 'aws-sdk-s3', '~> 1.0'
|
39
|
+
spec.add_dependency 'bigdecimal', '~> 3.0'
|
40
|
+
spec.add_dependency 'connection_pool', '~> 2.4'
|
41
|
+
spec.add_dependency 'redis', '~> 5.0'
|
42
|
+
end
|
@@ -0,0 +1,284 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'redis'
|
4
|
+
require 'connection_pool'
|
5
|
+
require 'json'
|
6
|
+
|
7
|
+
module HatiConfig
|
8
|
+
# Cache module provides functionality for caching and refreshing configurations.
|
9
|
+
module Cache
|
10
|
+
# Module for handling numeric configuration attributes
|
11
|
+
module NumericConfigurable
|
12
|
+
def self.included(base)
|
13
|
+
base.extend(ClassMethods)
|
14
|
+
end
|
15
|
+
|
16
|
+
module ClassMethods
|
17
|
+
def numeric_accessor(*names)
|
18
|
+
names.each do |name|
|
19
|
+
define_method(name) do |value = nil|
|
20
|
+
if value.nil?
|
21
|
+
instance_variable_get(:"@#{name}")
|
22
|
+
else
|
23
|
+
instance_variable_set(:"@#{name}", value)
|
24
|
+
self
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
# Defines caching behavior for configurations.
|
33
|
+
#
|
34
|
+
# @param adapter [Symbol] The cache adapter to use (:memory, :redis)
|
35
|
+
# @param options [Hash] Options for the cache adapter
|
36
|
+
# @yield The cache configuration block
|
37
|
+
# @example
|
38
|
+
# cache do
|
39
|
+
# adapter :redis, url: "redis://cache.example.com:6379/0"
|
40
|
+
# ttl 300 # 5 minutes
|
41
|
+
# stale_while_revalidate true
|
42
|
+
# end
|
43
|
+
def cache(&block)
|
44
|
+
@cache_config = CacheConfig.new
|
45
|
+
@cache_config.instance_eval(&block) if block_given?
|
46
|
+
@cache_config
|
47
|
+
end
|
48
|
+
|
49
|
+
# Gets the cache configuration.
|
50
|
+
#
|
51
|
+
# @return [CacheConfig] The cache configuration
|
52
|
+
def cache_config
|
53
|
+
@cache_config ||= CacheConfig.new
|
54
|
+
end
|
55
|
+
|
56
|
+
# CacheConfig class handles cache configuration and behavior.
|
57
|
+
class CacheConfig
|
58
|
+
include NumericConfigurable
|
59
|
+
|
60
|
+
attr_reader :adapter_type, :adapter_options
|
61
|
+
|
62
|
+
numeric_accessor :ttl
|
63
|
+
|
64
|
+
def refresh(&block)
|
65
|
+
@refresh_config.instance_eval(&block) if block_given?
|
66
|
+
@refresh_config
|
67
|
+
end
|
68
|
+
|
69
|
+
def stale_while_revalidate(enabled = nil)
|
70
|
+
if enabled.nil?
|
71
|
+
@stale_while_revalidate
|
72
|
+
else
|
73
|
+
@stale_while_revalidate = enabled
|
74
|
+
self
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
def initialize
|
79
|
+
@adapter_type = :memory
|
80
|
+
@adapter_options = {}
|
81
|
+
@ttl = 300
|
82
|
+
@stale_while_revalidate = false
|
83
|
+
@refresh_config = RefreshConfig.new
|
84
|
+
@adapter = nil
|
85
|
+
end
|
86
|
+
|
87
|
+
# Sets the cache adapter.
|
88
|
+
#
|
89
|
+
# @param type [Symbol] The adapter type (:memory, :redis)
|
90
|
+
# @param options [Hash] Options for the adapter
|
91
|
+
def adapter(*args, **kwargs)
|
92
|
+
if args.empty? && kwargs.empty?
|
93
|
+
@adapter ||= initialize_adapter
|
94
|
+
else
|
95
|
+
type = args[0]
|
96
|
+
options = args[1] || kwargs
|
97
|
+
@adapter_type = type
|
98
|
+
@adapter_options = options
|
99
|
+
@adapter = nil
|
100
|
+
self
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
attr_writer :adapter
|
105
|
+
|
106
|
+
private
|
107
|
+
|
108
|
+
def initialize_adapter
|
109
|
+
case adapter_type
|
110
|
+
when :memory
|
111
|
+
MemoryAdapter.new
|
112
|
+
when :redis
|
113
|
+
RedisAdapter.new(adapter_options)
|
114
|
+
else
|
115
|
+
raise ArgumentError, "Unknown cache adapter: #{adapter_type}"
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
# Gets a value from the cache.
|
120
|
+
#
|
121
|
+
# @param key [String] The cache key
|
122
|
+
# @return [Object, nil] The cached value or nil if not found
|
123
|
+
def get(key)
|
124
|
+
adapter.get(key)
|
125
|
+
end
|
126
|
+
|
127
|
+
# Sets a value in the cache.
|
128
|
+
#
|
129
|
+
# @param key [String] The cache key
|
130
|
+
# @param value [Object] The value to cache
|
131
|
+
# @param ttl [Integer, nil] Optional TTL override
|
132
|
+
def set(key, value, ttl = nil)
|
133
|
+
adapter.set(key, value, ttl || @ttl)
|
134
|
+
end
|
135
|
+
|
136
|
+
# Deletes a value from the cache.
|
137
|
+
#
|
138
|
+
# @param key [String] The cache key
|
139
|
+
def delete(key)
|
140
|
+
adapter.delete(key)
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
# RefreshConfig class handles refresh configuration and behavior.
|
145
|
+
class RefreshConfig
|
146
|
+
include NumericConfigurable
|
147
|
+
|
148
|
+
attr_reader :interval, :jitter, :backoff_config
|
149
|
+
|
150
|
+
numeric_accessor :interval, :jitter
|
151
|
+
|
152
|
+
def initialize
|
153
|
+
@interval = 60
|
154
|
+
@jitter = 0
|
155
|
+
@backoff_config = BackoffConfig.new
|
156
|
+
end
|
157
|
+
|
158
|
+
# Configures backoff behavior.
|
159
|
+
#
|
160
|
+
# @yield The backoff configuration block
|
161
|
+
def backoff(&block)
|
162
|
+
@backoff_config.instance_eval(&block) if block_given?
|
163
|
+
@backoff_config
|
164
|
+
end
|
165
|
+
|
166
|
+
# Gets the next refresh time.
|
167
|
+
#
|
168
|
+
# @return [Time] The next refresh time
|
169
|
+
def next_refresh_time
|
170
|
+
jitter_amount = jitter.positive? ? rand(0.0..jitter) : 0
|
171
|
+
Time.now + interval + jitter_amount
|
172
|
+
end
|
173
|
+
end
|
174
|
+
|
175
|
+
# BackoffConfig class handles backoff configuration and behavior.
|
176
|
+
class BackoffConfig
|
177
|
+
include NumericConfigurable
|
178
|
+
|
179
|
+
attr_reader :initial, :multiplier, :max
|
180
|
+
|
181
|
+
numeric_accessor :initial, :multiplier, :max
|
182
|
+
|
183
|
+
def initialize
|
184
|
+
@initial = 1
|
185
|
+
@multiplier = 2
|
186
|
+
@max = 300
|
187
|
+
end
|
188
|
+
|
189
|
+
# Gets the backoff time for a given attempt.
|
190
|
+
#
|
191
|
+
# @param attempt [Integer] The attempt number
|
192
|
+
# @return [Integer] The backoff time in seconds
|
193
|
+
def backoff_time(attempt)
|
194
|
+
time = initial * (multiplier**(attempt - 1))
|
195
|
+
[time, max].min
|
196
|
+
end
|
197
|
+
end
|
198
|
+
|
199
|
+
# MemoryAdapter class provides in-memory caching.
|
200
|
+
class MemoryAdapter
|
201
|
+
def initialize
|
202
|
+
@store = {}
|
203
|
+
@expiry = {}
|
204
|
+
end
|
205
|
+
|
206
|
+
# Gets a value from the cache.
|
207
|
+
#
|
208
|
+
# @param key [String] The cache key
|
209
|
+
# @return [Object, nil] The cached value or nil if not found/expired
|
210
|
+
def get(key)
|
211
|
+
return nil if expired?(key)
|
212
|
+
|
213
|
+
@store[key]
|
214
|
+
end
|
215
|
+
|
216
|
+
# Sets a value in the cache.
|
217
|
+
#
|
218
|
+
# @param key [String] The cache key
|
219
|
+
# @param value [Object] The value to cache
|
220
|
+
# @param ttl [Integer] The TTL in seconds
|
221
|
+
def set(key, value, ttl)
|
222
|
+
@store[key] = value
|
223
|
+
@expiry[key] = Time.now + ttl if ttl
|
224
|
+
end
|
225
|
+
|
226
|
+
# Deletes a value from the cache.
|
227
|
+
#
|
228
|
+
# @param key [String] The cache key
|
229
|
+
def delete(key)
|
230
|
+
@store.delete(key)
|
231
|
+
@expiry.delete(key)
|
232
|
+
end
|
233
|
+
|
234
|
+
private
|
235
|
+
|
236
|
+
def expired?(key)
|
237
|
+
expiry = @expiry[key]
|
238
|
+
expiry && Time.now >= expiry
|
239
|
+
end
|
240
|
+
end
|
241
|
+
|
242
|
+
# RedisAdapter class provides Redis-based caching.
|
243
|
+
class RedisAdapter
|
244
|
+
def initialize(options)
|
245
|
+
@pool = ConnectionPool.new(size: 5, timeout: 5) do
|
246
|
+
Redis.new(options)
|
247
|
+
end
|
248
|
+
end
|
249
|
+
|
250
|
+
# Gets a value from the cache.
|
251
|
+
#
|
252
|
+
# @param key [String] The cache key
|
253
|
+
# @return [Object, nil] The cached value or nil if not found
|
254
|
+
def get(key)
|
255
|
+
@pool.with do |redis|
|
256
|
+
value = redis.get(key)
|
257
|
+
value ? Marshal.load(value) : nil
|
258
|
+
end
|
259
|
+
rescue TypeError, ArgumentError
|
260
|
+
nil
|
261
|
+
end
|
262
|
+
|
263
|
+
# Sets a value in the cache.
|
264
|
+
#
|
265
|
+
# @param key [String] The cache key
|
266
|
+
# @param value [Object] The value to cache
|
267
|
+
# @param ttl [Integer] The TTL in seconds
|
268
|
+
def set(key, value, ttl)
|
269
|
+
@pool.with do |redis|
|
270
|
+
redis.setex(key, ttl, Marshal.dump(value))
|
271
|
+
end
|
272
|
+
end
|
273
|
+
|
274
|
+
# Deletes a value from the cache.
|
275
|
+
#
|
276
|
+
# @param key [String] The cache key
|
277
|
+
def delete(key)
|
278
|
+
@pool.with do |redis|
|
279
|
+
redis.del(key)
|
280
|
+
end
|
281
|
+
end
|
282
|
+
end
|
283
|
+
end
|
284
|
+
end
|
@@ -0,0 +1,140 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# HatiConfig module provides functionality for managing HatiConfig features.
|
4
|
+
module HatiConfig
|
5
|
+
# This module handles configuration trees and loading data from various sources.
|
6
|
+
module Configuration
|
7
|
+
# Isolated module provides methods for handling isolated configurations.
|
8
|
+
module Local
|
9
|
+
# Configures an isolated config tree and tracks it
|
10
|
+
#
|
11
|
+
# @param config_tree_name [Symbol] The name of the configuration tree
|
12
|
+
# @param opts [Hash] Options for configuration
|
13
|
+
# @yield The configuration block
|
14
|
+
# @return [self]
|
15
|
+
#
|
16
|
+
# @example
|
17
|
+
# configure :isolated_settings do
|
18
|
+
# config api_url: "https://api.example.com"
|
19
|
+
# end
|
20
|
+
#
|
21
|
+
# # Example of accessing the configuration
|
22
|
+
# puts MyApp.isolated_settings.api_url # => "https://api.example.com"
|
23
|
+
def configure(config_tree_name, opts = {}, &block)
|
24
|
+
super
|
25
|
+
|
26
|
+
@isolated_configs ||= []
|
27
|
+
@isolated_configs << config_tree_name
|
28
|
+
|
29
|
+
self
|
30
|
+
end
|
31
|
+
|
32
|
+
# Inherits isolated configurations to the base class
|
33
|
+
#
|
34
|
+
# @param base [Class] The inheriting class
|
35
|
+
#
|
36
|
+
# @example
|
37
|
+
# class ChildClass < ParentClass
|
38
|
+
# # Automatically inherits isolated configurations
|
39
|
+
# end
|
40
|
+
#
|
41
|
+
# # Example of accessing inherited configurations
|
42
|
+
# puts ChildClass.parent_settings.timeout # => 30
|
43
|
+
def inherited(base)
|
44
|
+
super
|
45
|
+
|
46
|
+
@isolated_configs.each do |parent_config|
|
47
|
+
hash = __send__(parent_config).to_h
|
48
|
+
schema = __send__(parent_config).type_schema
|
49
|
+
lock_schema = __send__(parent_config).lock_schema
|
50
|
+
|
51
|
+
base.configure parent_config, hash: hash, schema: schema, lock_schema: lock_schema
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
# Creates a class-level method for the configuration tree.
|
57
|
+
#
|
58
|
+
# This method allows you to define a configuration tree using a block
|
59
|
+
# or load configuration data from a hash, JSON, or YAML file.
|
60
|
+
#
|
61
|
+
# @param config_tree_name [Symbol, String] The name of the configuration method to be defined.
|
62
|
+
# @param opts [Hash] Optional options for loading configuration data.
|
63
|
+
# @option opts [Hash] :hash A hash containing configuration data.
|
64
|
+
# @option opts [String] :json A JSON string containing configuration data.
|
65
|
+
# @option opts [String] :yaml A file path to a YAML file containing configuration data.
|
66
|
+
# @option opts [Hash] :schema A hash representing the type schema for the configuration.
|
67
|
+
# @yield [Setting] A block that builds the configuration tree.
|
68
|
+
#
|
69
|
+
# @example Configuring with a block
|
70
|
+
# configure :app_config do
|
71
|
+
# config :username, value: "admin"
|
72
|
+
# config :max_connections, type: :int, value: 10
|
73
|
+
# end
|
74
|
+
#
|
75
|
+
# @example Loading from a hash
|
76
|
+
# configure :app_config, hash: { username: "admin", max_connections: 10 }
|
77
|
+
#
|
78
|
+
# @example Loading from a JSON string
|
79
|
+
# configure :app_config, json: '{"username": "admin", "max_connections": 10}'
|
80
|
+
#
|
81
|
+
# @example Loading from a YAML file
|
82
|
+
# configure :app_config, yaml: 'config/settings.yml'
|
83
|
+
#
|
84
|
+
# @example Loading with a schema
|
85
|
+
# configure :app_config, hash: { name: "admin", policy: "allow" }, schema: { name: :str, policy: :str }
|
86
|
+
def configure(config_tree_name, opts = {}, &block)
|
87
|
+
settings = block ? HatiConfig::Setting.new(&block) : HatiConfig::Setting.new
|
88
|
+
|
89
|
+
load_configs = load_data(opts) unless opts.empty?
|
90
|
+
settings.load_from_hash(load_configs, schema: opts[:schema], lock_schema: opts[:lock_schema]) if load_configs
|
91
|
+
|
92
|
+
define_singleton_method(config_tree_name) do |&tree_block|
|
93
|
+
tree_block ? settings.instance_eval(&tree_block) : settings
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
module_function
|
98
|
+
|
99
|
+
# Loads configuration data from various sources based on the provided options.
|
100
|
+
#
|
101
|
+
# @param opts [Hash] Optional options for loading configuration data.
|
102
|
+
# @option opts [Hash] :hash A hash containing configuration data.
|
103
|
+
# @option opts [String] :json A JSON string containing configuration data.
|
104
|
+
# @option opts [String] :yaml A file path to a YAML file containing configuration data.
|
105
|
+
# @return [Hash] The loaded configuration data.
|
106
|
+
# @raise [LoadDataError] If no valid data is found.
|
107
|
+
#
|
108
|
+
# @example Loading from a hash
|
109
|
+
# load_data(hash: { key: "value" })
|
110
|
+
#
|
111
|
+
# @example Loading from a JSON string
|
112
|
+
# load_data(json: '{"key": "value"}')
|
113
|
+
#
|
114
|
+
# @example Loading from a YAML file
|
115
|
+
# load_data(yaml: 'config/settings.yml')
|
116
|
+
def load_data(opts = {})
|
117
|
+
data = if opts[:hash]
|
118
|
+
opts[:hash]
|
119
|
+
elsif opts[:json]
|
120
|
+
JSON.parse(opts[:json])
|
121
|
+
elsif opts[:yaml]
|
122
|
+
YAML.load_file(opts[:yaml])
|
123
|
+
elsif opts[:http]
|
124
|
+
RemoteLoader.from_http(**opts[:http])
|
125
|
+
elsif opts[:s3]
|
126
|
+
RemoteLoader.from_s3(**opts[:s3])
|
127
|
+
elsif opts[:redis]
|
128
|
+
RemoteLoader.from_redis(**opts[:redis])
|
129
|
+
end
|
130
|
+
|
131
|
+
raise HatiConfig::LoadDataError, 'Invalid load source type' unless data
|
132
|
+
|
133
|
+
data
|
134
|
+
rescue JSON::ParserError
|
135
|
+
raise HatiConfig::LoadDataError, 'Invalid JSON format'
|
136
|
+
rescue Errno::ENOENT
|
137
|
+
raise HatiConfig::LoadDataError, 'YAML file not found'
|
138
|
+
end
|
139
|
+
end
|
140
|
+
end
|
@@ -0,0 +1,244 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'openssl'
|
4
|
+
require 'base64'
|
5
|
+
|
6
|
+
module HatiConfig
|
7
|
+
# Encryption module provides methods for encrypting and decrypting sensitive configuration values.
|
8
|
+
module Encryption
|
9
|
+
# Custom error class for encryption-related errors.
|
10
|
+
class EncryptionError < StandardError; end
|
11
|
+
|
12
|
+
# Defines encryption configuration for a class or module.
|
13
|
+
#
|
14
|
+
# @yield [encryption_config] A block to configure encryption settings.
|
15
|
+
# @return [EncryptionConfig] The encryption configuration instance.
|
16
|
+
def encryption(&block)
|
17
|
+
@encryption_config ||= EncryptionConfig.new
|
18
|
+
@encryption_config.instance_eval(&block) if block_given?
|
19
|
+
@encryption_config
|
20
|
+
end
|
21
|
+
|
22
|
+
# Gets the encryption configuration.
|
23
|
+
#
|
24
|
+
# @return [EncryptionConfig] The encryption configuration
|
25
|
+
def encryption_config
|
26
|
+
@encryption_config ||= EncryptionConfig.new
|
27
|
+
end
|
28
|
+
|
29
|
+
# EncryptionConfig class handles encryption configuration and behavior.
|
30
|
+
class EncryptionConfig
|
31
|
+
attr_reader :key_provider, :algorithm, :key_size, :mode, :key_provider_type, :key_provider_options
|
32
|
+
|
33
|
+
def initialize
|
34
|
+
@key_provider = nil
|
35
|
+
@key_provider_type = nil
|
36
|
+
@key_provider_options = {}
|
37
|
+
@algorithm = 'aes'
|
38
|
+
@key_size = 256
|
39
|
+
@mode = 'gcm'
|
40
|
+
end
|
41
|
+
|
42
|
+
# Sets the key provider.
|
43
|
+
#
|
44
|
+
# @param provider [Symbol] The key provider type (:env, :file, :aws_kms)
|
45
|
+
# @param options [Hash] Options for the key provider
|
46
|
+
def key_provider(provider = nil, options = {})
|
47
|
+
if provider.nil?
|
48
|
+
@key_provider
|
49
|
+
else
|
50
|
+
@key_provider_type = provider
|
51
|
+
@key_provider_options = options
|
52
|
+
@key_provider = KeyProvider.create(provider, options)
|
53
|
+
self
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
# Sets the encryption algorithm.
|
58
|
+
#
|
59
|
+
# @param value [String] The encryption algorithm (e.g., "aes")
|
60
|
+
def algorithm(value = nil)
|
61
|
+
if value.nil?
|
62
|
+
@algorithm
|
63
|
+
else
|
64
|
+
@algorithm = value
|
65
|
+
self
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
# Sets the key size.
|
70
|
+
#
|
71
|
+
# @param value [Integer] The key size in bits (e.g., 256)
|
72
|
+
def key_size(value = nil)
|
73
|
+
if value.nil?
|
74
|
+
@key_size
|
75
|
+
else
|
76
|
+
@key_size = value
|
77
|
+
self
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
# Sets the encryption mode.
|
82
|
+
#
|
83
|
+
# @param value [String] The encryption mode (e.g., "gcm")
|
84
|
+
def mode(value = nil)
|
85
|
+
if value.nil?
|
86
|
+
@mode
|
87
|
+
else
|
88
|
+
@mode = value
|
89
|
+
self
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
# Encrypts a value.
|
94
|
+
#
|
95
|
+
# @param value [String] The value to encrypt
|
96
|
+
# @return [String] The encrypted value in Base64 format
|
97
|
+
# @raise [EncryptionError] If encryption fails
|
98
|
+
def encrypt(value)
|
99
|
+
raise EncryptionError, 'No key provider configured' unless @key_provider
|
100
|
+
|
101
|
+
begin
|
102
|
+
cipher = OpenSSL::Cipher.new("#{@algorithm}-#{@key_size}-#{@mode}")
|
103
|
+
cipher.encrypt
|
104
|
+
cipher.key = @key_provider.key
|
105
|
+
|
106
|
+
if @mode == 'gcm'
|
107
|
+
cipher.auth_data = ''
|
108
|
+
iv = cipher.random_iv
|
109
|
+
cipher.iv = iv
|
110
|
+
ciphertext = cipher.update(value.to_s) + cipher.final
|
111
|
+
auth_tag = cipher.auth_tag
|
112
|
+
|
113
|
+
# Format: Base64(IV + Auth Tag + Ciphertext)
|
114
|
+
Base64.strict_encode64(iv + auth_tag + ciphertext)
|
115
|
+
else
|
116
|
+
iv = cipher.random_iv
|
117
|
+
cipher.iv = iv
|
118
|
+
ciphertext = cipher.update(value.to_s) + cipher.final
|
119
|
+
|
120
|
+
# Format: Base64(IV + Ciphertext)
|
121
|
+
Base64.strict_encode64(iv + ciphertext)
|
122
|
+
end
|
123
|
+
rescue OpenSSL::Cipher::CipherError => e
|
124
|
+
raise EncryptionError, "Encryption failed: #{e.message}"
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
# Decrypts a value.
|
129
|
+
#
|
130
|
+
# @param encrypted_value [String] The encrypted value in Base64 format
|
131
|
+
# @return [String] The decrypted value
|
132
|
+
# @raise [EncryptionError] If decryption fails
|
133
|
+
def decrypt(encrypted_value)
|
134
|
+
raise EncryptionError, 'No key provider configured' unless @key_provider
|
135
|
+
return nil if encrypted_value.nil?
|
136
|
+
|
137
|
+
begin
|
138
|
+
data = Base64.strict_decode64(encrypted_value)
|
139
|
+
cipher = OpenSSL::Cipher.new("#{@algorithm}-#{@key_size}-#{@mode}")
|
140
|
+
cipher.decrypt
|
141
|
+
cipher.key = @key_provider.key
|
142
|
+
|
143
|
+
if @mode == 'gcm'
|
144
|
+
iv = data[0, 12] # GCM uses 12-byte IV
|
145
|
+
auth_tag = data[12, 16] # GCM uses 16-byte auth tag
|
146
|
+
ciphertext = data[28..]
|
147
|
+
|
148
|
+
cipher.iv = iv
|
149
|
+
cipher.auth_tag = auth_tag
|
150
|
+
cipher.auth_data = ''
|
151
|
+
|
152
|
+
else
|
153
|
+
iv = data[0, 16] # Other modes typically use 16-byte IV
|
154
|
+
ciphertext = data[16..]
|
155
|
+
|
156
|
+
cipher.iv = iv
|
157
|
+
end
|
158
|
+
cipher.update(ciphertext) + cipher.final
|
159
|
+
rescue OpenSSL::Cipher::CipherError => e
|
160
|
+
raise EncryptionError, "Decryption failed: #{e.message}"
|
161
|
+
rescue ArgumentError => e
|
162
|
+
raise EncryptionError, "Invalid encrypted value: #{e.message}"
|
163
|
+
end
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
167
|
+
# KeyProvider class hierarchy for handling encryption keys.
|
168
|
+
class KeyProvider
|
169
|
+
def self.create(type, options = {})
|
170
|
+
case type
|
171
|
+
when :env
|
172
|
+
EnvKeyProvider.new(options)
|
173
|
+
when :file
|
174
|
+
FileKeyProvider.new(options)
|
175
|
+
when :aws_kms
|
176
|
+
AwsKmsKeyProvider.new(options)
|
177
|
+
else
|
178
|
+
raise EncryptionError, "Unknown key provider: #{type}"
|
179
|
+
end
|
180
|
+
end
|
181
|
+
|
182
|
+
def key
|
183
|
+
raise NotImplementedError, 'Subclasses must implement #key'
|
184
|
+
end
|
185
|
+
end
|
186
|
+
|
187
|
+
# EnvKeyProvider gets the encryption key from an environment variable.
|
188
|
+
class EnvKeyProvider < KeyProvider
|
189
|
+
def initialize(options = {})
|
190
|
+
super()
|
191
|
+
@env_var = options[:env_var] || 'HATI_CONFIG_ENCRYPTION_KEY'
|
192
|
+
end
|
193
|
+
|
194
|
+
def key
|
195
|
+
key = ENV.fetch(@env_var, nil)
|
196
|
+
raise EncryptionError, "Encryption key not found in environment variable #{@env_var}" unless key
|
197
|
+
|
198
|
+
key
|
199
|
+
end
|
200
|
+
end
|
201
|
+
|
202
|
+
# FileKeyProvider gets the encryption key from a file.
|
203
|
+
class FileKeyProvider < KeyProvider
|
204
|
+
def initialize(options = {})
|
205
|
+
super()
|
206
|
+
@file_path = options[:file_path]
|
207
|
+
raise EncryptionError, 'File path not provided' unless @file_path
|
208
|
+
end
|
209
|
+
|
210
|
+
def key
|
211
|
+
raise EncryptionError, "Key file not found: #{@file_path}" unless File.exist?(@file_path)
|
212
|
+
|
213
|
+
File.read(@file_path).strip
|
214
|
+
rescue SystemCallError => e
|
215
|
+
raise EncryptionError, "Failed to read key file: #{e.message}"
|
216
|
+
end
|
217
|
+
end
|
218
|
+
|
219
|
+
# AwsKmsKeyProvider gets the encryption key from AWS KMS.
|
220
|
+
class AwsKmsKeyProvider < KeyProvider
|
221
|
+
def initialize(options = {})
|
222
|
+
super()
|
223
|
+
require 'aws-sdk-kms'
|
224
|
+
@key_id = options[:key_id]
|
225
|
+
@region = options[:region]
|
226
|
+
@client = nil
|
227
|
+
raise EncryptionError, 'KMS key ID not provided' unless @key_id
|
228
|
+
end
|
229
|
+
|
230
|
+
def key
|
231
|
+
@key ||= begin
|
232
|
+
client = Aws::KMS::Client.new(region: @region)
|
233
|
+
response = client.generate_data_key(
|
234
|
+
key_id: @key_id,
|
235
|
+
key_spec: 'AES_256'
|
236
|
+
)
|
237
|
+
response.plaintext
|
238
|
+
rescue Aws::KMS::Errors::ServiceError => e
|
239
|
+
raise EncryptionError, "Failed to get key from KMS: #{e.message}"
|
240
|
+
end
|
241
|
+
end
|
242
|
+
end
|
243
|
+
end
|
244
|
+
end
|