invar 0.4.0 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 58a64d36feebf42e3d2e44e3cee20197683055dbb276f38701344db45a050e27
4
- data.tar.gz: f39265a13a4ae8a49cb2b7944599c75bab6723422d3b92f5838b777d7fa8dfab
3
+ metadata.gz: 6e3d07cd5d6d704c0bec06fe0803e0a5fd553f69e54f74501ddd7e9639353172
4
+ data.tar.gz: 3ecb948cb3b856d9f15d0d1d985f5e59db62532a724e8c4c748287532d1efac2
5
5
  SHA512:
6
- metadata.gz: c8d134ac530b0e9a2046922cc1055621d4120e92840eedee5fd5d527b1cd5a6880577a78cc4cbfa065dc751d48d9883a0edd36e661dcd83151b6967f1501cf6d
7
- data.tar.gz: e27ee5476e9efcf5871f78e50dd428398306a2dc48092b56c55a04be3e98c30fd2fad2d899ed7d35fff70ef297d2ede51941dd22197eb56222155f8b5d93be48
6
+ metadata.gz: 66ebee132d5bc1ba58e5876594435514d19b177fda6820d1ebe05ad6908b590f48b4466c30af950127cf3178a43eeb74573535fcfd32aa9f2165e378b657f4be
7
+ data.tar.gz: 3972595f9f126f76707b1bf6c2d76ef6803d5056808cf06977106256524be109ed9b0975a99931cb9acf694d9e1fb934916f5de0939e6dc65fc920e61c412195
data/README.md CHANGED
@@ -181,7 +181,9 @@ puts invar / :config / :database
181
181
  In your `Rakefile`, add:
182
182
 
183
183
  ```ruby
184
- require 'invar/rake'
184
+ require 'invar/rake/tasks'
185
+
186
+ Invar::Rake::Tasks.define namespace: 'app-name-here'
185
187
  ```
186
188
 
187
189
  Then you can use the rake tasks as reported by `rake -T`
@@ -272,6 +274,54 @@ puts invar[:config][:database][:host]
272
274
  >
273
275
  > **A**: Because key names could collide with method names, like `inspect`, `dup`, or `tap`.
274
276
 
277
+ ### Validation
278
+
279
+ You may provide `:configs_schema` and `:secrets_schema` keyword arguments to `Invar.new` and it will use those schema to
280
+ validate your `Invar::Reality`.
281
+
282
+ > **Note** Invar uses dry-schema to validate its internal `configs` and `secrets` trees, not the raw file contents.
283
+
284
+ `dry-schema` has a lot of shorthand which can add a lot of complexity, but here's a crash course:
285
+
286
+ * Keys are declared with the `required` method while the value's validator is declared to be a general `schema` type
287
+ with an accompanying block.
288
+ * In that block are the units of validation logic (*"predicates"*)
289
+ * They get unioned together with a **single** `&`
290
+ operator, **not** double `&&` like a boolean expression.
291
+ * There are predefined predicates for a bunch of simple properties
292
+
293
+ Generally when used with Invar it will look like:
294
+
295
+ ```ruby
296
+ require 'invar'
297
+
298
+ cnf_schema = Dry::Schema.define do
299
+ required(:upload).schema do
300
+ required(:mysql) { str? & filled? }
301
+ end
302
+
303
+ required(:upload).schema do
304
+ required(:max_bytes) { int? & filled? & gt?(0) }
305
+ required(:timeout) { int? & filled? & gt?(0) }
306
+ end
307
+ # ...
308
+ end
309
+
310
+ sec_schema = Dry::Schema.define do
311
+ required(:email).schema do
312
+ required(:username) { str? & filled? }
313
+ required(:password) { str? & filled? }
314
+ end
315
+ # ...
316
+ end
317
+
318
+ invar = Invar.new 'my-app', configs_schema: cnf_schema, secrets_schema: sec_schema
319
+ # ...
320
+ ```
321
+
322
+ If there are any unexpected or invalid keys in the *configs* or *secrets* files, Invar will complain about it with
323
+ a `SchemaValidationError`.
324
+
275
325
  ### Custom Locations
276
326
 
277
327
  You can customize the search paths by setting the environment variables `XDG_CONFIG_HOME` and/or `XDG_CONFIG_DIRS` any
@@ -280,6 +330,16 @@ time you run a Rake task or your application.
280
330
  # Looks in /tmp instead of ~/.config/
281
331
  XDG_CONFIG_HOME=/tmp bundle exec rake invar:paths
282
332
 
333
+ You can also specify the Lockbox decryption keyfile (eg. for automated production environments) by using the
334
+ `:decryption_keyfile` keyword argument to `Invar.new`. Be aware that your secrets are only as secure as the file that
335
+ keeps the master key, so double check that its file permissions are as restricted as possible.
336
+
337
+ ```ruby
338
+ require 'invar'
339
+
340
+ invar = Invar.new 'my-app', decryption_keyfile: '/etc/my-app/master_key'
341
+ ```
342
+
283
343
  ## Alternatives
284
344
 
285
345
  Some other gems with different approaches:
data/RELEASE_NOTES.md CHANGED
@@ -1,14 +1,20 @@
1
1
  # Release Notes
2
2
 
3
+ All notable changes to this project will be documented below.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project loosely follows
6
+ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
3
8
  ## [Unreleased]
4
9
 
5
10
  ### Major Changes
6
11
 
7
- * none
12
+ * Renamed `Invar::Invar` to `Invar::Reality`
13
+ * Maintenance Rake task inclusion now requires explicit define call
8
14
 
9
15
  ### Minor Changes
10
16
 
11
- * none
17
+ * Expanded docs
12
18
 
13
19
  ### Bugfixes
