legion-crypt 1.5.38 → 1.5.42

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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +30 -3
  3. data/.rspec +2 -0
  4. data/AGENTS.md +2 -0
  5. data/CHANGELOG.md +28 -0
  6. data/CLAUDE.md +64 -192
  7. data/CODEOWNERS +4 -4
  8. data/Gemfile +4 -1
  9. data/README.md +60 -16
  10. data/legion-crypt.gemspec +1 -0
  11. data/lib/legion/crypt/cert_rotation.rb +7 -21
  12. data/lib/legion/crypt/cluster_secret.rb +3 -14
  13. data/lib/legion/crypt/ed25519.rb +2 -10
  14. data/lib/legion/crypt/erasure.rb +3 -13
  15. data/lib/legion/crypt/jwks_client.rb +4 -4
  16. data/lib/legion/crypt/kerberos_auth.rb +2 -2
  17. data/lib/legion/crypt/ldap_auth.rb +2 -2
  18. data/lib/legion/crypt/lease_manager.rb +7 -7
  19. data/lib/legion/crypt/mtls.rb +8 -46
  20. data/lib/legion/crypt/partition_keys.rb +2 -6
  21. data/lib/legion/crypt/settings.rb +24 -21
  22. data/lib/legion/crypt/spiffe/svid_rotation.rb +6 -9
  23. data/lib/legion/crypt/spiffe.rb +14 -39
  24. data/lib/legion/crypt/token_renewer.rb +6 -6
  25. data/lib/legion/crypt/vault/client.rb +355 -0
  26. data/lib/legion/crypt/vault/cluster.rb +330 -0
  27. data/lib/legion/crypt/vault/entity.rb +80 -0
  28. data/lib/legion/crypt/vault/jwt_auth.rb +99 -0
  29. data/lib/legion/crypt/vault/kerberos_auth.rb +53 -0
  30. data/lib/legion/crypt/vault/operation.rb +29 -0
  31. data/lib/legion/crypt/vault.rb +6 -11
  32. data/lib/legion/crypt/version.rb +1 -1
  33. data/lib/legion/crypt.rb +69 -80
  34. metadata +23 -8
  35. data/lib/legion/crypt/vault_client.rb +0 -355
  36. data/lib/legion/crypt/vault_cluster.rb +0 -331
  37. data/lib/legion/crypt/vault_entity.rb +0 -78
  38. data/lib/legion/crypt/vault_jwt_auth.rb +0 -97
  39. data/lib/legion/crypt/vault_kerberos_auth.rb +0 -51
  40. data/lib/legion/crypt/vault_operation.rb +0 -27
  41. /data/lib/legion/crypt/{vault_renewer.rb → vault/renewer.rb} +0 -0
