legion-settings 1.2.2 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7e6bfc5465268327a07174136c9b4cc7eb8793345bc43b61bbefbf99fdf2406a
4
- data.tar.gz: 9e6b1384a02334a6aecbe50474f184bfb7996d16efe36d9b80744a4feb6171bc
3
+ metadata.gz: cb0e2c44578e9e5b6cbb495c5160e1a37c067ea5692979e50e89494d33da9888
4
+ data.tar.gz: ebca4fdb1fb58e2fa315620209ccbe5f5302057a7dc0d4a256f9689ea8d5d8b0
5
5
  SHA512:
6
- metadata.gz: c8d084a1a0b75578ada3bb4f765bf7948ed9f9969e03717d10bf357d82895d1717b7e634e5f7ce8385bc3f1f4734e7e8e0d00a24ad4596a04f72f950c1ec84ec
7
- data.tar.gz: 6f2ed8e7febb6d6a9f9e36b3d86a5522230f7254bf46aaa4e966c9d60b91f71f80a8a2e46de9510f77bedbda53d759426d087c71fdecbb15c2ef2c1efdb08298
6
+ metadata.gz: 738f2ced6bf58fcb2d9ad4fca307d63abea4241bd6c5e788ff3ee47346f8f1054fd1cceb94d86e2f393f7985a0a835076513b94de730a89e9e233d923d5c1775
7
+ data.tar.gz: ac60c369c84bd0dcb53b84f5fcdf9440fcd25ea00d15bb32386b67a4b999ceead7dde040fa66de53c9e6b06a5e99628b674923d0503dcbbe945a5b30a7d88bb6
data/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Legion::Settings Changelog
2
2
 
3
+ ## [1.3.1] - 2026-03-16
4
+
5
+ ### Added
6
+ - `lease://name#key` URI scheme in secret resolver for dynamic Vault leases
7
+ - Delegates to `Legion::Crypt::LeaseManager` for lease data lookup
8
+ - Registers reverse references for push-back on credential rotation
9
+
10
+ ## [1.3.0] - 2026-03-16
11
+
12
+ ### Added
13
+ - Universal secret resolver: `vault://` and `env://` URI references in any settings value
14
+ - Fallback chain support via arrays (first non-nil wins)
15
+ - `Legion::Settings.resolve_secrets!` method for explicit resolution phase
16
+ - Vault read caching within a single resolution pass
17
+
3
18
  ## [1.2.2] - 2026-03-16
4
19
 
5
20
  ### Added
data/CLAUDE.md CHANGED
@@ -68,6 +68,7 @@ Legion::Settings (singleton module)
68
68
  | `lib/legion/settings/schema.rb` | Type inference, validation logic, unknown key detection (Levenshtein) |
69
69
  | `lib/legion/settings/validation_error.rb` | Error collection and formatted reporting |
70
70
  | `lib/legion/settings/os.rb` | OS detection helpers |
71
+ | `lib/legion/settings/resolver.rb` | Secret resolution: `vault://` and `env://` URI references, fallback chains |
71
72
  | `lib/legion/settings/version.rb` | VERSION constant |
72
73
  | `spec/legion/settings_spec.rb` | Core settings module tests |
73
74
  | `spec/legion/settings_module_spec.rb` | Module-level accessor and merge tests |
@@ -76,6 +77,45 @@ Legion::Settings (singleton module)
76
77
  | `spec/legion/settings/validation_error_spec.rb` | Error formatting tests |
77
78
  | `spec/legion/settings/integration_spec.rb` | End-to-end validation tests |
78
79
  | `spec/legion/settings/role_defaults_spec.rb` | Role profile default settings tests |