14
20
 
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'invar'
4
+
5
+ require 'rake'
6
+ require 'io/console'
7
+ require 'tempfile'
8
+
9
+ module Invar
10
+ # Rake task implementation.
11
+ #
12
+ # The actual rake tasks themselves are thinly defined in invar/rake.rb (so that the external include
13
+ # path is nice and short)
14
+ module Rake
15
+ # RakeTask builder class. Use Tasks.define to generate the needed tasks.
16
+ class Tasks
17
+ include ::Rake::Cloneable
18
+ include ::Rake::DSL
19
+
20
+ # Template config YAML file
21
+ CONFIG_TEMPLATE = SECRETS_TEMPLATE = <<~YML
22
+ ---
23
+ YML
24
+
25
+ # Shorthand for Invar::Rake::Tasks.new.define
26
+ #
27
+ # @param (see #define)
28
+ # @see Tasks#define
29
+ def self.define(**args, &block)
30
+ new.define(**args, &block)
31
+ end
32
+
33
+ # Defines helpful Rake tasks for the given namespace.
34
+ #
35
+ # @param namespace [String] The namespace to search for files within
36
+ def define(namespace: nil)
37
+ raise ArgumentError, ':namespace keyword argument cannot be nil' if namespace.nil?
38
+ raise ArgumentError, ':namespace keyword argument cannot be empty string' if namespace.empty?
39
+
40
+ define_all_tasks(namespace)
41
+ end
42
+
43
+ private
44
+
45
+ def define_all_tasks(app_namespace)
46
+ namespace :invar do
47
+ define_config_tasks(app_namespace)
48
+ define_secrets_tasks(app_namespace)
49
+
50
+ define_info_tasks(app_namespace)
51
+ end
52
+ end
53
+
54
+ def define_config_tasks(app_namespace)
55
+ namespace :configs do
56
+ desc 'Create a new configuration file'
57
+ task :create do
58
+ ::Invar::Rake::Tasks::ConfigTask.new(app_namespace).create
59
+ end
60
+
61
+ desc 'Edit the config in your default editor'
62
+ task :edit do
63
+ ::Invar::Rake::Tasks::ConfigTask.new(app_namespace).edit
64
+ end
65
+ end
66
+
67
+ # alias
68
+ namespace :config do
69
+ task create: ['configs:create']
70
+ task edit: ['configs:edit']
71
+ end
72
+ end
73
+
74
+ def define_secrets_tasks(app_namespace)
75
+ namespace :secrets do
76
+ desc 'Create a new encrypted secrets file'
77
+ task :create do
78
+ ::Invar::Rake::Tasks::SecretTask.new(app_namespace).create
79
+ end
80
+
81
+ desc 'Edit the encrypted secrets file in your default editor'
82
+ task :edit do
83
+ ::Invar::Rake::Tasks::SecretTask.new(app_namespace).edit
84
+ end
85
+ end
86
+
87
+ # alias
88
+ namespace :secret do
89
+ task create: ['secrets:create']
90
+ task edit: ['secrets:edit']
91
+ end
92
+ end
93
+
94
+ def define_info_tasks(app_namespace)
95
+ desc 'Show directories to be searched for the given namespace'
96
+ task :paths do
97
+ ::Invar::Rake::Tasks::StateTask.new(app_namespace).show_paths
98
+ end
99
+ end
100
+
101
+ # Tasks that use a namespace for file searching
102
+ class NamespacedTask
103
+ def initialize(namespace)
104
+ @locator = FileLocator.new(namespace)
105
+ end
106
+ end
107
+
108
+ # Configuration file actions.
109
+ class ConfigTask < NamespacedTask
110
+ # Creates a config file in the appropriate location
111
+ def create
112
+ config_dir = @locator.search_paths.first
113
+ config_dir.mkpath
114
+
115
+ file = config_dir / 'config.yml'
116
+ if file.exist?
117
+ warn <<~MSG
118
+ Abort: File exists. (#{ file })
119
+ Maybe you meant to edit the file with bundle exec rake invar:secrets:edit?
120
+ MSG
121
+ exit 1
122
+ end
123
+
124
+ file.write CONFIG_TEMPLATE
125
+
126
+ warn "Created file: #{ file }"
127
+ end
128
+
129
+ # Edits the existing config file in the appropriate location
130
+ def edit
131
+ configs_file = begin
132
+ @locator.find('config.yml')
133
+ rescue ::Invar::FileLocator::FileNotFoundError => e
134
+ warn <<~ERR
135
+ Abort: #{ e.message }. Searched in: #{ @locator.search_paths.join(', ') }
136
+ Maybe you used the wrong namespace or need to create the file with bundle exec rake invar:configs:create?
137
+ ERR
138
+ exit 1
139
+ end
140
+
141
+ system(ENV.fetch('EDITOR', 'editor'), configs_file.to_s, exception: true)
142
+
143
+ warn "File saved to: #{ configs_file }"
144
+ end
145
+ end
146
+
147
+ # Secrets file actions.
148
+ class SecretTask < NamespacedTask
149
+ # Instructions hint for how to handle secret keys.
150
+ SECRETS_INSTRUCTIONS = <<~INST
151
+ Save this key to a secure password manager. You will need it to edit the secrets.yml file.
152
+ INST
153
+
154
+ # Creates a new encrypted secrets file and prints the generated encryption key to STDOUT
155
+ def create
156
+ config_dir = @locator.search_paths.first
157
+ config_dir.mkpath
158
+
159
+ file = config_dir / 'secrets.yml'
160
+
161
+ if file.exist?
162
+ warn <<~ERR
163
+ Abort: File exists. (#{ file })
164
+ Maybe you meant to edit the file with bundle exec rake invar:secrets:edit?
165
+ ERR
166
+ exit 1
167
+ end
168
+
169
+ encryption_key = Lockbox.generate_key
170
+
171
+ write_encrypted_file(file, encryption_key, SECRETS_TEMPLATE)
172
+
173
+ warn "Created file #{ file }"
174
+
175
+ warn SECRETS_INSTRUCTIONS
176
+ warn 'Generated key is:'
177
+ puts encryption_key
178
+ end
179
+
180
+ # Opens an editor for the decrypted contents of the secrets file. After closing the editor, the file will be
181
+ # updated with the new encrypted contents.
182
+ def edit
183
+ secrets_file = begin
184
+ @locator.find('secrets.yml')
185
+ rescue ::Invar::FileLocator::FileNotFoundError => e
186
+ warn <<~ERR
187
+ Abort: #{ e.message }. Searched in: #{ @locator.search_paths.join(', ') }
188
+ Maybe you used the wrong namespace or need to create the file with bundle exec rake invar:secrets:create?
189
+ ERR
190
+ exit 1
191
+ end
192
+
193
+ edit_encrypted_file(secrets_file)
194
+
195
+ warn "File saved to #{ secrets_file }"
196
+ end
197
+
198
+ private
199
+
200
+ def write_encrypted_file(file_path, encryption_key, content)
201
+ lockbox = Lockbox.new(key: encryption_key)
202
+
203
+ encrypted_data = lockbox.encrypt(content)
204
+
205
+ # TODO: replace File.opens with photo_path.binwrite(uri.data) once FakeFS can handle it
206
+ File.open(file_path.to_s, 'wb') { |f| f.write encrypted_data }
207
+ end
208
+
209
+ def edit_encrypted_file(file_path)
210
+ encryption_key = determine_key(file_path)
211
+
212
+ lockbox = build_lockbox(encryption_key)
213
+
214
+ file_str = Tempfile.create(file_path.basename.to_s) do |tmp_file|
215
+ decrypted = lockbox.decrypt(file_path.binread)
216
+
217
+ tmp_file.write(decrypted)
218
+ tmp_file.rewind # rewind needed because file does not get closed after write
219
+ system(ENV.fetch('EDITOR', 'editor'), tmp_file.path, exception: true)
220
+ tmp_file.read
221
+ end
222
+
223
+ write_encrypted_file(file_path, encryption_key, file_str)
224
+ end
225
+
226
+ def determine_key(file_path)
227
+ encryption_key = Lockbox.master_key
228
+
229
+ if encryption_key.nil? && $stdin.respond_to?(:noecho)
230
+ warn "Enter master key to decrypt #{ file_path }:"
231
+ encryption_key = $stdin.noecho(&:gets).strip
232
+ end
233
+
234
+ encryption_key
235
+ end
236
+
237
+ def build_lockbox(encryption_key)
238
+ Lockbox.new(key: encryption_key)
239
+ rescue ArgumentError => e
240
+ raise SecretsFileEncryptionError, e
241
+ end
242
+ end
243
+
244
+ # General status tasks
245
+ class StateTask < NamespacedTask
246
+ # Prints the current paths to be searched in
247
+ def show_paths
248
+ warn @locator.search_paths.join("\n")
249
+ end
250
+ end
251
+ end
252
+ end
253
+ end
254
+
@@ -0,0 +1,290 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'lockbox'
5
+ require 'pathname'
6
+ require 'dry/schema'
7
+
8
+ require 'invar/file_locator'
9
+ require 'invar/scope'
10
+
11
+ # :nodoc:
12
+ module Invar
13
+ # Stores application config, secrets, and environmental variables. Environment variables come from ENV at the time of
14
+ # instantiation, while configs and secrets are loaded from respective files in the appropriate location. The secrets
15
+ # file is kept encrypted at rest.
16
+ #
17
+ # Fetch information from a Reality by using the slash operator or square brackets:
18
+ #
19
+ # invar = Invar::Reality.new(namespace: 'test-app')
20
+ #
21
+ # puts invar / :config / :database / :host
22
+ # puts invar[:config][:database][:host] # same as above
23
+ #
24
+ # puts invar / :secrets / :database / :user
25
+ #
26
+ # Note: `Invar.new` is a shorthand for Invar::Reality.new
27
+ #
28
+ # Information known by a Reality is immutable. You may use the `#pretend` method to simulate different values during
29
+ # testing.
30
+ #
31
+ # # In your tests, define an after_load hook
32
+ # require 'invar/test'
33
+ # Invar.after_load do |reality|
34
+ # reality[:config][:database][:host].pretend 'example.com'
35
+ # end
36
+ #
37
+ # # then later, in your app, it will use the pretend value
38
+ # invar = Invar.new namespace: 'my-app'
39
+ # puts invar / :config / :database / :host # prints example.com
40
+ #
41
+ # @see Invar.new
42
+ # @see Reality#after_load
43
+ # @see Reality#pretend
44
+ class Reality
45
+ # Allowed permissions modes for lockfile. Readable or read-writable by the current user only
46
+ ALLOWED_LOCKFILE_MODES = [0o600, 0o400].freeze
47
+
48
+ # Name of the default key file to be searched for within config directories
49
+ DEFAULT_KEY_FILE_NAME = 'master_key'
50
+
51
+ # Constructs a new Invar.
52
+ #
53
+ # It will search for config, secrets, and decryption key files using the XDG specification.
54
+ #
55
+ # The secrets file is decrypted using Lockbox. The key will be requested from these locations in order:
56
+ #
57
+ # 1. the Lockbox.master_key variable
58
+ # 2. the LOCKBOX_MASTER_KEY environment variable.
59
+ # 3. saved in a secure key file (recommended)
60
+ # 4. manual terminal prompt entry (recommended)
61
+ #
62
+ # The :decryption_keyfile argument specifies the filename to read for option 3. It will be searched for in
63
+ # the same XDG locations as the secrets file. The decryption keyfile will be checked for safe permission modes.
64
+ #
65
+ # NEVER hardcode your encryption key. This class intentionally does not accept a raw string of your decryption
66
+ # key to discourage hardcoding your encryption key and committing it to version control.
67
+ #
68
+ # @param [String] namespace name of the subdirectory within XDG locations
69
+ # @param [#read] decryption_keyfile Any #read capable object referring to the decryption key file
70
+ def initialize(namespace:, decryption_keyfile: nil, configs_schema: nil, secrets_schema: nil)
71
+ locator = FileLocator.new(namespace)
72
+ search_paths = locator.search_paths.join(', ')
73
+
74
+ begin
75
+ @configs = Scope.new(load_configs(locator))
76
+ rescue FileLocator::FileNotFoundError
77
+ raise MissingConfigFileError,
78
+ "No config file found. Create config.yml in one of these locations: #{ search_paths }"
79
+ end
80
+
81
+ begin
82
+ @secrets = Scope.new(load_secrets(locator, decryption_keyfile))
83
+ rescue FileLocator::FileNotFoundError
84
+ raise MissingSecretsFileError,
85
+ "No secrets file found. Create encrypted secrets.yml in one of these locations: #{ search_paths }"
86
+ end
87
+
88
+ freeze
89
+ # instance_eval(&self.class.__override_block__)
90
+ self.class.__override_block__&.call(self)
91
+
92
+ RealityValidator.new(configs_schema, secrets_schema).validate(@configs, @secrets)
93
+ end
94
+
95
+ class << self
96
+ attr_accessor :__override_block__
97
+ end
98
+
99
+ # Fetch from one of the two base scopes: :config or :secret.
100
+ # Plural names are also accepted (ie. :configs and :secrets).
101
+ #
102
+ # @param base_scope [Symbol, String]
103
+ def fetch(base_scope)
104
+ case base_scope
105
+ when /configs?/i
106
+ @configs
107
+ when /secrets?/i
108
+ @secrets
109
+ else
110
+ raise ArgumentError, 'The root scope name must be either :config or :secret.'
111
+ end
112
+ end
113
+
114
+ alias / fetch
115
+ alias [] fetch
116
+
117
+ private
118
+
119
+ def load_configs(locator)
120
+ file = locator.find('config', EXT::YAML)
121
+
122
+ configs = parse(file.read)
123
+ env_hash = ENV.to_hash.transform_keys(&:downcase).transform_keys(&:to_sym)
124
+
125
+ collision_key = configs.keys.collect(&:downcase).find { |key| env_hash.key? key }
126
+ if collision_key
127
+ hint = EnvConfigCollisionError::HINT
128
+ raise EnvConfigCollisionError,
129
+ "Both the environment and your config file have key #{ collision_key }. #{ hint }"
130
+ end
131
+
132
+ configs.merge(env_hash)
133
+ end
134
+
135
+ def load_secrets(locator, decryption_keyfile)
136
+ file = locator.find('secrets', EXT::YAML)
137
+
138
+ lockbox = begin
139
+ decryption_key = Lockbox.master_key || resolve_key(decryption_keyfile, locator,
140
+ "Enter master key to decrypt #{ file }:")
141
+
142
+ Lockbox.new(key: decryption_key)
143
+ rescue Lockbox::Error => e
144
+ raise SecretsFileDecryptionError, e
145
+ end
146
+
147
+ bytes = file.binread
148
+ file_data = begin
149
+ lockbox.decrypt bytes
150
+ rescue Lockbox::DecryptionError => e
151
+ hint = 'Perhaps you used the wrong file decryption key?'
152
+ raise SecretsFileDecryptionError, "Failed to open #{ file } (#{ e }). #{ hint }"
153
+ end
154
+
155
+ parse(file_data)
156
+ end
157
+
158
+ def parse(file_data)
159
+ YAML.safe_load(file_data, symbolize_names: true) || {}
160
+ end
161
+
162
+ def resolve_key(pathname, locator, prompt)
163
+ key_file = locator.find(pathname || DEFAULT_KEY_FILE_NAME)
164
+
165
+ read_keyfile(key_file)
166
+ rescue FileLocator::FileNotFoundError
167
+ if $stdin.respond_to?(:noecho)
168
+ warn prompt
169
+ $stdin.noecho(&:gets).strip
170
+ else
171
+ raise SecretsFileDecryptionError,
172
+ "Could not find file '#{ pathname }'. Searched in: #{ locator.search_paths }"
173
+ end
174
+ end
175
+
176
+ def read_keyfile(key_file)
177
+ permissions_mask = 0o777 # only the lowest three digits are perms, so masking
178
+ stat = key_file.stat
179
+ file_mode = stat.mode & permissions_mask
180
+ # TODO: use stat.world_readable? etc instead
181
+ unless ALLOWED_LOCKFILE_MODES.include? file_mode
182
+ hint = "Try: chmod 600 #{ key_file }"
183
+ raise SecretsFileDecryptionError,
184
+ format("File '%<path>s' has improper permissions (%<mode>04o). %<hint>s",
185
+ path: key_file,
186
+ mode: file_mode,
187
+ hint: hint)
188
+ end
189
+
190
+ key_file.read.strip
191
+ end
192
+
193
+ # Validates a Reality object
194
+ class RealityValidator
195
+ def initialize(configs_schema, secrets_schema)
196
+ configs_schema ||= Dry::Schema.define
197
+ env_schema = build_env_schema
198
+
199
+ @schema = Dry::Schema.define do
200
+ config.validate_keys = true
201
+
202
+ required(:configs).hash(configs_schema & env_schema)
203
+
204
+ if secrets_schema
205
+ required(:secrets).hash(secrets_schema)
206
+ else
207
+ required(:secrets)
208
+ end
209
+ end
210
+ end
211
+
212
+ def validate(configs, secrets)
213
+ validation = @schema.call(configs: configs.to_h,
214
+ secrets: secrets.to_h)
215
+
216
+ return true if validation.success?
217
+
218
+ errs = validation.errors.messages.collect do |message|
219
+ [message.path.collect do |p|
220
+ ":#{ p }"
221
+ end.join(' / '), message.text].join(' ')
222
+ end
223
+
224
+ raise SchemaValidationError, <<~ERR
225
+ Validation errors:
226
+ #{ errs.join("\n ") }
227
+ ERR
228
+ end
229
+
230
+ private
231
+
232
+ # Special schema for just the env variables, listing them explicitly allows for using validate_keys
233
+ def build_env_schema
234
+ env_keys = ENV.to_hash.transform_keys(&:downcase).transform_keys(&:to_sym).keys
235
+ Dry::Schema.define do
236
+ env_keys.each do |key|
237
+ optional(key)
238
+ end
239
+ end
240
+ end
241
+ end
242
+ end
243
+
244
+ # Raised when no config file can be found within the search paths.
245
+ class MissingConfigFileError < RuntimeError
246
+ end
247
+
248
+ # Raised when no secrets file can be found within the search paths.
249
+ class MissingSecretsFileError < RuntimeError
250
+ end
251
+
252
+ # Raised when an error is encountered during secrets file encryption
253
+ class SecretsFileEncryptionError < RuntimeError
254
+ end
255
+
256
+ # Raised when an error is encountered during secrets file decryption
257
+ class SecretsFileDecryptionError < RuntimeError
258
+ end
259
+
260
+ # Raised when a key is defined in both the environment and the configuration file.
261
+ class EnvConfigCollisionError < RuntimeError
262
+ # Message hinting at possible solution
263
+ HINT = 'Either rename your config entry or remove the environment variable.'
264
+ end
265
+
266
+ # Raised when there are config or secrets files found at multiple locations. You can resolve this by deciding on
267
+ # one correct location and removing the alternate file(s).
268
+ class AmbiguousSourceError < RuntimeError
269
+ # Message hinting at possible solution
270
+ HINT = 'Choose 1 correct one and delete the others.'
271
+ end
272
+
273
+ # Raised when #pretend is called but the testing extension has not been loaded.
274
+ #
275
+ # When raised during normal operation, it may mean the application is calling #pretend directly, which is strongly
276
+ # discouraged. The feature is meant for testing.
277
+ #
278
+ # @see Invar#pretend
279
+ class ImmutableRealityError < NoMethodError
280
+ # Message and hint for a possible solution
281
+ MSG = <<~MSG
282
+ Method 'pretend' is defined in the testing extension. Try adding this to your test suite config file:
283
+ require 'invar/test'
284
+ MSG
285
+ end
286
+
287
+ # Raised when schema validation fails
288
+ class SchemaValidationError < RuntimeError
289
+ end
290
+ end
data/lib/invar/scope.rb CHANGED
@@ -30,6 +30,9 @@ module Invar
30
30
  raise ::Invar::ImmutableRealityError, ::Invar::ImmutableRealityError::MSG
31
31
  end
32
32
 
33
+ # Returns a hash representation of this scope and subscopes.
34
+ #
35
+ # @return [Hash] a hash representation of this scope
33
36
  def to_h
34
37
  @data.merge(@data_override).to_h.transform_values do |value|
35
38
  case value
data/lib/invar/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Invar
4
4
  # Current version of the gem
5
- VERSION = '0.4.0'
5
+ VERSION = '0.5.0'
6
6
  end
data/lib/invar.rb CHANGED
@@ -1,216 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'invar/version'
4
- require 'invar/file_locator'
5
- require 'invar/scope'
4
+ require 'invar/reality'
6
5
 
7
- require 'yaml'
8
- require 'lockbox'
9
- require 'pathname'
10
- require 'dry/schema'
11
-
12
- # Invar is a Ruby Gem that provides a single source of truth for application configuration and environment.
6
+ # Invar is a Ruby Gem that provides a single source of truth for application configuration, secrets, and environment
7
+ # variables.
13
8
  module Invar
14
- # Alias for Invar::Invar.new
9
+ # Alias for Invar::Reality.new
15
10
  #
16
- # @see Invar.new
11
+ # @see Invar::Reality.new
17
12
  def self.new(**args)
18
- Invar.new(**args)
19
- end
20
-
21
- # A wrapper for config and ENV variable data. It endeavours to limit your situation to a single source of truth.
22
- class Invar
23
- # Allowed permissions modes for lockfile. Readable or read-writable by the current user only
24
- ALLOWED_LOCKFILE_MODES = [0o600, 0o400].freeze
25
-
26
- # Name of the default key file to be searched for within config directories
27
- DEFAULT_KEY_FILE_NAME = 'master_key'
28
-
29
- # Constructs a new Invar.
30
- #
31
- # It will search for config, secrets, and decryption key files using the XDG specification.
32
- #
33
- # The secrets file is decrypted using Lockbox. The key will be requested from these locations in order:
34
- #
35
- # 1. the Lockbox.master_key variable
36
- # 2. the LOCKBOX_MASTER_KEY environment variable.
37
- # 3. saved in a secure key file (recommended)
38
- # 4. manual terminal prompt entry (recommended)
39
- #
40
- # The :decryption_keyfile argument specifies the filename to read for option 3. It will be searched for in
41
- # the same XDG locations as the secrets file. The decryption keyfile will be checked for safe permission modes.
42
- #
43
- # NEVER hardcode your encryption key. This class intentionally does not accept a raw string of your decryption
44
- # key to discourage hardcoding your encryption key and committing it to version control.
45
- #
46
- # @param [String] namespace name of the subdirectory within XDG locations
47
- # @param [#read] decryption_keyfile Any #read capable object referring to the decryption key file
48
- def initialize(namespace:, decryption_keyfile: nil, configs_schema: nil, secrets_schema: nil)
49
- locator = FileLocator.new(namespace)
50
- search_paths = locator.search_paths.join(', ')
51
-
52
- begin
53
- @configs = Scope.new(load_configs(locator))
54
- rescue FileLocator::FileNotFoundError
55
- raise MissingConfigFileError,
56
- "No config file found. Create config.yml in one of these locations: #{ search_paths }"
57
- end
58
-
59
- begin
60
- @secrets = Scope.new(load_secrets(locator, decryption_keyfile))
61
- rescue FileLocator::FileNotFoundError
62
- raise MissingSecretsFileError,
63
- "No secrets file found. Create encrypted secrets.yml in one of these locations: #{ search_paths }"
64
- end
65
-
66
- freeze
67
- # instance_eval(&self.class.__override_block__)
68
- self.class.__override_block__&.call(self)
69
-
70
- validate_with configs_schema, secrets_schema
71
- end
72
-
73
- class << self
74
- attr_accessor :__override_block__
75
- end
76
-
77
- # Fetch from one of the two base scopes: :config or :secret.
78
- # Plural names are also accepted (ie. :configs and :secrets).
79
- #
80
- # @param base_scope [Symbol, String]
81
- def fetch(base_scope)
82
- case base_scope
83
- when /configs?/i
84
- @configs
85
- when /secrets?/i
86
- @secrets
87
- else
88
- raise ArgumentError, 'The root scope name must be either :config or :secret.'
89
- end
90
- end
91
-
92
- alias / fetch
93
- alias [] fetch
94
-
95
- private
96
-
97
- def load_configs(locator)
98
- file = locator.find('config', EXT::YAML)
99
-
100
- configs = parse(file.read)
101
-
102
- collision_key = configs.keys.collect(&:downcase).find { |key| env_hash.key? key }
103
- if collision_key
104
- hint = EnvConfigCollisionError::HINT
105
- raise EnvConfigCollisionError,
106
- "Both the environment and your config file have key #{ collision_key }. #{ hint }"
107
- end
108
-
109
- configs.merge(env_hash)
110
- end
111
-
112
- def env_hash
113
- ENV.to_hash.transform_keys(&:downcase).transform_keys(&:to_sym)
114
- end
115
-
116
- def load_secrets(locator, decryption_keyfile)
117
- file = locator.find('secrets', EXT::YAML)
118
-
119
- lockbox = begin
120
- decryption_key = Lockbox.master_key || resolve_key(decryption_keyfile, locator,
121
- "Enter master key to decrypt #{ file }:")
122
-
123
- Lockbox.new(key: decryption_key)
124
- rescue Lockbox::Error => e
125
- raise SecretsFileDecryptionError, e
126
- end
127
-
128
- bytes = file.binread
129
- file_data = begin
130
- lockbox.decrypt bytes
131
- rescue Lockbox::DecryptionError => e
132
- hint = 'Perhaps you used the wrong file decryption key?'
133
- raise SecretsFileDecryptionError, "Failed to open #{ file } (#{ e }). #{ hint }"
134
- end
135
-
136
- parse(file_data)
137
- end
138
-
139
- def parse(file_data)
140
- YAML.safe_load(file_data, symbolize_names: true) || {}
141
- end
142
-
143
- def resolve_key(pathname, locator, prompt)
144
- key_file = locator.find(pathname || DEFAULT_KEY_FILE_NAME)
145
-
146
- read_keyfile(key_file)
147
- rescue FileLocator::FileNotFoundError
148
- if $stdin.respond_to?(:noecho)
149
- warn prompt
150
- $stdin.noecho(&:gets).strip
151
- else
152
- raise SecretsFileDecryptionError,
153
- "Could not find file '#{ pathname }'. Searched in: #{ locator.search_paths }"
154
- end
155
- end
156
-
157
- def read_keyfile(key_file)
158
- permissions_mask = 0o777 # only the lowest three digits are perms, so masking
159
- stat = key_file.stat
160
- file_mode = stat.mode & permissions_mask
161
- # TODO: use stat.world_readable? etc instead
162
- unless ALLOWED_LOCKFILE_MODES.include? file_mode
163
- hint = "Try: chmod 600 #{ key_file }"
164
- raise SecretsFileDecryptionError,
165
- format("File '%<path>s' has improper permissions (%<mode>04o). %<hint>s",
166
- path: key_file,
167
- mode: file_mode,
168
- hint: hint)
169
- end
170
-
171
- key_file.read.strip
172
- end
173
-
174
- def validate_with(configs_schema, secrets_schema)
175
- env_keys = env_hash.keys
176
-
177
- configs_schema ||= Dry::Schema.define
178
-
179
- # Special schema for just the env variables, listing them explicitly allows for using validate_keys
180
- env_schema = Dry::Schema.define do
181
- env_keys.each do |key|
182
- optional(key)
183
- end
184
- end
185
-
186
- schema = Dry::Schema.define do
187
- config.validate_keys = true
188
-
189
- required(:configs).hash(configs_schema & env_schema)
190
-
191
- if secrets_schema
192
- required(:secrets).hash(secrets_schema)
193
- else
194
- required(:secrets)
195
- end
196
- end
197
-
198
- validation = schema.call(configs: @configs.to_h,
199
- secrets: @secrets.to_h)
200
-
201
- return true if validation.success?
202
-
203
- errs = validation.errors.messages.collect do |message|
204
- [message.path.collect do |p|
205
- ":#{ p }"
206
- end.join(' / '), message.text].join(' ')
207
- end
208
-
209
- raise SchemaValidationError, <<~ERR
210
- Validation errors:
211
- #{ errs.join("\n ") }
212
- ERR
213
- end
13
+ ::Invar::Reality.new(**args)
214
14
  end
215
15
 
216
16
  class << self
@@ -220,54 +20,7 @@ module Invar
220
20
  # @yieldparam the configs from the Invar
221
21
  # @return [void]
222
22
  def after_load(&block)
223
- ::Invar::Invar.__override_block__ = block
23
+ ::Invar::Reality.__override_block__ = block
224
24
  end
225
25
  end
226
-
227
- # Raised when no config file can be found within the search paths.
228
- class MissingConfigFileError < RuntimeError
229
- end
230
-
231
- # Raised when no secrets file can be found within the search paths.
232
- class MissingSecretsFileError < RuntimeError
233
- end
234
-
235
- # Raised when an error is encountered during secrets file encryption
236
- class SecretsFileEncryptionError < RuntimeError
237
- end
238
-
239
- # Raised when an error is encountered during secrets file decryption
240
- class SecretsFileDecryptionError < RuntimeError
241
- end
242
-
243
- # Raised when a key is defined in both the environment and the configuration file.
244
- class EnvConfigCollisionError < RuntimeError
245
- # Message hinting at possible solution
246
- HINT = 'Either rename your config entry or remove the environment variable.'
247
- end
248
-
249
- # Raised when there are config or secrets files found at multiple locations. You can resolve this by deciding on
250
- # one correct location and removing the alternate file(s).
251
- class AmbiguousSourceError < RuntimeError
252
- # Message hinting at possible solution
253
- HINT = 'Choose 1 correct one and delete the others.'
254
- end
255
-
256
- # Raised when #pretend is called but the testing extension has not been loaded.
257
- #
258
- # When raised during normal operation, it may mean the application is calling #pretend directly, which is strongly
259
- # discouraged. The feature is meant for testing.
260
- #
261
- # @see Invar#pretend
262
- class ImmutableRealityError < NoMethodError
263
- # Message and hint for a possible solution
264
- MSG = <<~MSG
265
- Method 'pretend' is defined in the testing extension. Try adding this to your test suite config file:
266
- require 'invar/test'
267
- MSG
268
- end
269
-
270
- # Raised when schema validation fails
271
- class SchemaValidationError < RuntimeError
272
- end
273
26
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: invar
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Robin Miller
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2022-12-08 00:00:00.000000000 Z
11
+ date: 2022-12-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: dry-schema
@@ -144,8 +144,8 @@ files:
144
144
  - invar.gemspec
145
145
  - lib/invar.rb
146
146
  - lib/invar/file_locator.rb
147
- - lib/invar/rake.rb
148
- - lib/invar/rake_tasks.rb
147
+ - lib/invar/rake/tasks.rb
148
+ - lib/invar/reality.rb
149
149
  - lib/invar/scope.rb
150
150
  - lib/invar/test.rb
151
151
  - lib/invar/version.rb
data/lib/invar/rake.rb DELETED
@@ -1,47 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'rake'
4
- require 'io/console'
5
- require 'tempfile'
6
- require_relative 'rake_tasks'
7
-
8
- namespace :invar do
9
- namespace :configs do
10
- desc 'Create a new configuration file'
11
- task :create, [:namespace] do |task, args|
12
- Invar::RakeTasks::ConfigTask.new(args[:namespace], task).create
13
- end
14
-
15
- desc 'Edit the config in your default editor'
16
- task :edit, [:namespace] do |task, args|
17
- Invar::RakeTasks::ConfigTask.new(args[:namespace], task).edit
18
- end
19
- end
20
-
21
- namespace :secrets do
22
- desc 'Create a new encrypted secrets file'
23
- task :create, [:namespace] do |task, args|
24
- Invar::RakeTasks::SecretTask.new(args[:namespace], task).create
25
- end
26
-
27
- desc 'Edit the encrypted secrets file in your default editor'
28
- task :edit, [:namespace] do |task, args|
29
- Invar::RakeTasks::SecretTask.new(args[:namespace], task).edit
30
- end
31
- end
32
-
33
- # aliasing
34
- namespace :config do
35
- task :create, [:namespace] => ['configs:create']
36
- task :edit, [:namespace] => ['configs:edit']
37
- end
38
- namespace :secret do
39
- task :create, [:namespace] => ['secrets:create']
40
- task :edit, [:namespace] => ['secrets:edit']
41
- end
42
-
43
- desc 'Show directories to be searched for the given namespace'
44
- task :paths, [:namespace] do |task, args|
45
- Invar::RakeTasks::StateTask.new(args[:namespace], task).show_paths
46
- end
47
- end
@@ -1,178 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'invar'
4
-
5
- module Invar
6
- # Rake task implementation.
7
- #
8
- # The actual rake tasks themselves are thinly defined in invar/rake.rb (so that the external include
9
- # path is nice and short)
10
- module RakeTasks
11
- # Template config YAML file
12
- CONFIG_TEMPLATE = SECRETS_TEMPLATE = <<~YML
13
- ---
14
- YML
15
-
16
- # Tasks that use a namespace
17
- class NamespacedTask
18
- def initialize(namespace, task)
19
- if namespace.nil?
20
- raise NamespaceMissingError,
21
- "Namespace argument required. Run with: bundle exec rake #{ task.name }[namespace_here]"
22
- end
23
-
24
- @namespace = namespace
25
- @locator = FileLocator.new(@namespace)
26
- end
27
- end
28
-
29
- # Configuration file actions.
30
- class ConfigTask < NamespacedTask
31
- # Creates a config file in the appropriate location
32
- def create
33
- config_dir = @locator.search_paths.first
34
- config_dir.mkpath
35
-
36
- file = config_dir / 'config.yml'
37
- if file.exist?
38
- warn <<~MSG
39
- Abort: File exists. (#{ file })
40
- Maybe you meant to edit the file with bundle exec rake invar:secrets:edit[#{ @namespace }]?
41
- MSG
42
- exit 1
43
- end
44
-
45
- file.write CONFIG_TEMPLATE
46
-
47
- warn "Created file: #{ file }"
48
- end
49
-
50
- # Edits the existing config file in the appropriate location
51
- def edit
52
- configs_file = begin
53
- @locator.find('config.yml')
54
- rescue ::Invar::FileLocator::FileNotFoundError => e
55
- warn <<~ERR
56
- Abort: #{ e.message }. Searched in: #{ @locator.search_paths.join(', ') }
57
- Maybe you used the wrong namespace or need to create the file with bundle exec rake invar:configs:create?
58
- ERR
59
- exit 1
60
- end
61
-
62
- system(ENV.fetch('EDITOR', 'editor'), configs_file.to_s, exception: true)
63
-
64
- warn "File saved to: #{ configs_file }"
65
- end
66
- end
67
-
68
- # Secrets file actions.
69
- class SecretTask < NamespacedTask
70
- # Instructions hint for how to handle secret keys.
71
- SECRETS_INSTRUCTIONS = <<~INST
72
- Save this key to a secure password manager. You will need it to edit the secrets.yml file.
73
- INST
74
-
75
- # Creates a new encrypted secrets file and prints the generated encryption key to STDOUT
76
- def create
77
- config_dir = ::Invar::FileLocator.new(@namespace).search_paths.first
78
- config_dir.mkpath
79
-
80
- file = config_dir / 'secrets.yml'
81
-
82
- if file.exist?
83
- warn <<~ERR
84
- Abort: File exists. (#{ file })
85
- Maybe you meant to edit the file with bundle exec rake invar:secrets:edit[#{ @namespace }]?
86
- ERR
87
- exit 1
88
- end
89
-
90
- encryption_key = Lockbox.generate_key
91
-
92
- write_encrypted_file(file, encryption_key, SECRETS_TEMPLATE)
93
-
94
- warn "Created file #{ file }"
95
-
96
- warn SECRETS_INSTRUCTIONS
97
- warn 'Generated key is:'
98
- puts encryption_key
99
- end
100
-
101
- # Opens an editor for the decrypted contents of the secrets file. After closing the editor, the file will be
102
- # updated with the new encrypted contents.
103
- def edit
104
- secrets_file = begin
105
- @locator.find('secrets.yml')
106
- rescue ::Invar::FileLocator::FileNotFoundError => e
107
- warn <<~ERR
108
- Abort: #{ e.message }. Searched in: #{ @locator.search_paths.join(', ') }
109
- Maybe you used the wrong namespace or need to create the file with bundle exec rake invar:secrets:create?
110
- ERR
111
- exit 1
112
- end
113
-
114
- edit_encrypted_file(secrets_file)
115
-
116
- warn "File saved to #{ secrets_file }"
117
- end
118
-
119
- private
120
-
121
- def write_encrypted_file(file_path, encryption_key, content)
122
- lockbox = Lockbox.new(key: encryption_key)
123
-
124
- encrypted_data = lockbox.encrypt(content)
125
-
126
- # TODO: replace File.opens with photo_path.binwrite(uri.data) once FakeFS can handle it
127
- File.open(file_path.to_s, 'wb') { |f| f.write encrypted_data }
128
- end
129
-
130
- def edit_encrypted_file(file_path)
131
- encryption_key = determine_key(file_path)
132
-
133
- lockbox = build_lockbox(encryption_key)
134
-
135
- file_str = Tempfile.create(file_path.basename.to_s) do |tmp_file|
136
- decrypted = lockbox.decrypt(file_path.binread)
137
-
138
- tmp_file.write(decrypted)
139
- tmp_file.rewind # rewind needed because file does not get closed after write
140
- system(ENV.fetch('EDITOR', 'editor'), tmp_file.path, exception: true)
141
- tmp_file.read
142
- end
143
-
144
- write_encrypted_file(file_path, encryption_key, file_str)
145
- end
146
-
147
- def determine_key(file_path)
148
- encryption_key = Lockbox.master_key
149
-
150
- if encryption_key.nil? && $stdin.respond_to?(:noecho)
151
- warn "Enter master key to decrypt #{ file_path }:"
152
- encryption_key = $stdin.noecho(&:gets).strip
153
- end
154
-
155
- encryption_key
156
- end
157
-
158
- def build_lockbox(encryption_key)
159
- Lockbox.new(key: encryption_key)
160
- rescue ArgumentError => e
161
- raise SecretsFileEncryptionError, e
162
- end
163
- end
164
-
165
- # General status tasks
166
- class StateTask < NamespacedTask
167
- # Prints the current paths to be searched in
168
- def show_paths
169
- warn @locator.search_paths.join("\n")
170
- end
171
- end
172
-
173
- # Raised when the namespace Rake task parameter is missing. Add it in square brackets after the task name when
174
- # running Rake.
175
- class NamespaceMissingError < RuntimeError
176
- end
177
- end
178
- end