secret_config 0.3.1 → 0.4.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 +4 -4
- data/README.md +111 -9
- data/bin/secret_config +7 -0
- data/lib/secret_config/cli.rb +232 -0
- data/lib/secret_config/providers/file.rb +1 -13
- data/lib/secret_config/railtie.rb +1 -1
- data/lib/secret_config/registry.rb +39 -23
- data/lib/secret_config/utils.rb +30 -0
- data/lib/secret_config/version.rb +1 -1
- data/lib/secret_config.rb +10 -54
- data/test/config/application.yml +1 -1
- data/test/providers/file_test.rb +14 -14
- data/test/providers/ssm_test.rb +17 -17
- data/test/registry_test.rb +21 -21
- data/test/secret_config_test.rb +11 -6
- metadata +5 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 2d046c2ff5be65ccdd09c1fdb82fe28df303ec584ce89c4201320529d53025da
|
4
|
+
data.tar.gz: 5f4973a7b9ae821ac9b511569873dff21f427793d59e846999d419473efdc022
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 47788fa5c66536ba5bb36eed0ba86866375400375d0474e5b566da775db3cf7142682ac34075cb9a18c787bba0c31a892613db5283e48e47191ea98b0a5a97b7
|
7
|
+
data.tar.gz: f75ce0f2542a8dbde1e912a5a7bd1fec02e9b2c97fed42018dab8b7bbd84e9850a2c872aac8a0497f9bfea6042a566bc12f6345e3c26a84a5e1f8baf13513125
|
data/README.md
CHANGED
@@ -78,11 +78,24 @@ to flatten everything into a single level.
|
|
78
78
|
|
79
79
|
Note: Do not put any production credentials into this file.
|
80
80
|
|
81
|
-
###
|
81
|
+
### Environment Variables
|
82
|
+
|
83
|
+
Any of the above values can be overridden with an environment variable.
|
84
|
+
|
85
|
+
To overwrite any of these settings with an environment variable:
|
86
|
+
|
87
|
+
* Join the keys together with an '_'
|
88
|
+
* Convert to uppercase
|
89
|
+
|
90
|
+
For example, `mysql/host` can be overridden with the env var:
|
91
|
+
|
92
|
+
export MYSQL_HOST=test.server
|
93
|
+
|
94
|
+
### Applying to existing config files
|
82
95
|
|
83
96
|
Go through all the configuration files and look for sensitive data such as passwords:
|
84
97
|
|
85
|
-
Example `database.yml`:
|
98
|
+
Example, an unchanged common `database.yml`:
|
86
99
|
|
87
100
|
~~~yaml
|
88
101
|
defaults: &defaults
|
@@ -136,6 +149,45 @@ production:
|
|
136
149
|
|
137
150
|
Since the secrets are externalized the configuration between environments is simpler.
|
138
151
|
|
152
|
+
### Replacing custom config files
|
153
|
+
|
154
|
+
When writing new components or gems, instead of requiring a proprietary config file, refer
|
155
|
+
to the settings programmatically:
|
156
|
+
|
157
|
+
For example, somewhere in your codebase you need a persistent http connection:
|
158
|
+
|
159
|
+
~~~ruby
|
160
|
+
def http_client
|
161
|
+
@http_client ||=
|
162
|
+
PersistentHTTP.new(
|
163
|
+
name: 'HTTPClient',
|
164
|
+
url: SecretConfig.fetch('http_client/url'),
|
165
|
+
logger: logger,
|
166
|
+
pool_size: SecretConfig.fetch('http_client/pool_size', type: :integer, default: 10),
|
167
|
+
warn_timeout: SecretConfig.fetch('http_client/warn_timeout', type: :float, default: 0.25),
|
168
|
+
open_timeout: SecretConfig.fetch('http_client/open_timeout', type: :float, default: 30),
|
169
|
+
read_timeout: SecretConfig.fetch('http_client/read_timeout', type: :float, default: 30),
|
170
|
+
force_retry: true
|
171
|
+
)
|
172
|
+
end
|
173
|
+
~~~
|
174
|
+
|
175
|
+
Then the application that uses the above library / gem just needs to add the relevant entries to their
|
176
|
+
`application.rb` file:
|
177
|
+
|
178
|
+
~~~yaml
|
179
|
+
http_client:
|
180
|
+
url: https://test.example.com
|
181
|
+
pool_size: 20
|
182
|
+
open_timeout: secret_configrules
|
183
|
+
~~~
|
184
|
+
|
185
|
+
This avoids a custom config file just for the above library.
|
186
|
+
|
187
|
+
Additionally the values can be overridden with environment variables at any time:
|
188
|
+
|
189
|
+
export HTTP_CLIENT_URL=https://production.example.com
|
190
|
+
|
139
191
|
## Configuration
|
140
192
|
|
141
193
|
Add the following line to Gemfile
|
@@ -143,7 +195,7 @@ Add the following line to Gemfile
|
|
143
195
|
gem "secret_config"
|
144
196
|
|
145
197
|
Out of the box Secret Config will look in the local file system for the file `config/application.yml`
|
146
|
-
as covered above. By default it will use env var `RAILS_ENV` to define the
|
198
|
+
as covered above. By default it will use env var `RAILS_ENV` to define the path to look under for settings.
|
147
199
|
|
148
200
|
The default settings are great for getting started in development and test, but should not be used in production.
|
149
201
|
|
@@ -153,11 +205,11 @@ AWS System Manager Parameter Store:
|
|
153
205
|
~~~ruby
|
154
206
|
Rails.application.configure do
|
155
207
|
# Read configuration from AWS Parameter Store
|
156
|
-
config.secret_config.use :ssm,
|
208
|
+
config.secret_config.use :ssm, path: '/production/my_application'
|
157
209
|
end
|
158
210
|
~~~
|
159
211
|
|
160
|
-
`
|
212
|
+
`path` is the path from which the configuration data will be read. This path uniquely identifies the
|
161
213
|
configuration for this instance of the application.
|
162
214
|
|
163
215
|
If we need 2 completely separate instances of the application running in a single AWS account then we could use
|
@@ -169,8 +221,8 @@ multiple paths. For example:
|
|
169
221
|
/production/instance1/my_application
|
170
222
|
/production/instance2/my_application
|
171
223
|
|
172
|
-
The
|
173
|
-
The same
|
224
|
+
The `path` is completely flexible, but must be unique for every AWS account under which the application will run.
|
225
|
+
The same `path` can be used in different AWS accounts though. It is also not replicated across regions.
|
174
226
|
|
175
227
|
When writing settings to the parameter store, it is recommended to use a custom KMS key to encrypt the values.
|
176
228
|
To supply the key to encrypt the values with, add the `key_id` parameter:
|
@@ -180,7 +232,7 @@ Rails.application.configure do
|
|
180
232
|
# Read configuration from AWS Parameter Store
|
181
233
|
config.secret_config.use :ssm,
|
182
234
|
key_id: 'alias/production/myapplication',
|
183
|
-
|
235
|
+
path: '/production/my_application'
|
184
236
|
end
|
185
237
|
~~~
|
186
238
|
|
@@ -191,7 +243,7 @@ will only read from the parameter store.
|
|
191
243
|
|
192
244
|
### Authorization
|
193
245
|
|
194
|
-
The following policy needs to be added to the
|
246
|
+
The following policy needs to be added to the IAM Group under which the application will be running:
|
195
247
|
|
196
248
|
~~~json
|
197
249
|
{
|
@@ -219,6 +271,56 @@ to view and modify parameters:
|
|
219
271
|
- `ssm:GetParameters`
|
220
272
|
- `ssm:GetParameter`
|
221
273
|
|
274
|
+
## Command Line Interface
|
275
|
+
|
276
|
+
Secret Config has a command line interface for exporting, importing and copying between paths in the registry.
|
277
|
+
|
278
|
+
~~~
|
279
|
+
secret_config [options]
|
280
|
+
-e, --export [FILE_NAME] Export configuration to a file or stdout if no file_name supplied.
|
281
|
+
-i, --import [FILE_NAME] Import configuration from a file or stdin if no file_name supplied.
|
282
|
+
-c, --copy SOURCE_PATH Import configuration from a file or stdin if no file_name supplied.
|
283
|
+
-p, --path PATH Path to import from / export to.
|
284
|
+
-P, --provider PROVIDER Provider to use. [ssm | file]. Default: ssm
|
285
|
+
-U, --no-filter Do not filter passwords and keys.
|
286
|
+
-k, --key KEY_ID | KEY_ALIAS AWS KMS Key id or Key Alias to use when importing configuration values. Default: AWS Default key.
|
287
|
+
-r, --region REGION AWS Region to use. Default: AWS_REGION env var.
|
288
|
+
-R, --random_size INTEGER Size to use when generating random values. Whenever $random is encountered during an import. Default: 32
|
289
|
+
-v, --version Display Symmetric Encryption version.
|
290
|
+
-h, --help Prints this help.
|
291
|
+
~~~
|
292
|
+
|
293
|
+
### CLI Examples
|
294
|
+
|
295
|
+
Export from a path in AWS SSM Parameter Store to a yaml file, where passwords are filtered:
|
296
|
+
|
297
|
+
secret_config --export test.yml --path /test/my_application
|
298
|
+
|
299
|
+
Export from a path in AWS SSM Parameter Store to a yaml file, _without_ filtering out passwords:
|
300
|
+
|
301
|
+
secret_config --export test.yml --path /test/my_application --no-filter
|
302
|
+
|
303
|
+
Export from a path in AWS SSM Parameter Store to a json file, where passwords are filtered:
|
304
|
+
|
305
|
+
secret_config --export test.json --path /test/my_application
|
306
|
+
|
307
|
+
Import a yaml file, into a path in AWS SSM Parameter Store:
|
308
|
+
|
309
|
+
secret_config --import test.yml --path /production/my_application
|
310
|
+
|
311
|
+
Import a yaml file, into a path in AWS SSM Parameter Store, using a custom KMS key to encrypt the values:
|
312
|
+
|
313
|
+
secret_config --import test.yml --path /production/my_application --key_id "arn:aws:kms:us-east-1:23643632463:key/UUID"
|
314
|
+
|
315
|
+
Copy configuration from one path in AWS SSM Parameter Store to another path in AWS SSM Parameter Store:
|
316
|
+
|
317
|
+
secret_config --copy /test/my_application --path /production/my_application
|
318
|
+
|
319
|
+
During an `import` or `copy` if any of the source values consist only of `$random`,
|
320
|
+
they will be replaced with a secure 32 byte random value.
|
321
|
+
This is deal for when a secure random password needs to be generated.
|
322
|
+
Use `--random_size` to adjust the length of the randomized string.
|
323
|
+
|
222
324
|
## Versioning
|
223
325
|
|
224
326
|
This project adheres to [Semantic Versioning](http://semver.org/).
|
data/bin/secret_config
ADDED
@@ -0,0 +1,232 @@
|
|
1
|
+
require 'optparse'
|
2
|
+
require 'fileutils'
|
3
|
+
require 'erb'
|
4
|
+
require 'yaml'
|
5
|
+
require 'json'
|
6
|
+
require 'securerandom'
|
7
|
+
|
8
|
+
module SecretConfig
|
9
|
+
class CLI
|
10
|
+
attr_reader :path, :region, :provider,
|
11
|
+
:export, :no_filter,
|
12
|
+
:import, :key_id, :random_size, :prune, :overwrite,
|
13
|
+
:copy_path,
|
14
|
+
:show_version
|
15
|
+
|
16
|
+
PROVIDERS = %i[ssm].freeze
|
17
|
+
|
18
|
+
def self.run!(argv)
|
19
|
+
new(argv).run!
|
20
|
+
end
|
21
|
+
|
22
|
+
def initialize(argv)
|
23
|
+
@export = false
|
24
|
+
@import = false
|
25
|
+
@path = nil
|
26
|
+
@key_id = nil
|
27
|
+
@region = ENV['AWS_REGION']
|
28
|
+
@provider = :ssm
|
29
|
+
@random_size = 32
|
30
|
+
@no_filter = false
|
31
|
+
@prune = false
|
32
|
+
@replace = false
|
33
|
+
@copy_path = nil
|
34
|
+
@show_version = false
|
35
|
+
|
36
|
+
if argv.empty?
|
37
|
+
puts parser
|
38
|
+
exit(-10)
|
39
|
+
end
|
40
|
+
parser.parse!(argv)
|
41
|
+
end
|
42
|
+
|
43
|
+
def run!
|
44
|
+
if show_version
|
45
|
+
puts "Secret Config v#{VERSION}"
|
46
|
+
puts "Region: #{region}"
|
47
|
+
elsif export
|
48
|
+
run_export(export, filtered: !no_filter)
|
49
|
+
elsif import
|
50
|
+
run_import(import)
|
51
|
+
elsif copy_path
|
52
|
+
run_copy(copy_path, path)
|
53
|
+
else
|
54
|
+
puts parser
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def parser
|
59
|
+
@parser ||= OptionParser.new do |opts|
|
60
|
+
opts.banner = <<~BANNER
|
61
|
+
Secret Config v#{VERSION}
|
62
|
+
|
63
|
+
For more information, see: https://rocketjob.github.io/secret_config/
|
64
|
+
|
65
|
+
secret_config [options]
|
66
|
+
BANNER
|
67
|
+
|
68
|
+
opts.on '-e', '--export [FILE_NAME]', 'Export configuration to a file or stdout if no file_name supplied.' do |file_name|
|
69
|
+
@export = file_name || STDOUT
|
70
|
+
end
|
71
|
+
|
72
|
+
opts.on '-i', '--import [FILE_NAME]', 'Import configuration from a file or stdin if no file_name supplied.' do |file_name|
|
73
|
+
@import = file_name || STDIN
|
74
|
+
end
|
75
|
+
|
76
|
+
opts.on '-c', '--copy SOURCE_PATH', 'Import configuration from a file or stdin if no file_name supplied.' do |path|
|
77
|
+
@copy_path = path
|
78
|
+
end
|
79
|
+
|
80
|
+
opts.on '-p', '--path PATH', 'Path to import from / export to.' do |path|
|
81
|
+
@path = path
|
82
|
+
end
|
83
|
+
|
84
|
+
opts.on '-P', '--provider PROVIDER', 'Provider to use. [ssm | file]. Default: ssm' do |provider|
|
85
|
+
@provider = provider.to_sym
|
86
|
+
end
|
87
|
+
|
88
|
+
opts.on '-U', '--no-filter', 'Do not filter passwords and keys.' do
|
89
|
+
@no_filter = true
|
90
|
+
end
|
91
|
+
|
92
|
+
# TODO:
|
93
|
+
# opts.on '-Q', '--prune', 'During import delete all existing keys for which their is no key in the import file' do
|
94
|
+
# @prune = true
|
95
|
+
# end
|
96
|
+
#
|
97
|
+
# opts.on '-r', '--replace', 'During import replace existing keys if present' do
|
98
|
+
# @replace = true
|
99
|
+
# end
|
100
|
+
|
101
|
+
opts.on '-k', '--key_id KEY_ID', 'AWS KMS Key id or Key Alias to use when importing configuration values. Default: AWS Default key.' do |key_id|
|
102
|
+
@key_id = key_id
|
103
|
+
end
|
104
|
+
|
105
|
+
opts.on '-r', '--region REGION', 'AWS Region to use. Default: AWS_REGION env var.' do |region|
|
106
|
+
@region = region
|
107
|
+
end
|
108
|
+
|
109
|
+
opts.on '-R', '--random_size INTEGER', 'Size to use when generating random values. Whenever $random is encountered during an import. Default: 32' do |region|
|
110
|
+
@random_size = random_size
|
111
|
+
end
|
112
|
+
|
113
|
+
opts.on '-v', '--version', 'Display Symmetric Encryption version.' do
|
114
|
+
@show_version = true
|
115
|
+
end
|
116
|
+
|
117
|
+
opts.on('-h', '--help', 'Prints this help.') do
|
118
|
+
puts opts
|
119
|
+
exit
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
private
|
125
|
+
|
126
|
+
def provider_instance
|
127
|
+
@provider_instance ||= begin
|
128
|
+
case provider
|
129
|
+
when :ssm
|
130
|
+
Providers::Ssm.new(key_id: key_id)
|
131
|
+
else
|
132
|
+
raise ArgumentError, "Invalid provider: #{provider}"
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
def run_export(file_name, filtered: true)
|
138
|
+
config = fetch_config(path, filtered: filtered)
|
139
|
+
|
140
|
+
format = file_format(file_name)
|
141
|
+
data = render(config, format)
|
142
|
+
write_file(file_name, data)
|
143
|
+
|
144
|
+
puts("Exported #{path} from #{provider} to #{file_name}") if file_name.is_a?(String)
|
145
|
+
end
|
146
|
+
|
147
|
+
def run_import(file_name)
|
148
|
+
format = file_format(file_name)
|
149
|
+
data = read_file(file_name)
|
150
|
+
config = parse(data, format)
|
151
|
+
set_config(config, path)
|
152
|
+
|
153
|
+
puts("Imported #{file_name} to #{provider} at #{path}") if file_name.is_a?(String)
|
154
|
+
end
|
155
|
+
|
156
|
+
def run_copy(source_path, target_path)
|
157
|
+
config = fetch_config(source_path, filtered: false)
|
158
|
+
|
159
|
+
set_config(config, target_path)
|
160
|
+
|
161
|
+
puts "Copied #{source_path} to #{target_path} using #{provider}"
|
162
|
+
end
|
163
|
+
|
164
|
+
def set_config(config, path)
|
165
|
+
# TODO: prune, replace
|
166
|
+
Utils.flatten_each(config, path) do |key, value|
|
167
|
+
value = random_password if value.strip == '$random'
|
168
|
+
puts "Setting: #{key}"
|
169
|
+
provider_instance.set(key, value)
|
170
|
+
end
|
171
|
+
end
|
172
|
+
|
173
|
+
def fetch_config(path, filtered: true)
|
174
|
+
registry = Registry.new(path: path, provider: provider_instance)
|
175
|
+
filtered ? registry.configuration : registry.configuration(filters: nil)
|
176
|
+
end
|
177
|
+
|
178
|
+
def read_file(file_name_or_io)
|
179
|
+
return file_name_or_io.read unless file_name_or_io.is_a?(String)
|
180
|
+
|
181
|
+
::File.new(file_name_or_io).read
|
182
|
+
end
|
183
|
+
|
184
|
+
def write_file(file_name_or_io, data)
|
185
|
+
return file_name_or_io.write(data) unless file_name_or_io.is_a?(String)
|
186
|
+
|
187
|
+
output_path = ::File.dirname(file_name_or_io)
|
188
|
+
FileUtils.mkdir_p(output_path) unless ::File.exist?(output_path)
|
189
|
+
|
190
|
+
::File.open(file_name_or_io, 'w') { |io| io.write(data) }
|
191
|
+
end
|
192
|
+
|
193
|
+
def render(hash, format)
|
194
|
+
case format
|
195
|
+
when :yml
|
196
|
+
hash.to_yaml
|
197
|
+
when :json
|
198
|
+
hash.to_json
|
199
|
+
else
|
200
|
+
raise ArgumentError, "Invalid format: #{format.inspect}"
|
201
|
+
end
|
202
|
+
end
|
203
|
+
|
204
|
+
def parse(data, format)
|
205
|
+
case format
|
206
|
+
when :yml
|
207
|
+
YAML.load(ERB.new(data).result)
|
208
|
+
when :json
|
209
|
+
JSON.parse(data)
|
210
|
+
else
|
211
|
+
raise ArgumentError, "Invalid format: #{format.inspect}"
|
212
|
+
end
|
213
|
+
end
|
214
|
+
|
215
|
+
def file_format(file_name)
|
216
|
+
return :yml unless file_name.is_a?(String)
|
217
|
+
|
218
|
+
case File.extname(file_name).downcase
|
219
|
+
when '.yml', '.yaml'
|
220
|
+
:yml
|
221
|
+
when '.json'
|
222
|
+
:json
|
223
|
+
else
|
224
|
+
raise ArgumentError, "Import/Export file name must end with '.yml' or '.json'"
|
225
|
+
end
|
226
|
+
end
|
227
|
+
|
228
|
+
def random_password
|
229
|
+
SecureRandom.urlsafe_base64(random_size)
|
230
|
+
end
|
231
|
+
end
|
232
|
+
end
|
@@ -20,7 +20,7 @@ module SecretConfig
|
|
20
20
|
|
21
21
|
raise(ConfigError, "Path #{paths.join(".")} not found in file: #{file_name}") unless settings
|
22
22
|
|
23
|
-
flatten_each(
|
23
|
+
Utils.flatten_each(settings, path, &block)
|
24
24
|
nil
|
25
25
|
end
|
26
26
|
|
@@ -28,18 +28,6 @@ module SecretConfig
|
|
28
28
|
raise NotImplementedError
|
29
29
|
end
|
30
30
|
|
31
|
-
private
|
32
|
-
|
33
|
-
def flatten_each(path, hash, &block)
|
34
|
-
hash.each_pair do |key, value|
|
35
|
-
if value.is_a?(Hash)
|
36
|
-
flatten_each("#{path}/#{key}", value, &block)
|
37
|
-
else
|
38
|
-
key = "#{path}/#{key}" unless key.start_with?('/')
|
39
|
-
yield(key, value)
|
40
|
-
end
|
41
|
-
end
|
42
|
-
end
|
43
31
|
end
|
44
32
|
end
|
45
33
|
end
|
@@ -5,7 +5,7 @@ module SecretConfig
|
|
5
5
|
# @example Set up configuration in the Rails app.
|
6
6
|
# module MyApplication
|
7
7
|
# class Application < Rails::Application
|
8
|
-
# config.secret_config.use :file,
|
8
|
+
# config.secret_config.use :file, path: '/development'
|
9
9
|
# end
|
10
10
|
# end
|
11
11
|
config.secret_config = SecretConfig
|
@@ -5,20 +5,21 @@ module SecretConfig
|
|
5
5
|
# Centralized configuration with values stored in AWS System Manager Parameter Store
|
6
6
|
class Registry
|
7
7
|
attr_reader :provider
|
8
|
-
attr_accessor :
|
8
|
+
attr_accessor :path
|
9
9
|
|
10
|
-
def initialize(
|
11
|
-
|
12
|
-
@
|
13
|
-
|
14
|
-
@
|
10
|
+
def initialize(path: nil, provider: :file, provider_args: nil)
|
11
|
+
@path = path || default_path
|
12
|
+
raise(UndefinedRootError, 'Root must start with /') unless @path.start_with?('/')
|
13
|
+
|
14
|
+
@provider = create_provider(provider, provider_args)
|
15
|
+
@cache = Concurrent::Map.new
|
15
16
|
refresh!
|
16
17
|
end
|
17
18
|
|
18
19
|
# Returns [Hash] a copy of the in memory configuration data.
|
19
20
|
def configuration(relative: true, filters: SecretConfig.filters)
|
20
21
|
h = {}
|
21
|
-
|
22
|
+
cache.each_pair do |key, value|
|
22
23
|
key = relative_key(key) if relative
|
23
24
|
value = filter_value(key, value, filters)
|
24
25
|
decompose(key, value, h)
|
@@ -28,19 +29,19 @@ module SecretConfig
|
|
28
29
|
|
29
30
|
# Returns [String] configuration value for the supplied key, or nil when missing.
|
30
31
|
def [](key)
|
31
|
-
|
32
|
+
cache[expand_key(key)]
|
32
33
|
end
|
33
34
|
|
34
35
|
# Returns [String] configuration value for the supplied key, or nil when missing.
|
35
36
|
def key?(key)
|
36
|
-
|
37
|
+
cache.key?(expand_key(key))
|
37
38
|
end
|
38
39
|
|
39
40
|
# Returns [String] configuration value for the supplied key
|
40
41
|
def fetch(key, default: nil, type: :string, encoding: nil)
|
41
42
|
value = self[key]
|
42
43
|
if value.nil?
|
43
|
-
raise(MissingMandatoryKey, "Missing configuration value for #{
|
44
|
+
raise(MissingMandatoryKey, "Missing configuration value for #{path}/#{key}") unless default
|
44
45
|
|
45
46
|
value = default.respond_to?(:call) ? default.call : default
|
46
47
|
end
|
@@ -53,28 +54,28 @@ module SecretConfig
|
|
53
54
|
def set(key:, value:, encrypt: true)
|
54
55
|
key = expand_key(key)
|
55
56
|
provider.set(key, value, encrypt: true)
|
56
|
-
|
57
|
+
cache[key] = value
|
57
58
|
end
|
58
59
|
|
59
60
|
# Refresh the in-memory cached copy of the centralized configuration information.
|
60
61
|
# Environment variable values will take precendence over the central store values.
|
61
62
|
def refresh!
|
62
|
-
existing_keys =
|
63
|
+
existing_keys = cache.keys
|
63
64
|
updated_keys = []
|
64
|
-
provider.each(
|
65
|
-
|
65
|
+
provider.each(path) do |key, value|
|
66
|
+
cache[key] = env_var_override(key, value)
|
66
67
|
updated_keys << key
|
67
68
|
end
|
68
69
|
|
69
|
-
# Remove keys deleted from the registry.
|
70
|
-
(existing_keys - updated_keys).each { |key|
|
70
|
+
# Remove keys deleted from the central registry.
|
71
|
+
(existing_keys - updated_keys).each { |key| provider.delete(key) }
|
71
72
|
|
72
73
|
true
|
73
74
|
end
|
74
75
|
|
75
76
|
private
|
76
77
|
|
77
|
-
attr_reader :
|
78
|
+
attr_reader :cache
|
78
79
|
|
79
80
|
# Returns the value from an env var if it is present,
|
80
81
|
# Otherwise the value is returned unchanged.
|
@@ -83,22 +84,21 @@ module SecretConfig
|
|
83
84
|
ENV[env_var_name] || value
|
84
85
|
end
|
85
86
|
|
86
|
-
# Add the
|
87
|
+
# Add the path to the path if it is a relative path.
|
87
88
|
def expand_key(key)
|
88
|
-
key.start_with?('/') ? key : "#{
|
89
|
+
key.start_with?('/') ? key : "#{path}/#{key}"
|
89
90
|
end
|
90
91
|
|
91
|
-
# Convert the key to a relative path by removing the
|
92
|
-
# root path.
|
92
|
+
# Convert the key to a relative path by removing the path.
|
93
93
|
def relative_key(key)
|
94
|
-
key.start_with?('/') ? key.sub("#{
|
94
|
+
key.start_with?('/') ? key.sub("#{path}/", '') : key
|
95
95
|
end
|
96
96
|
|
97
97
|
def filter_value(key, value, filters)
|
98
98
|
return value unless filters
|
99
99
|
|
100
100
|
_, name = File.split(key)
|
101
|
-
filter
|
101
|
+
filter = filters.any? do |filter|
|
102
102
|
filter.is_a?(Regexp) ? name =~ filter : name == filter
|
103
103
|
end
|
104
104
|
|
@@ -144,5 +144,21 @@ module SecretConfig
|
|
144
144
|
end
|
145
145
|
end
|
146
146
|
|
147
|
+
# Create a new provider instance unless it is alread a provider instance.
|
148
|
+
def create_provider(provider, args = nil)
|
149
|
+
return provider if provider.respond_to?(:each) && provider.respond_to?(:set)
|
150
|
+
|
151
|
+
klass = Utils.constantize_symbol(provider)
|
152
|
+
args && args.size > 0 ? klass.new(**args) : klass.new
|
153
|
+
end
|
154
|
+
|
155
|
+
def default_path
|
156
|
+
path = ENV["SECRET_CONFIG_PATH"] || ENV["RAILS_ENV"]
|
157
|
+
path = Rails.env if path.nil? && defined?(Rails) && Rails.respond_to?(:env)
|
158
|
+
|
159
|
+
raise(UndefinedRootError, "Either set env var 'SECRET_CONFIG_PATH' or call SecretConfig.use") unless path
|
160
|
+
|
161
|
+
path.start_with?('/') ? path : "/#{path}"
|
162
|
+
end
|
147
163
|
end
|
148
164
|
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
module SecretConfig
|
2
|
+
module Utils
|
3
|
+
# Takes a hierarchical structure and flattens it to a single level
|
4
|
+
# If path is supplied it is prepended to every key returned
|
5
|
+
def self.flatten_each(hash, path = nil, &block)
|
6
|
+
hash.each_pair do |key, value|
|
7
|
+
name = path.nil? ? key : "#{path}/#{key}"
|
8
|
+
value.is_a?(Hash) ? flatten_each(value, name, &block) : yield(name, value)
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.constantize_symbol(symbol, namespace = 'SecretConfig::Providers')
|
13
|
+
klass = "#{namespace}::#{camelize(symbol.to_s)}"
|
14
|
+
begin
|
15
|
+
Object.const_get(klass)
|
16
|
+
rescue NameError
|
17
|
+
raise(ArgumentError, "Could not convert symbol: #{symbol.inspect} to a class in: #{namespace}. Looking for: #{klass}")
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
# Borrow from Rails, when not running Rails
|
22
|
+
def self.camelize(term)
|
23
|
+
string = term.to_s
|
24
|
+
string = string.sub(/^[a-z\d]*/, &:capitalize)
|
25
|
+
string.gsub!(/(?:_|(\/))([a-z\d]*)/i) { "#{Regexp.last_match(1)}#{Regexp.last_match(2).capitalize}" }
|
26
|
+
string.gsub!('/'.freeze, '::'.freeze)
|
27
|
+
string
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
data/lib/secret_config.rb
CHANGED
@@ -11,6 +11,9 @@ module SecretConfig
|
|
11
11
|
autoload :Ssm, 'secret_config/providers/ssm'
|
12
12
|
end
|
13
13
|
|
14
|
+
autoload :CLI, 'secret_config/cli'
|
15
|
+
autoload :Utils, 'secret_config/utils'
|
16
|
+
|
14
17
|
class << self
|
15
18
|
extend Forwardable
|
16
19
|
|
@@ -25,33 +28,16 @@ module SecretConfig
|
|
25
28
|
end
|
26
29
|
|
27
30
|
# Which provider to use along with any arguments
|
28
|
-
# The
|
29
|
-
def self.use(provider,
|
30
|
-
|
31
|
-
@
|
32
|
-
@registry = nil if @registry
|
33
|
-
end
|
34
|
-
|
35
|
-
def self.root
|
36
|
-
@root ||= begin
|
37
|
-
root = ENV["SECRET_CONFIG_ROOT"] || ENV["RAILS_ENV"]
|
38
|
-
root = Rails.env if root.nil? && defined?(Rails) && Rails.respond_to?(:env)
|
39
|
-
raise(UndefinedRootError, "Either set env var 'SECRET_CONFIG_ROOT' or call SecretConfig.use") unless root
|
40
|
-
root = "/#{root}" unless root.start_with?('/')
|
41
|
-
root
|
42
|
-
end
|
43
|
-
end
|
44
|
-
|
45
|
-
# Returns the current provider.
|
46
|
-
# If `SecretConfig.use` was not called previously it automatically use the file based provider.
|
47
|
-
def self.provider
|
48
|
-
@provider ||= begin
|
49
|
-
create_provider(:file)
|
50
|
-
end
|
31
|
+
# The path will be overriden by env var `SECRET_CONFIG_PATH` if present.
|
32
|
+
def self.use(provider, path: nil, **args)
|
33
|
+
path ||= ENV["SECRET_CONFIG_PATH"]
|
34
|
+
@registry = SecretConfig::Registry.new(path: path, provider: provider, provider_args: args)
|
51
35
|
end
|
52
36
|
|
37
|
+
# Returns the global registry.
|
38
|
+
# Unless `.use` was called above, it will default to a file provider.
|
53
39
|
def self.registry
|
54
|
-
@registry ||= SecretConfig::Registry.new
|
40
|
+
@registry ||= SecretConfig::Registry.new
|
55
41
|
end
|
56
42
|
|
57
43
|
# Filters to apply when returning the configuration
|
@@ -77,34 +63,4 @@ module SecretConfig
|
|
77
63
|
|
78
64
|
@check_env_var = true
|
79
65
|
@filters = [/password/, 'key', /secret_key/]
|
80
|
-
|
81
|
-
# Create a new provider instance unless it is alread a provider instance.
|
82
|
-
def self.create_provider(provider, args = nil)
|
83
|
-
return provider if provider.respond_to?(:each) && provider.respond_to?(:set)
|
84
|
-
|
85
|
-
klass = constantize_symbol(provider)
|
86
|
-
args && args.size > 0 ? klass.new(**args) : klass.new
|
87
|
-
end
|
88
|
-
|
89
|
-
def implementation
|
90
|
-
@implementation ||= constantize_symbol(provider).new
|
91
|
-
end
|
92
|
-
|
93
|
-
def self.constantize_symbol(symbol, namespace = 'SecretConfig::Providers')
|
94
|
-
klass = "#{namespace}::#{camelize(symbol.to_s)}"
|
95
|
-
begin
|
96
|
-
Object.const_get(klass)
|
97
|
-
rescue NameError
|
98
|
-
raise(ArgumentError, "Could not convert symbol: #{symbol.inspect} to a class in: #{namespace}. Looking for: #{klass}")
|
99
|
-
end
|
100
|
-
end
|
101
|
-
|
102
|
-
# Borrow from Rails, when not running Rails
|
103
|
-
def self.camelize(term)
|
104
|
-
string = term.to_s
|
105
|
-
string = string.sub(/^[a-z\d]*/, &:capitalize)
|
106
|
-
string.gsub!(/(?:_|(\/))([a-z\d]*)/i) { "#{Regexp.last_match(1)}#{Regexp.last_match(2).capitalize}" }
|
107
|
-
string.gsub!('/'.freeze, '::'.freeze)
|
108
|
-
string
|
109
|
-
end
|
110
66
|
end
|
data/test/config/application.yml
CHANGED
data/test/providers/file_test.rb
CHANGED
@@ -7,23 +7,23 @@ module Providers
|
|
7
7
|
File.join(File.dirname(__FILE__), '..', 'config', 'application.yml')
|
8
8
|
end
|
9
9
|
|
10
|
-
let :
|
11
|
-
"/
|
10
|
+
let :path do
|
11
|
+
"/test/my_application"
|
12
12
|
end
|
13
13
|
|
14
14
|
let :expected do
|
15
15
|
{
|
16
|
-
"/
|
17
|
-
"/
|
18
|
-
"/
|
19
|
-
"/
|
20
|
-
"/
|
21
|
-
"/
|
22
|
-
"/
|
23
|
-
"/
|
24
|
-
"/
|
25
|
-
"/
|
26
|
-
"/
|
16
|
+
"/test/my_application/mongo/database" => "secret_config_test",
|
17
|
+
"/test/my_application/mongo/primary" => "127.0.0.1:27017",
|
18
|
+
"/test/my_application/mongo/secondary" => "127.0.0.1:27018",
|
19
|
+
"/test/my_application/mysql/database" => "secret_config_test",
|
20
|
+
"/test/my_application/mysql/password" => "secret_configrules",
|
21
|
+
"/test/my_application/mysql/username" => "secret_config",
|
22
|
+
"/test/my_application/mysql/host" => "127.0.0.1",
|
23
|
+
"/test/my_application/secrets/secret_key_base" => "somereallylongteststring",
|
24
|
+
"/test/my_application/symmetric_encryption/key" => "QUJDREVGMTIzNDU2Nzg5MEFCQ0RFRjEyMzQ1Njc4OTA=",
|
25
|
+
"/test/my_application/symmetric_encryption/version" => 2,
|
26
|
+
"/test/my_application/symmetric_encryption/iv" => "QUJDREVGMTIzNDU2Nzg5MA=="
|
27
27
|
}
|
28
28
|
end
|
29
29
|
|
@@ -31,7 +31,7 @@ module Providers
|
|
31
31
|
it 'file' do
|
32
32
|
file_provider = SecretConfig::Providers::File.new(file_name: file_name)
|
33
33
|
paths = {}
|
34
|
-
file_provider.each(
|
34
|
+
file_provider.each(path) { |key, value| paths[key] = value }
|
35
35
|
|
36
36
|
expected.each_pair do |key, value|
|
37
37
|
assert_equal value, paths[key], "Path: #{key}"
|
data/test/providers/ssm_test.rb
CHANGED
@@ -7,23 +7,23 @@ module Providers
|
|
7
7
|
File.join(File.dirname(__FILE__), '..', 'config', 'application.yml')
|
8
8
|
end
|
9
9
|
|
10
|
-
let :
|
11
|
-
"/
|
10
|
+
let :path do
|
11
|
+
"/test/my_application"
|
12
12
|
end
|
13
13
|
|
14
14
|
let :expected do
|
15
15
|
{
|
16
|
-
"/
|
17
|
-
"/
|
18
|
-
"/
|
19
|
-
"/
|
20
|
-
"/
|
21
|
-
"/
|
22
|
-
"/
|
23
|
-
"/
|
24
|
-
"/
|
25
|
-
"/
|
26
|
-
"/
|
16
|
+
"/test/my_application/mongo/database" => "secret_config_test",
|
17
|
+
"/test/my_application/mongo/primary" => "127.0.0.1:27017",
|
18
|
+
"/test/my_application/mongo/secondary" => "127.0.0.1:27018",
|
19
|
+
"/test/my_application/mysql/database" => "secret_config_test",
|
20
|
+
"/test/my_application/mysql/password" => "secret_configrules",
|
21
|
+
"/test/my_application/mysql/username" => "secret_config",
|
22
|
+
"/test/my_application/mysql/host" => "127.0.0.1",
|
23
|
+
"/test/my_application/secrets/secret_key_base" => "somereallylongteststring",
|
24
|
+
"/test/my_application/symmetric_encryption/key" => "QUJDREVGMTIzNDU2Nzg5MEFCQ0RFRjEyMzQ1Njc4OTA=",
|
25
|
+
"/test/my_application/symmetric_encryption/version" => "2",
|
26
|
+
"/test/my_application/symmetric_encryption/iv" => "QUJDREVGMTIzNDU2Nzg5MA=="
|
27
27
|
}
|
28
28
|
end
|
29
29
|
|
@@ -37,11 +37,11 @@ module Providers
|
|
37
37
|
it 'fetches all keys in path' do
|
38
38
|
ssm = SecretConfig::Providers::Ssm.new
|
39
39
|
paths = {}
|
40
|
-
ssm.each(
|
40
|
+
ssm.each(path) { |key, value| paths[key] = value }
|
41
41
|
|
42
42
|
if paths.empty?
|
43
|
-
upload_settings(ssm) unless
|
44
|
-
ssm.each(
|
43
|
+
upload_settings(ssm) unless paths.key?("/test/my_application/mongo/database")
|
44
|
+
ssm.each(path) { |key, value| paths[key] = value }
|
45
45
|
end
|
46
46
|
|
47
47
|
expected.each_pair do |key, value|
|
@@ -52,7 +52,7 @@ module Providers
|
|
52
52
|
|
53
53
|
def upload_settings(ssm)
|
54
54
|
file_provider = SecretConfig::Providers::File.new(file_name: file_name)
|
55
|
-
file_provider.each(
|
55
|
+
file_provider.each(path) { |key, value| ap key; ssm.set(key, value) }
|
56
56
|
end
|
57
57
|
end
|
58
58
|
end
|
data/test/registry_test.rb
CHANGED
@@ -6,8 +6,8 @@ class RegistryTest < Minitest::Test
|
|
6
6
|
File.join(File.dirname(__FILE__), 'config', 'application.yml')
|
7
7
|
end
|
8
8
|
|
9
|
-
let :
|
10
|
-
"/
|
9
|
+
let :path do
|
10
|
+
"/test/my_application"
|
11
11
|
end
|
12
12
|
|
13
13
|
let :provider do
|
@@ -15,22 +15,22 @@ class RegistryTest < Minitest::Test
|
|
15
15
|
end
|
16
16
|
|
17
17
|
let :registry do
|
18
|
-
SecretConfig::Registry.new(
|
18
|
+
SecretConfig::Registry.new(path: path, provider: provider)
|
19
19
|
end
|
20
20
|
|
21
21
|
let :expected do
|
22
22
|
{
|
23
|
-
"/
|
24
|
-
"/
|
25
|
-
"/
|
26
|
-
"/
|
27
|
-
"/
|
28
|
-
"/
|
29
|
-
"/
|
30
|
-
"/
|
31
|
-
"/
|
32
|
-
"/
|
33
|
-
"/
|
23
|
+
"/test/my_application/mongo/database" => "secret_config_test",
|
24
|
+
"/test/my_application/mongo/primary" => "127.0.0.1:27017",
|
25
|
+
"/test/my_application/mongo/secondary" => "127.0.0.1:27018",
|
26
|
+
"/test/my_application/mysql/database" => "secret_config_test",
|
27
|
+
"/test/my_application/mysql/password" => "secret_configrules",
|
28
|
+
"/test/my_application/mysql/username" => "secret_config",
|
29
|
+
"/test/my_application/mysql/host" => "127.0.0.1",
|
30
|
+
"/test/my_application/secrets/secret_key_base" => "somereallylongteststring",
|
31
|
+
"/test/my_application/symmetric_encryption/key" => "QUJDREVGMTIzNDU2Nzg5MEFCQ0RFRjEyMzQ1Njc4OTA=",
|
32
|
+
"/test/my_application/symmetric_encryption/version" => 2,
|
33
|
+
"/test/my_application/symmetric_encryption/iv" => "QUJDREVGMTIzNDU2Nzg5MA=="
|
34
34
|
}
|
35
35
|
end
|
36
36
|
|
@@ -51,7 +51,7 @@ class RegistryTest < Minitest::Test
|
|
51
51
|
describe '#key?' do
|
52
52
|
it 'has key' do
|
53
53
|
expected.each_pair do |key, value|
|
54
|
-
key = key.sub("#{
|
54
|
+
key = key.sub("#{path}/", "")
|
55
55
|
assert registry.key?(key), "Path: #{key}"
|
56
56
|
end
|
57
57
|
end
|
@@ -61,14 +61,14 @@ class RegistryTest < Minitest::Test
|
|
61
61
|
end
|
62
62
|
|
63
63
|
it 'returns nil with missing full key' do
|
64
|
-
refute registry.key?("/
|
64
|
+
refute registry.key?("/test/invalid/path")
|
65
65
|
end
|
66
66
|
end
|
67
67
|
|
68
68
|
describe '#[]' do
|
69
69
|
it 'returns values' do
|
70
70
|
expected.each_pair do |key, value|
|
71
|
-
key = key.sub("#{
|
71
|
+
key = key.sub("#{path}/", "")
|
72
72
|
assert_equal value, registry[key], "Path: #{key}"
|
73
73
|
end
|
74
74
|
end
|
@@ -78,14 +78,14 @@ class RegistryTest < Minitest::Test
|
|
78
78
|
end
|
79
79
|
|
80
80
|
it 'returns nil with missing full key' do
|
81
|
-
assert_nil registry["/
|
81
|
+
assert_nil registry["/test/invalid/path"]
|
82
82
|
end
|
83
83
|
end
|
84
84
|
|
85
85
|
describe '#fetch' do
|
86
86
|
it 'returns values' do
|
87
87
|
expected.each_pair do |key, value|
|
88
|
-
key = key.sub("#{
|
88
|
+
key = key.sub("#{path}/", "")
|
89
89
|
assert_equal value, registry.fetch(key), "Path: #{key}"
|
90
90
|
end
|
91
91
|
end
|
@@ -98,12 +98,12 @@ class RegistryTest < Minitest::Test
|
|
98
98
|
|
99
99
|
it 'returns nil with missing full key' do
|
100
100
|
assert_raises SecretConfig::MissingMandatoryKey do
|
101
|
-
registry.fetch("/
|
101
|
+
registry.fetch("/test/invalid/path")
|
102
102
|
end
|
103
103
|
end
|
104
104
|
|
105
105
|
it 'returns default with missing key' do
|
106
|
-
assert_equal "default_value", registry.fetch("/
|
106
|
+
assert_equal "default_value", registry.fetch("/test/invalid/path", default: "default_value")
|
107
107
|
end
|
108
108
|
|
109
109
|
it 'converts to integer' do
|
data/test/secret_config_test.rb
CHANGED
@@ -6,12 +6,12 @@ class SecretConfigTest < Minitest::Test
|
|
6
6
|
File.join(File.dirname(__FILE__), 'config', 'application.yml')
|
7
7
|
end
|
8
8
|
|
9
|
-
let :
|
10
|
-
"/
|
9
|
+
let :path do
|
10
|
+
"/test/my_application"
|
11
11
|
end
|
12
12
|
|
13
13
|
before do
|
14
|
-
SecretConfig.use :file,
|
14
|
+
SecretConfig.use :file, path: path, file_name: file_name
|
15
15
|
end
|
16
16
|
|
17
17
|
describe '#configuration' do
|
@@ -28,19 +28,24 @@ class SecretConfigTest < Minitest::Test
|
|
28
28
|
|
29
29
|
describe '#[]' do
|
30
30
|
it 'returns values' do
|
31
|
-
assert_equal "
|
31
|
+
assert_equal "secret_config_test", SecretConfig["mysql/database"]
|
32
32
|
end
|
33
33
|
end
|
34
34
|
|
35
35
|
describe '#fetch' do
|
36
|
+
after do
|
37
|
+
ENV['MYSQL_DATABASE'] = nil
|
38
|
+
end
|
39
|
+
|
36
40
|
it 'fetches values' do
|
37
|
-
assert_equal "
|
41
|
+
assert_equal "secret_config_test", SecretConfig.fetch("mysql/database")
|
38
42
|
end
|
39
43
|
|
40
44
|
it 'can be overridden by an environment variable' do
|
41
45
|
ENV['MYSQL_DATABASE'] = 'other'
|
46
|
+
|
47
|
+
SecretConfig.use :file, path: path, file_name: file_name
|
42
48
|
assert_equal "other", SecretConfig.fetch("mysql/database")
|
43
|
-
ENV['MYSQL_DATABASE'] = nil
|
44
49
|
end
|
45
50
|
end
|
46
51
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: secret_config
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.4.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Reid Morrison
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2019-04-
|
11
|
+
date: 2019-04-18 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: concurrent-ruby
|
@@ -34,12 +34,15 @@ files:
|
|
34
34
|
- LICENSE
|
35
35
|
- README.md
|
36
36
|
- Rakefile
|
37
|
+
- bin/secret_config
|
37
38
|
- lib/secret_config.rb
|
39
|
+
- lib/secret_config/cli.rb
|
38
40
|
- lib/secret_config/errors.rb
|
39
41
|
- lib/secret_config/providers/file.rb
|
40
42
|
- lib/secret_config/providers/ssm.rb
|
41
43
|
- lib/secret_config/railtie.rb
|
42
44
|
- lib/secret_config/registry.rb
|
45
|
+
- lib/secret_config/utils.rb
|
43
46
|
- lib/secret_config/version.rb
|
44
47
|
- test/config/application.yml
|
45
48
|
- test/providers/file_test.rb
|