secret_config 0.3.1 → 0.4.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|