@@ -0,0 +1,355 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'legion/json'
5
+ require 'openssl'
6
+ require 'uri'
7
+
8
+ module Legion
9
+ module Crypt
10
+ module Vault
11
+ class HTTPError < StandardError
12
+ attr_reader :code, :errors, :address
13
+
14
+ def initialize(code:, errors: [], address: nil, **)
15
+ @code = Integer(code)
16
+ @errors = Array(errors)
17
+ @address = address
18
+ super("Vault HTTP #{@code}#{": #{@errors.join('; ')}" unless @errors.empty?}")
19
+ end
20
+ end
21
+
22
+ class HTTPClientError < HTTPError; end
23
+ class HTTPServerError < HTTPError; end
24
+
25
+ class Client
26
+ CONNECTION_ATTRIBUTES = %i[address ssl_verify ca cert key timeout].freeze
27
+
28
+ attr_reader(*CONNECTION_ATTRIBUTES, :logical, :sys, :auth_token, :token)
29
+ attr_accessor :namespace
30
+
31
+ def initialize(address:, token: nil, namespace: nil, tls: {}, timeout: 5, **)
32
+ @address = validate_address(address: address)
33
+ @timeout = validate_timeout(timeout: timeout)
34
+ @token = token
35
+ @namespace = namespace
36
+ @ssl_verify = tls[:verify] != false && tls[:verify].to_s != 'none'
37
+ @ca = tls[:ca]
38
+ @cert = tls[:cert]
39
+ @key = tls[:key]
40
+ @mutex = Mutex.new
41
+ @retired = false
42
+ @logical = Logical.new(client: self)
43
+ @sys = Sys.new(client: self)
44
+ @auth_token = AuthToken.new(client: self)
45
+ end
46
+
47
+ CONNECTION_ATTRIBUTES.each do |attribute|
48
+ define_method(:"#{attribute}=") do |value|
49
+ value = validate_address(address: value) if attribute == :address
50
+ value = validate_timeout(timeout: value) if attribute == :timeout
51
+ @mutex.synchronize do
52
+ return value if instance_variable_get(:"@#{attribute}") == value
53
+
54
+ previous = @connection
55
+ @connection = nil
56
+ instance_variable_set(:"@#{attribute}", value)
57
+ previous&.close
58
+ end
59
+ value
60
+ end
61
+ end
62
+
63
+ def token=(value)
64
+ @mutex.synchronize do
65
+ raise IOError, 'Vault client has been shut down' if @retired
66
+
67
+ @token = value
68
+ end
69
+ end
70
+
71
+ def kv(mount:, **)
72
+ KV.new(client: self, mount: mount)
73
+ end
74
+
75
+ def put(path:, body:, headers: {}, **)
76
+ request(method: :put, path: path, body: body, headers: headers) || {}
77
+ end
78
+
79
+ def request(method:, path:, body: nil, headers: {}, nil_on_not_found: false, **)
80
+ connection, request_headers, address = @mutex.synchronize do
81
+ raise IOError, 'Vault client has been shut down' if @retired
82
+
83
+ [@connection ||= build_connection, vault_headers(headers: headers), @address]
84
+ end
85
+ payload = body.is_a?(String) || body.nil? ? body : Legion::JSON.dump(body)
86
+ response = connection.run_request(method, request_path(path: path), payload, request_headers)
87
+ return nil if nil_on_not_found && response.status == 404
88
+
89
+ decode_response(response: response, address: address)
90
+ end
91
+
92
+ def shutdown
93
+ @mutex.synchronize do
94
+ @retired = true
95
+ previous = @connection
96
+ @connection = nil
97
+ previous&.close
98
+ end
99
+ nil
100
+ end
101
+
102
+ def inspect
103
+ "#<#{self.class}>"
104
+ end
105
+
106
+ class Logical
107
+ def initialize(client:, **)
108
+ @client = client
109
+ end
110
+
111
+ def read(path:, **)
112
+ Secret.decode(data: @client.request(method: :get, path: path, nil_on_not_found: true))
113
+ end
114
+
115
+ def write(path:, data:, **)
116
+ Secret.decode(data: @client.put(path: path, body: data))
117
+ end
118
+
119
+ def delete(path:, **)
120
+ @client.request(method: :delete, path: path)
121
+ true
122
+ end
123
+ end
124
+
125
+ class Sys
126
+ class HealthStatus
127
+ def initialize(data:, **)
128
+ @initialized = data[:initialized]
129
+ end
130
+
131
+ def initialized?
132
+ @initialized == true
133
+ end
134
+ end
135
+
136
+ def initialize(client:, **)
137
+ @client = client
138
+ end
139
+
140
+ def health_status
141
+ HealthStatus.new(data: @client.request(method: :get, path: 'sys/health'))
142
+ end
143
+
144
+ def mounts
145
+ @client.request(method: :get, path: 'sys/mounts').fetch(:data)
146
+ end
147
+
148
+ def renew(lease_id:, **)
149
+ Secret.decode(data: @client.put(path: 'sys/leases/renew', body: { lease_id: lease_id }))
150
+ end
151
+
152
+ def revoke(lease_id:, **)
153
+ @client.put(path: 'sys/leases/revoke', body: { lease_id: lease_id })
154
+ true
155
+ end
156
+ end
157
+
158
+ class AuthToken
159
+ def initialize(client:, **)
160
+ @client = client
161
+ end
162
+
163
+ def renew_self
164
+ Secret.decode(data: @client.put(path: 'auth/token/renew-self', body: {}))
165
+ end
166
+
167
+ def revoke_self
168
+ @client.put(path: 'auth/token/revoke-self', body: {})
169
+ true
170
+ end
171
+ end
172
+
173
+ class KV
174
+ def initialize(client:, mount:, **)
175
+ @client = client
176
+ mount = mount.to_s
177
+ first = mount.index(%r{[^/]})
178
+ @mount = first ? mount[first..mount.rindex(%r{[^/]})] : ''
179
+ end
180
+
181
+ def read(path:, **)
182
+ response = @client.request(method: :get, path: "#{@mount}/data/#{path}", nil_on_not_found: true)
183
+ return nil if response.nil?
184
+
185
+ Secret.decode(data: response.merge(data: response.dig(:data, :data)))
186
+ end
187
+
188
+ def write(path:, data:, **)
189
+ Secret.decode(data: @client.put(path: "#{@mount}/data/#{path}", body: { data: data }))
190
+ end
191
+
192
+ def read_metadata(path:, **)
193
+ Secret.decode(data: @client.request(method: :get, path: "#{@mount}/metadata/#{path}", nil_on_not_found: true))
194
+ end
195
+ end
196
+
197
+ class Secret
198
+ attr_reader :data, :auth, :lease_id, :lease_duration
199
+
200
+ def self.decode(data:, **)
201
+ return nil if data.nil? || data.empty?
202
+
203
+ new(data: data)
204
+ end
205
+
206
+ def initialize(data:, **)
207
+ @data = data[:data]
208
+ @auth = Auth.new(data: data[:auth]) unless data[:auth].nil?
209
+ @lease_id = data[:lease_id]
210
+ @lease_duration = data[:lease_duration]
211
+ @renewable = data[:renewable]
212
+ end
213
+
214
+ def renewable?
215
+ @renewable == true
216
+ end
217
+
218
+ def inspect
219
+ "#<#{self.class}>"
220
+ end
221
+ end
222
+
223
+ class Auth
224
+ attr_reader :client_token, :lease_duration, :policies, :metadata
225
+
226
+ def initialize(data:, **)
227
+ @client_token = data[:client_token]
228
+ @lease_duration = data[:lease_duration]
229
+ @policies = data[:policies]
230
+ @metadata = data[:metadata]
231
+ @renewable = data[:renewable]
232
+ end
233
+
234
+ def renewable?
235
+ @renewable == true
236
+ end
237
+
238
+ def inspect
239
+ "#<#{self.class}>"
240
+ end
241
+ end
242
+
243
+ private
244
+
245
+ def validate_address(address:, **)
246
+ uri = URI.parse(address.to_s)
247
+ unless uri.is_a?(URI::HTTP) && uri.host && !uri.userinfo && !uri.query && !uri.fragment
248
+ raise ArgumentError, 'Vault address must be an HTTP(S) URL without credentials, query, or fragment'
249
+ end
250
+
251
+ uri.to_s.freeze
252
+ rescue URI::InvalidURIError
253
+ raise ArgumentError, 'Vault address must be a valid HTTP(S) URL'
254
+ end
255
+
256
+ def validate_timeout(timeout:, **)
257
+ seconds = Float(timeout)
258
+ raise ArgumentError, 'Vault timeout must be positive and finite' unless seconds.positive? && seconds.finite?
259
+
260
+ seconds
261
+ end
262
+
263
+ def vault_headers(headers:, **)
264
+ result = Faraday::Utils::Headers.new('Accept' => 'application/json', 'Content-Type' => 'application/json')
265
+ result.update(headers)
266
+ result.delete('X-Vault-Token')
267
+ result.delete('X-Vault-Namespace')
268
+ result['X-Vault-Token'] = @token unless @token.nil?
269
+ result['X-Vault-Namespace'] = @namespace unless @namespace.nil?
270
+ result
271
+ end
272
+
273
+ def request_path(path:, **)
274
+ relative = path.to_s.sub(%r{\A/v1(?:/|\z)}, '').sub(%r{\A/+}, '')
275
+ encoded = relative.split('/', -1).map do |segment|
276
+ if ['.', '..'].include?(segment)
277
+ segment.gsub('.', '%2E')
278
+ else
279
+ URI.encode_www_form_component(segment).gsub('+', '%20')
280
+ end
281
+ end
282
+ "/v1/#{encoded.join('/')}"
283
+ end
284
+
285
+ def build_connection
286
+ Faraday.new(url: @address, ssl: tls_options) do |connection|
287
+ connection.options.timeout = @timeout
288
+ connection.options.open_timeout = @timeout
289
+ connection.options.read_timeout = @timeout
290
+ connection.options.write_timeout = @timeout
291
+ connection.adapter :net_http
292
+ end
293
+ end
294
+
295
+ def tls_options
296
+ options = { verify: @ssl_verify != false && @ssl_verify.to_s != 'none' }
297
+ if @ca
298
+ if @ca.include?('-----BEGIN')
299
+ store = OpenSSL::X509::Store.new
300
+ certificates = @ca.scan(/-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----/m)
301
+ raise ArgumentError, 'Vault CA PEM contains no certificates' if certificates.empty?
302
+
303
+ certificates.each { |certificate| store.add_cert(OpenSSL::X509::Certificate.new(certificate)) }
304
+ options[:cert_store] = store
305
+ else
306
+ options[:ca_file] = @ca
307
+ end
308
+ end
309
+ options[:client_cert] = tls_certificate(certificate: @cert) if @cert
310
+ options[:client_key] = tls_key(key: @key) if @key
311
+ options
312
+ end
313
+
314
+ def tls_certificate(certificate:, **)
315
+ return certificate if certificate.is_a?(OpenSSL::X509::Certificate)
316
+
317
+ pem = certificate.include?('-----BEGIN') ? certificate : File.read(certificate)
318
+ certificates = pem.scan(/-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----/m)
319
+ return OpenSSL::X509::Certificate.new(pem) if certificates.length < 2
320
+
321
+ certificates.map { |entry| OpenSSL::X509::Certificate.new(entry) }
322
+ end
323
+
324
+ def tls_key(key:, **)
325
+ return key if key.is_a?(OpenSSL::PKey::PKey)
326
+
327
+ OpenSSL::PKey.read(key.include?('-----BEGIN') ? key : File.read(key))
328
+ end
329
+
330
+ def decode_response(response:, address:, **)
331
+ status = response.status
332
+ unless (200..299).cover?(status)
333
+ error_class = if (400..499).cover?(status)
334
+ Legion::Crypt::Vault::HTTPClientError
335
+ elsif (500..599).cover?(status)
336
+ Legion::Crypt::Vault::HTTPServerError
337
+ else
338
+ Legion::Crypt::Vault::HTTPError
339
+ end
340
+ begin
341
+ decoded = response.body.nil? || response.body.empty? ? {} : Legion::JSON.load(response.body)
342
+ rescue Legion::JSON::ParseError
343
+ raise error_class.new(code: status, errors: [response.body.to_s], address: address)
344
+ end
345
+ errors = decoded.is_a?(Hash) ? decoded[:errors] : [response.body.to_s]
346
+ raise error_class.new(code: status, errors: errors, address: address)
347
+ end
348
+ return nil if status == 204 || response.body.nil? || response.body.empty?
349
+
350
+ Legion::JSON.load(response.body)
351
+ end
352
+ end
353
+ end
354
+ end
355
+ end
@@ -0,0 +1,330 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/logging/helper'
4
+ require 'uri'
5
+ require 'legion/crypt/vault/client'
6
+ require 'legion/crypt/tls'
7
+ require 'legion/crypt/vault/operation'
8
+
9
+ module Legion
10
+ module Crypt
11
+ module Vault
12
+ module Cluster
13
+ include Legion::Logging::Helper
14
+
15
+ def vault_client(name = nil)
16
+ name = resolve_cluster_name(name)
17
+ loop do
18
+ candidate = accepted = nil
19
+ signature, generation = vault_clients_mutex.synchronize do
20
+ return @vault_clients&.dig(name, :client) if Legion::Crypt.respond_to?(:stopping) && Legion::Crypt.stopping
21
+
22
+ @vault_clients ||= {}
23
+ entry = @vault_clients[name]
24
+ signature = vault_client_signature(config: name.nil? ? Legion::Settings[:crypt][:vault] : clusters[name])
25
+ cached = cached_vault_client(entry: entry, signature: signature)
26
+ return cached if cached
27
+
28
+ unless signature
29
+ (@retired_vault_clients ||= ObjectSpace::WeakMap.new)[entry[:client]] = true if entry
30
+ @vault_clients.delete(name)
31
+ return nil
32
+ end
33
+ signature.transform_values! { |value| value.is_a?(String) ? value.dup.freeze : value }
34
+ [signature, @vault_client_generation || 0]
35
+ end
36
+ candidate = build_vault_client(signature, name: name)
37
+ accepted = install_vault_client(name: name, signature: signature, client: candidate, generation: generation)
38
+ return accepted if accepted
39
+ ensure
40
+ retire_vault_client(client: candidate) unless accepted.equal?(candidate)
41
+ end
42
+ end
43
+
44
+ def publish_vault_grant(client:, config:, grant:, auth_method:, issued_at: Time.now, **)
45
+ token = grant.fetch(:token)
46
+ ttl = grant[:lease_duration].to_f
47
+ expires_at = issued_at + ttl if ttl.positive?
48
+ vault_clients_mutex.synchronize do
49
+ pair = @vault_clients&.find { |_name, entry| entry[:client].equal?(client) }
50
+ raise IOError, 'Vault client replaced during authentication' if @retired_vault_clients&.key?(client)
51
+
52
+ if pair
53
+ name, entry = pair
54
+ current_config = name.nil? ? Legion::Settings[:crypt][:vault] : clusters[name]
55
+ unless config.equal?(current_config) && current_vault_client?(entry: entry, signature: vault_client_signature(config: current_config))
56
+ raise IOError, 'Vault client configuration changed during authentication'
57
+ end
58
+ end
59
+ client.token = token
60
+ connected = expires_at.nil? || expires_at > Time.now
61
+ config.merge!(token: token, lease_duration: grant[:lease_duration], renewable: grant[:renewable],
62
+ expires_at: expires_at, connected: connected, relogin_required: !connected, auth_method: auth_method)
63
+ entry[:signature][:token] = token&.dup&.freeze if entry
64
+ end
65
+ end
66
+
67
+ def clear_vault_clients(deadline: nil, **)
68
+ deadline ||= ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + Legion::Crypt::Vault::Operation::SHUTDOWN_TIMEOUT
69
+ clients = vault_clients_mutex.synchronize do
70
+ @vault_client_generation = (@vault_client_generation || 0) + 1
71
+ previous = (@vault_clients || {}).values.map { |entry| entry[:client] }
72
+ previous.concat(@retired_vault_clients.keys) if @retired_vault_clients
73
+ @vault_clients = {}
74
+ @retired_vault_clients = nil
75
+ previous.uniq
76
+ end
77
+ clients.each { |client| retire_vault_client(client: client, deadline: deadline) }
78
+ end
79
+
80
+ def cluster(name = nil)
81
+ name = resolve_cluster_name(name)
82
+ clusters[name]
83
+ end
84
+
85
+ def default_cluster_name
86
+ name = Legion::Settings[:crypt][:vault][:default]
87
+ name ? name.to_sym : clusters.keys.first
88
+ end
89
+
90
+ def clusters
91
+ Legion::Settings[:crypt][:vault][:clusters]
92
+ end
93
+
94
+ def connected_clusters
95
+ vault_clients_mutex.synchronize do
96
+ now = Time.now
97
+ clusters.select do |_, config|
98
+ config[:token] && config[:connected] && !config[:relogin_required] &&
99
+ (!config[:expires_at] || config[:expires_at] > now)
100
+ end
101
+ end
102
+ end
103
+
104
+ def connect_all_clusters
105
+ log.info "Vault cluster connect requested configured_clusters=#{clusters.size}"
106
+ log_vault_debug("connect_all_clusters: #{clusters.size} cluster(s) configured")
107
+ results = {}
108
+ clusters.each do |name, config|
109
+ log_vault_debug("connect_all_clusters: #{name} (auth_method=#{config[:auth_method].inspect})")
110
+ case config[:auth_method]&.to_s
111
+ when 'kerberos'
112
+ results[name] = connect_kerberos_cluster(name, config)
113
+ when 'ldap'
114
+ next # handled by ldap_login_all
115
+ else
116
+ next unless config[:token]
117
+
118
+ client = vault_client(name)
119
+ config[:connected] = cluster_healthy?(client)
120
+ results[name] = config[:connected]
121
+ log_cluster_connected(name, config) if config[:connected]
122
+ end
123
+ rescue StandardError => e
124
+ config[:connected] = false
125
+ results[name] = false
126
+ log_vault_error(name, e, operation: 'crypt.vault_cluster.connect_all_clusters')
127
+ end
128
+
129
+ connected = results.select { |_, v| v }
130
+ log.info "Vault cluster connect complete connected=#{connected.size} attempted=#{results.size}"
131
+ log_vault_debug("connect_all_clusters: #{connected.size}/#{results.size} connected")
132
+ sync_vault_connected(connected.any?)
133
+ results
134
+ end
135
+
136
+ private
137
+
138
+ def cluster_healthy?(client)
139
+ client.sys.health_status.initialized?
140
+ rescue Legion::Crypt::Vault::HTTPError => e
141
+ return true if [429, 472, 473].include?(e.code)
142
+
143
+ handle_exception(e, level: :warn, operation: 'crypt.vault_cluster.cluster_healthy')
144
+ raise
145
+ end
146
+
147
+ def sync_vault_connected(connected)
148
+ Legion::Settings[:crypt][:vault][:connected] = connected
149
+ end
150
+
151
+ def selected_connected_cluster_name(name = nil)
152
+ active_clusters = connected_clusters
153
+ return nil if active_clusters.empty?
154
+
155
+ if name
156
+ cluster_name = name.to_sym
157
+ raise ArgumentError, "Vault cluster not connected: #{cluster_name}" unless active_clusters.key?(cluster_name)
158
+
159
+ return cluster_name
160
+ end
161
+
162
+ default_name = Legion::Settings[:crypt][:vault][:default]&.to_sym
163
+ return default_name if default_name && active_clusters.key?(default_name)
164
+
165
+ active_clusters.keys.first
166
+ end
167
+
168
+ def resolve_cluster_name(name)
169
+ return name.to_sym if name
170
+
171
+ default_cluster_name
172
+ end
173
+
174
+ def vault_clients_mutex
175
+ @vault_clients_mutex ||= Mutex.new
176
+ end
177
+
178
+ def current_vault_client?(entry:, signature:, **)
179
+ return false unless entry && signature
180
+ return false unless entry[:signature].all? { |key, value| key == :token || value == signature[key] }
181
+
182
+ entry[:signature][:token] == signature[:token] || entry[:client].token == signature[:token]
183
+ end
184
+
185
+ def cached_vault_client(entry:, signature:, **)
186
+ return unless current_vault_client?(entry: entry, signature: signature)
187
+
188
+ entry[:signature][:token] = signature[:token]&.dup if entry[:signature][:token] != signature[:token]
189
+ entry[:client]
190
+ end
191
+
192
+ def install_vault_client(name:, signature:, client:, generation:, **)
193
+ vault_clients_mutex.synchronize do
194
+ raise IOError, 'Vault clients cleared during construction' unless generation == (@vault_client_generation || 0)
195
+ raise IOError, 'Crypt stopped during client construction' if Legion::Crypt.respond_to?(:stopping) && Legion::Crypt.stopping
196
+
197
+ entry = @vault_clients[name]
198
+ current_signature = vault_client_signature(config: name.nil? ? Legion::Settings[:crypt][:vault] : clusters[name])
199
+ cached = cached_vault_client(entry: entry, signature: current_signature)
200
+ return cached if cached
201
+ return unless signature == current_signature
202
+
203
+ (@retired_vault_clients ||= ObjectSpace::WeakMap.new)[entry[:client]] = true if entry
204
+ @vault_clients[name] = { client: client, signature: signature }
205
+ client
206
+ end
207
+ end
208
+
209
+ def resolve_vault_address(vault_settings:, **)
210
+ protocol = vault_settings[:protocol] || 'http'
211
+ address = vault_settings[:address] || 'localhost'
212
+ port = vault_settings[:port] || 8200
213
+
214
+ if address.match?(%r{\Ahttps?://})
215
+ uri = URI.parse(address)
216
+ protocol = uri.scheme
217
+ address = uri.host
218
+ port = uri.port if vault_settings[:port].nil?
219
+ end
220
+
221
+ "#{protocol}://#{address}:#{port}"
222
+ end
223
+
224
+ def vault_client_signature(config:, **)
225
+ return unless config.is_a?(Hash)
226
+
227
+ tls = Legion::Crypt::TLS.migrate_legacy(config[:tls] || {})
228
+ verify = %w[none mutual].include?(tls[:verify].to_s) ? tls[:verify].to_s : 'peer'
229
+
230
+ namespace =
231
+ if config.key?(:namespace)
232
+ config[:namespace]
233
+ else
234
+ Legion::Settings[:crypt][:vault][:vault_namespace]
235
+ end
236
+ {
237
+ address: resolve_vault_address(vault_settings: config), namespace: namespace, token: config[:token],
238
+ verify: verify, ca: tls[:ca], cert: tls[:cert], key: tls[:key],
239
+ timeout: Legion::Settings[:crypt][:vault][:operation_timeout]
240
+ }
241
+ end
242
+
243
+ def build_vault_client(signature, name:)
244
+ building = Thread.current[:legion_vault_clients_building] ||= []
245
+ build_key = [self, name]
246
+ raise IOError, 'Circular Vault TLS material dependency' if building.include?(build_key)
247
+
248
+ building << build_key
249
+ begin
250
+ tls = Legion::Crypt::TLS.resolve(signature.slice(:verify, :ca, :cert, :key))
251
+ %i[ca cert key].each do |attribute|
252
+ raise IOError, "Configured Vault TLS #{attribute} could not be resolved" if signature[attribute] && tls[attribute].nil?
253
+ end
254
+ options = signature.slice(:address, :namespace, :token, :timeout).merge(tls: tls)
255
+ log.info "Building Vault client address=#{options[:address]} namespace=#{options[:namespace].inspect} tls_verify=#{tls[:verify]}"
256
+ Legion::Crypt::Vault::Client.new(**options)
257
+ ensure
258
+ building.delete(build_key)
259
+ end
260
+ end
261
+
262
+ def retire_vault_client(client:, deadline: nil, **)
263
+ return unless client.respond_to?(:shutdown)
264
+
265
+ Legion::Crypt::Vault::Operation.with_timeout(deadline: deadline) do
266
+ client.shutdown
267
+ end
268
+ rescue StandardError => e
269
+ handle_exception(e, level: :warn, operation: 'crypt.vault_cluster.retire_vault_client')
270
+ end
271
+
272
+ def log_vault_error(name, error, operation: 'crypt.vault_cluster.error')
273
+ handle_exception(error, level: :error, operation: operation, cluster_name: name)
274
+ end
275
+
276
+ def connect_kerberos_cluster(name, config)
277
+ krb_config = config[:kerberos] || {}
278
+ spn = krb_config[:service_principal]
279
+ auth_path = krb_config[:auth_path] || Legion::Crypt::KerberosAuth::DEFAULT_AUTH_PATH
280
+
281
+ log_vault_debug("connect_kerberos_cluster[#{name}]: SPN=#{spn}, auth_path=#{auth_path}, namespace=#{config[:namespace].inspect}")
282
+
283
+ unless spn
284
+ log_vault_warn(name, 'Kerberos auth missing service_principal, skipping')
285
+ config[:connected] = false
286
+ return false
287
+ end
288
+
289
+ require 'legion/crypt/kerberos_auth'
290
+ client = vault_client(name)
291
+ log_vault_debug("connect_kerberos_cluster[#{name}]: client.namespace=#{client.respond_to?(:namespace) ? client.namespace.inspect : 'n/a'}")
292
+ log.info "Connecting Vault cluster #{name} via Kerberos auth_path=#{auth_path}"
293
+
294
+ result = Legion::Crypt::KerberosAuth.login(
295
+ vault_client: client,
296
+ service_principal: spn,
297
+ auth_path: auth_path
298
+ )
299
+
300
+ publish_vault_grant(client: client, config: config, grant: result, auth_method: 'kerberos')
301
+ log_vault_debug("connect_kerberos_cluster[#{name}]: policies=#{result[:policies].inspect}")
302
+ log_cluster_connected(name, config)
303
+ true
304
+ rescue Legion::Crypt::KerberosAuth::GemMissingError => e
305
+ handle_exception(e, level: :warn, operation: 'crypt.vault_cluster.connect_kerberos_cluster', cluster_name: name)
306
+ log_vault_warn(name, e.message)
307
+ config[:connected] = false
308
+ false
309
+ rescue Legion::Crypt::KerberosAuth::AuthError => e
310
+ handle_exception(e, level: :warn, operation: 'crypt.vault_cluster.connect_kerberos_cluster', cluster_name: name)
311
+ log_vault_warn(name, "Kerberos auth failed: #{e.message}")
312
+ config[:connected] = false
313
+ false
314
+ end
315
+
316
+ def log_cluster_connected(name, config)
317
+ log.info "Vault cluster connected: #{name} at #{config[:address]}"
318
+ end
319
+
320
+ def log_vault_warn(name, message)
321
+ log.warn("Vault cluster #{name}: #{message}")
322
+ end
323
+
324
+ def log_vault_debug(message)
325
+ log.debug(message)
326
+ end
327
+ end
328
+ end
329
+ end
330
+ end