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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +30 -3
- data/.rspec +2 -0
- data/AGENTS.md +2 -0
- data/CHANGELOG.md +28 -0
- data/CLAUDE.md +64 -192
- data/CODEOWNERS +4 -4
- data/Gemfile +4 -1
- data/README.md +60 -16
- data/legion-crypt.gemspec +1 -0
- data/lib/legion/crypt/cert_rotation.rb +7 -21
- data/lib/legion/crypt/cluster_secret.rb +3 -14
- data/lib/legion/crypt/ed25519.rb +2 -10
- data/lib/legion/crypt/erasure.rb +3 -13
- data/lib/legion/crypt/jwks_client.rb +4 -4
- data/lib/legion/crypt/kerberos_auth.rb +2 -2
- data/lib/legion/crypt/ldap_auth.rb +2 -2
- data/lib/legion/crypt/lease_manager.rb +7 -7
- data/lib/legion/crypt/mtls.rb +8 -46
- data/lib/legion/crypt/partition_keys.rb +2 -6
- data/lib/legion/crypt/settings.rb +24 -21
- data/lib/legion/crypt/spiffe/svid_rotation.rb +6 -9
- data/lib/legion/crypt/spiffe.rb +14 -39
- data/lib/legion/crypt/token_renewer.rb +6 -6
- data/lib/legion/crypt/vault/client.rb +355 -0
- data/lib/legion/crypt/vault/cluster.rb +330 -0
- data/lib/legion/crypt/vault/entity.rb +80 -0
- data/lib/legion/crypt/vault/jwt_auth.rb +99 -0
- data/lib/legion/crypt/vault/kerberos_auth.rb +53 -0
- data/lib/legion/crypt/vault/operation.rb +29 -0
- data/lib/legion/crypt/vault.rb +6 -11
- data/lib/legion/crypt/version.rb +1 -1
- data/lib/legion/crypt.rb +69 -80
- metadata +23 -8
- data/lib/legion/crypt/vault_client.rb +0 -355
- data/lib/legion/crypt/vault_cluster.rb +0 -331
- data/lib/legion/crypt/vault_entity.rb +0 -78
- data/lib/legion/crypt/vault_jwt_auth.rb +0 -97
- data/lib/legion/crypt/vault_kerberos_auth.rb +0 -51
- data/lib/legion/crypt/vault_operation.rb +0 -27
- /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
|