80
+ | `spec/legion/settings/resolver_spec.rb` | Secret resolver tests (env://, vault://, lease://, fallback chains) |
81
+
82
+ ## Secret Resolution
83
+
84
+ Settings values can reference external secret sources using URI syntax. Resolved in-place via `Legion::Settings.resolve_secrets!` (called automatically after `Legion::Crypt.start` in the boot sequence).
85
+
86
+ ### URI Schemes
87
+
88
+ | Scheme | Format | Resolution |
89
+ |--------|--------|------------|
90
+ | `vault://` | `vault://path/to/secret#key` | `Legion::Crypt.read(path)[key]` (static KV secrets) |
91
+ | `env://` | `env://ENV_VAR_NAME` | `ENV['ENV_VAR_NAME']` |
92
+ | `lease://` | `lease://name#key` | `Legion::Crypt::LeaseManager.instance.fetch(name, key)` (dynamic Vault leases) |
93
+ | *(plain string)* | `"guest"` | Returned as-is |
94
+
95
+ ### Fallback Chains
96
+
97
+ Array values are tried in order — first non-nil wins:
98
+
99
+ ```json
100
+ {
101
+ "transport": {
102
+ "connection": {
103
+ "password": ["vault://secret/data/rabbitmq#password", "env://RABBITMQ_PASSWORD", "guest"]
104
+ }
105
+ }
106
+ }
107
+ ```
108
+
109
+ ### Logging Strategy
110
+
111
+ - Vault not connected + vault refs exist: one summary warning with count
112
+ - Individual vault path failures: debug level
113
+ - Entire chain resolves to nil: one warning per key path
114
+ - Success: info summary with resolved counts
115
+
116
+ ### Implementation
117
+
118
+ `Legion::Settings::Resolver` module with `module_function`. Called via `Legion::Settings.resolve_secrets!` which delegates to `Resolver.resolve_secrets!(@loader.to_hash)`. Vault reads are cached by path within a single resolution pass.
79
119
 
80
120
  ## Role in LegionIO
81
121
 
data/README.md CHANGED
@@ -28,11 +28,40 @@ Legion::Settings[:transport][:connection][:host]
28
28
  ### Config Paths (checked in order)
29
29
 
30
30
  1. `/etc/legionio/`
31
- 2. `~/legionio/`
32
- 3. `./settings/`
31
+ 2. `~/.legionio/settings/`
32
+ 3. `~/legionio/`
33
+ 4. `./settings/`
33
34
 
34
35
  Each Legion module registers its own defaults via `merge_settings` during startup.
35
36
 
37
+ ### Secret Resolution
38
+
39
+ Settings values can reference external secret sources using URI syntax. Two schemes are supported:
40
+
41
+ | Scheme | Format | Resolution |
42
+ |--------|--------|------------|
43
+ | `vault://` | `vault://path/to/secret#key` | Reads from HashiCorp Vault via `Legion::Crypt` |
44
+ | `env://` | `env://ENV_VAR_NAME` | Reads from environment variable |
45
+
46
+ Array values act as fallback chains — the first non-nil result wins:
47
+
48
+ ```json
49
+ {
50
+ "transport": {
51
+ "connection": {
52
+ "password": ["vault://secret/data/rabbitmq#password", "env://RABBITMQ_PASSWORD", "guest"]
53
+ }
54
+ }
55
+ }
56
+ ```
57
+
58
+ Call `Legion::Settings.resolve_secrets!` to resolve all URIs in-place. In the LegionIO boot sequence this is called automatically after `Legion::Crypt.start`. The `env://` scheme works even when Vault is not connected.
59
+
60
+ ```ruby
61
+ Legion::Settings.resolve_secrets!
62
+ # All vault:// and env:// references are now replaced with their resolved values
63
+ ```
64
+
36
65
  ### Schema Validation
37
66
 
38
67
  Types are inferred automatically from default values. Optional constraints can be added:
@@ -0,0 +1,235 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Settings
5
+ module Resolver
6
+ VAULT_PATTERN = %r{\Avault://(.+?)#(.+)\z}
7
+ ENV_PATTERN = %r{\Aenv://(.+)\z}
8
+ LEASE_PATTERN = %r{\Alease://(.+?)#(.+)\z}
9
+ URI_PATTERN = %r{\A(?:vault|env|lease)://}
10
+
11
+ module_function
12
+
13
+ def resolve_secrets!(settings_hash)
14
+ return settings_hash unless settings_hash.is_a?(Hash)
15
+
16
+ @vault_available = vault_connected?
17
+ @vault_cache = {}
18
+
19
+ vault_count = count_vault_refs(settings_hash)
20
+ log_warn("Vault not connected — #{vault_count} vault:// reference(s) will not be resolved") if vault_count.positive? && !@vault_available
21
+
22
+ lease_count = count_lease_refs(settings_hash)
23
+ log_warn("LeaseManager not available — #{lease_count} lease:// reference(s) will not be resolved") if lease_count.positive? && !lease_manager_available?
24
+
25
+ resolved = 0
26
+ unresolved = 0
27
+ walk(settings_hash, path: '') do |result|
28
+ if result == :resolved
29
+ resolved += 1
30
+ elsif result == :unresolved
31
+ unresolved += 1
32
+ end
33
+ end
34
+
35
+ log_info("Settings resolver: #{resolved} resolved, #{unresolved} unresolved") if resolved.positive? || unresolved.positive?
36
+
37
+ settings_hash
38
+ end
39
+
40
+ def resolve_value(value)
41
+ case value
42
+ when String
43
+ return value unless value.match?(URI_PATTERN)
44
+
45
+ resolve_single(value)
46
+ when Array
47
+ return value unless resolvable_chain?(value)
48
+
49
+ resolve_chain(value)
50
+ else
51
+ value
52
+ end
53
+ end
54
+
55
+ def resolve_single(str)
56
+ if (m = str.match(VAULT_PATTERN))
57
+ resolve_vault(m[1], m[2])
58
+ elsif (m = str.match(LEASE_PATTERN))
59
+ resolve_lease(m[1], m[2])
60
+ elsif (m = str.match(ENV_PATTERN))
61
+ ENV.fetch(m[1], nil)
62
+ else
63
+ str
64
+ end
65
+ end
66
+
67
+ def resolve_chain(arr)
68
+ arr.each do |entry|
69
+ result = if entry.is_a?(String) && entry.match?(URI_PATTERN)
70
+ resolve_single(entry)
71
+ else
72
+ entry
73
+ end
74
+ return result unless result.nil?
75
+ end
76
+ nil
77
+ end
78
+
79
+ def has_vault_refs?(hash) # rubocop:disable Naming/PredicatePrefix
80
+ count_vault_refs(hash).positive?
81
+ end
82
+
83
+ def count_vault_refs(hash)
84
+ return 0 unless hash.is_a?(Hash)
85
+
86
+ hash.sum do |_key, value|
87
+ case value
88
+ when String then value.match?(VAULT_PATTERN) ? 1 : 0
89
+ when Array then value.count { |v| v.is_a?(String) && v.match?(VAULT_PATTERN) }
90
+ when Hash then count_vault_refs(value)
91
+ else 0
92
+ end
93
+ end
94
+ end
95
+
96
+ def vault_connected?
97
+ return false unless defined?(Legion::Crypt)
98
+ return false unless defined?(Legion::Settings)
99
+
100
+ Legion::Settings[:crypt][:vault][:connected] == true
101
+ rescue StandardError
102
+ false
103
+ end
104
+
105
+ def walk(hash, path:, &block)
106
+ hash.each do |key, value|
107
+ current_path = path.empty? ? key.to_s : "#{path}.#{key}"
108
+
109
+ case value
110
+ when Hash
111
+ walk(value, path: current_path, &block)
112
+ when String
113
+ next unless value.match?(URI_PATTERN)
114
+
115
+ resolved = resolve_single(value)
116
+ if resolved.nil?
117
+ log_warn("Settings resolver: could not resolve #{current_path} (#{value})")
118
+ block&.call(:unresolved)
119
+ else
120
+ hash[key] = resolved
121
+ register_lease_ref(value, current_path) if value.match?(LEASE_PATTERN)
122
+ block&.call(:resolved)
123
+ end
124
+ when Array
125
+ next unless resolvable_chain?(value)
126
+
127
+ resolved = resolve_chain(value)
128
+ if resolved.nil?
129
+ log_warn("Settings resolver: fallback chain exhausted for #{current_path}")
130
+ block&.call(:unresolved)
131
+ else
132
+ hash[key] = resolved
133
+ register_lease_refs_from_chain(value, current_path)
134
+ block&.call(:resolved)
135
+ end
136
+ end
137
+ end
138
+ end
139
+
140
+ def resolve_vault(path, key)
141
+ return nil unless @vault_available
142
+
143
+ @vault_cache[path] ||= begin
144
+ Legion::Crypt.read(path)
145
+ rescue StandardError => e
146
+ log_debug("Settings resolver: vault read failed for #{path}: #{e.message}")
147
+ nil
148
+ end
149
+
150
+ data = @vault_cache[path]
151
+ return nil unless data.is_a?(Hash)
152
+
153
+ data[key.to_sym] || data[key.to_s]
154
+ end
155
+
156
+ def resolve_lease(name, key)
157
+ return nil unless lease_manager_available?
158
+
159
+ Legion::Crypt::LeaseManager.instance.fetch(name, key)
160
+ rescue StandardError => e
161
+ log_debug("Settings resolver: lease fetch failed for #{name}##{key}: #{e.message}")
162
+ nil
163
+ end
164
+
165
+ def lease_manager_available?
166
+ defined?(Legion::Crypt::LeaseManager)
167
+ rescue StandardError
168
+ false
169
+ end
170
+
171
+ def resolvable_chain?(arr)
172
+ arr.any? { |v| v.is_a?(String) && v.match?(URI_PATTERN) }
173
+ end
174
+
175
+ def register_lease_ref(value, path_string)
176
+ return unless lease_manager_available?
177
+
178
+ m = value.match(LEASE_PATTERN)
179
+ return unless m
180
+
181
+ path_parts = path_string.split('.').map(&:to_sym)
182
+ Legion::Crypt::LeaseManager.instance.register_ref(m[1], m[2], path_parts)
183
+ rescue StandardError
184
+ nil
185
+ end
186
+
187
+ def register_lease_refs_from_chain(arr, path_string)
188
+ return unless lease_manager_available?
189
+
190
+ arr.each do |entry|
191
+ next unless entry.is_a?(String)
192
+
193
+ register_lease_ref(entry, path_string) if entry.match?(LEASE_PATTERN)
194
+ end
195
+ end
196
+
197
+ def count_lease_refs(hash)
198
+ return 0 unless hash.is_a?(Hash)
199
+
200
+ hash.sum do |_key, value|
201
+ case value
202
+ when String then value.match?(LEASE_PATTERN) ? 1 : 0
203
+ when Array then value.count { |v| v.is_a?(String) && v.match?(LEASE_PATTERN) }
204
+ when Hash then count_lease_refs(value)
205
+ else 0
206
+ end
207
+ end
208
+ end
209
+
210
+ def log_info(message)
211
+ if defined?(Legion::Logging)
212
+ Legion::Logging.info(message)
213
+ else
214
+ $stdout.puts(message)
215
+ end
216
+ end
217
+
218
+ def log_warn(message)
219
+ if defined?(Legion::Logging)
220
+ Legion::Logging.warn(message)
221
+ else
222
+ warn(message)
223
+ end
224
+ end
225
+
226
+ def log_debug(message)
227
+ if defined?(Legion::Logging)
228
+ Legion::Logging.debug(message)
229
+ else
230
+ $stdout.puts(message)
231
+ end
232
+ end
233
+ end
234
+ end
235
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module Settings
5
- VERSION = '1.2.2'
5
+ VERSION = '1.3.1'
6
6
  end
7
7
  end
@@ -79,6 +79,12 @@ module Legion
79
79
  warn_validation_errors(errors)
80
80
  end
81
81
 
82
+ def resolve_secrets!
83
+ @loader = load if @loader.nil?
84
+ require 'legion/settings/resolver'
85
+ Resolver.resolve_secrets!(@loader.to_hash)
86
+ end
87
+
82
88
  def schema
83
89
  @schema ||= Schema.new
84
90
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: legion-settings
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.2
4
+ version: 1.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -46,6 +46,7 @@ files:
46
46
  - lib/legion/settings.rb
47
47
  - lib/legion/settings/loader.rb
48
48
  - lib/legion/settings/os.rb
49
+ - lib/legion/settings/resolver.rb
49
50
  - lib/legion/settings/schema.rb
50
51
  - lib/legion/settings/validation_error.rb
51
52
  - lib/legion/settings/version.rb