legion-crypt 1.5.12 → 1.5.37

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.
@@ -1,17 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Ruby 4.0 freezes OpenSSL::SSL::SSLContext::DEFAULT_PARAMS by default.
4
- # The vault gem (0.18.x) mutates this hash in Vault.setup! — replace it
5
- # with a mutable dup so the require succeeds on Ruby 4.0+.
6
3
  require 'legion/logging/helper'
7
- require 'openssl'
8
- if OpenSSL::SSL::SSLContext::DEFAULT_PARAMS.frozen?
9
- unfrozen = OpenSSL::SSL::SSLContext::DEFAULT_PARAMS.dup
10
- OpenSSL::SSL::SSLContext.send(:remove_const, :DEFAULT_PARAMS)
11
- OpenSSL::SSL::SSLContext.const_set(:DEFAULT_PARAMS, unfrozen)
12
- end
13
-
14
- require 'vault'
4
+ require 'uri'
5
+ require 'legion/crypt/vault_client'
6
+ require 'legion/crypt/tls'
7
+ require 'legion/crypt/vault_operation'
15
8
 
16
9
  module Legion
17
10
  module Crypt
@@ -20,8 +13,67 @@ module Legion
20
13
 
21
14
  def vault_client(name = nil)
22
15
  name = resolve_cluster_name(name)
23
- @vault_clients ||= {}
24
- @vault_clients[name] ||= build_vault_client(clusters[name])
16
+ loop do
17
+ candidate = accepted = nil
18
+ signature, generation = vault_clients_mutex.synchronize do
19
+ return @vault_clients&.dig(name, :client) if Legion::Crypt.respond_to?(:stopping) && Legion::Crypt.stopping
20
+
21
+ @vault_clients ||= {}
22
+ entry = @vault_clients[name]
23
+ signature = vault_client_signature(config: name.nil? ? vault_settings : clusters[name])
24
+ cached = cached_vault_client(entry: entry, signature: signature)
25
+ return cached if cached
26
+
27
+ unless signature
28
+ (@retired_vault_clients ||= ObjectSpace::WeakMap.new)[entry[:client]] = true if entry
29
+ @vault_clients.delete(name)
30
+ return nil
31
+ end
32
+ signature.transform_values! { |value| value.is_a?(String) ? value.dup.freeze : value }
33
+ [signature, @vault_client_generation || 0]
34
+ end
35
+ candidate = build_vault_client(signature, name: name)
36
+ accepted = install_vault_client(name: name, signature: signature, client: candidate, generation: generation)
37
+ return accepted if accepted
38
+ ensure
39
+ retire_vault_client(client: candidate) unless accepted.equal?(candidate)
40
+ end
41
+ end
42
+
43
+ def publish_vault_grant(client:, config:, grant:, auth_method:, issued_at: Time.now, **)
44
+ token = grant.fetch(:token)
45
+ ttl = grant[:lease_duration].to_f
46
+ expires_at = issued_at + ttl if ttl.positive?
47
+ vault_clients_mutex.synchronize do
48
+ pair = @vault_clients&.find { |_name, entry| entry[:client].equal?(client) }
49
+ raise IOError, 'Vault client replaced during authentication' if @retired_vault_clients&.key?(client)
50
+
51
+ if pair
52
+ name, entry = pair
53
+ current_config = name.nil? ? vault_settings : clusters[name]
54
+ unless config.equal?(current_config) && current_vault_client?(entry: entry, signature: vault_client_signature(config: current_config))
55
+ raise IOError, 'Vault client configuration changed during authentication'
56
+ end
57
+ end
58
+ client.token = token
59
+ connected = expires_at.nil? || expires_at > Time.now
60
+ config.merge!(token: token, lease_duration: grant[:lease_duration], renewable: grant[:renewable],
61
+ expires_at: expires_at, connected: connected, relogin_required: !connected, auth_method: auth_method)
62
+ entry[:signature][:token] = token&.dup&.freeze if entry
63
+ end
64
+ end
65
+
66
+ def clear_vault_clients(deadline: nil, **)
67
+ deadline ||= ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + Legion::Crypt::VaultOperation::SHUTDOWN_TIMEOUT
68
+ clients = vault_clients_mutex.synchronize do
69
+ @vault_client_generation = (@vault_client_generation || 0) + 1
70
+ previous = (@vault_clients || {}).values.map { |entry| entry[:client] }
71
+ previous.concat(@retired_vault_clients.keys) if @retired_vault_clients
72
+ @vault_clients = {}
73
+ @retired_vault_clients = nil
74
+ previous.uniq
75
+ end
76
+ clients.each { |client| retire_vault_client(client: client, deadline: deadline) }
25
77
  end
26
78
 
27
79
  def cluster(name = nil)
@@ -39,7 +91,13 @@ module Legion
39
91
  end
40
92
 
41
93
  def connected_clusters
42
- clusters.select { |_, config| config[:token] && config[:connected] }
94
+ vault_clients_mutex.synchronize do
95
+ now = Time.now
96
+ clusters.select do |_, config|
97
+ config[:token] && config[:connected] && !config[:relogin_required] &&
98
+ (!config[:expires_at] || config[:expires_at] > now)
99
+ end
100
+ end
43
101
  end
44
102
 
45
103
  def connect_all_clusters
@@ -78,8 +136,8 @@ module Legion
78
136
 
79
137
  def cluster_healthy?(client)
80
138
  client.sys.health_status.initialized?
81
- rescue ::Vault::HTTPError => e
82
- return true if e.message =~ /\b(429|472|473)\b/
139
+ rescue Legion::Crypt::Vault::HTTPError => e
140
+ return true if [429, 472, 473].include?(e.code)
83
141
 
84
142
  handle_exception(e, level: :warn, operation: 'crypt.vault_cluster.cluster_healthy')
85
143
  raise
@@ -114,18 +172,62 @@ module Legion
114
172
  default_cluster_name
115
173
  end
116
174
 
117
- def build_vault_client(config)
118
- return nil unless config.is_a?(Hash)
175
+ def vault_clients_mutex
176
+ @vault_clients_mutex ||= Mutex.new
177
+ end
178
+
179
+ def current_vault_client?(entry:, signature:, **)
180
+ return false unless entry && signature
181
+ return false unless entry[:signature].all? { |key, value| key == :token || value == signature[key] }
182
+
183
+ entry[:signature][:token] == signature[:token] || entry[:client].token == signature[:token]
184
+ end
185
+
186
+ def cached_vault_client(entry:, signature:, **)
187
+ return unless current_vault_client?(entry: entry, signature: signature)
188
+
189
+ entry[:signature][:token] = signature[:token]&.dup if entry[:signature][:token] != signature[:token]
190
+ entry[:client]
191
+ end
192
+
193
+ def install_vault_client(name:, signature:, client:, generation:, **)
194
+ vault_clients_mutex.synchronize do
195
+ raise IOError, 'Vault clients cleared during construction' unless generation == (@vault_client_generation || 0)
196
+ raise IOError, 'Crypt stopped during client construction' if Legion::Crypt.respond_to?(:stopping) && Legion::Crypt.stopping
197
+
198
+ entry = @vault_clients[name]
199
+ current_signature = vault_client_signature(config: name.nil? ? vault_settings : clusters[name])
200
+ cached = cached_vault_client(entry: entry, signature: current_signature)
201
+ return cached if cached
202
+ return unless signature == current_signature
203
+
204
+ (@retired_vault_clients ||= ObjectSpace::WeakMap.new)[entry[:client]] = true if entry
205
+ @vault_clients[name] = { client: client, signature: signature }
206
+ client
207
+ end
208
+ end
209
+
210
+ def resolve_vault_address(vault_settings:, **)
211
+ protocol = vault_settings[:protocol] || 'http'
212
+ address = vault_settings[:address] || 'localhost'
213
+ port = vault_settings[:port] || 8200
214
+
215
+ if address.match?(%r{\Ahttps?://})
216
+ uri = URI.parse(address)
217
+ protocol = uri.scheme
218
+ address = uri.host
219
+ port = uri.port if vault_settings[:port].nil?
220
+ end
221
+
222
+ "#{protocol}://#{address}:#{port}"
223
+ end
224
+
225
+ def vault_client_signature(config:, **)
226
+ return unless config.is_a?(Hash)
227
+
228
+ tls = Legion::Crypt::TLS.migrate_legacy(config[:tls] || {})
229
+ verify = %w[none mutual].include?(tls[:verify].to_s) ? tls[:verify].to_s : 'peer'
119
230
 
120
- addr = "#{config[:protocol]}://#{config[:address]}:#{config[:port]}"
121
- ssl_verify = resolve_cluster_ssl_verify(config[:tls])
122
- log.info "Building Vault client address=#{addr} namespace=#{config[:namespace].inspect} ssl_verify=#{ssl_verify}"
123
- log_vault_debug("build_vault_client: address=#{addr}")
124
- client = ::Vault::Client.new(
125
- address: addr,
126
- token: config[:token],
127
- ssl_verify: ssl_verify
128
- )
129
231
  namespace =
130
232
  if config.key?(:namespace)
131
233
  config[:namespace]
@@ -133,16 +235,40 @@ module Legion
133
235
  crypt_settings = Legion::Settings[:crypt]
134
236
  crypt_settings.respond_to?(:dig) ? crypt_settings.dig(:vault, :vault_namespace) : nil
135
237
  end
136
- client.namespace = namespace if namespace
137
- log_vault_debug("build_vault_client: namespace=#{namespace.inspect}")
138
- client
238
+ {
239
+ address: resolve_vault_address(vault_settings: config), namespace: namespace, token: config[:token],
240
+ verify: verify, ca: tls[:ca], cert: tls[:cert], key: tls[:key],
241
+ timeout: vault_settings[:operation_timeout] || Legion::Crypt::VaultOperation::DEFAULT_TIMEOUT
242
+ }
139
243
  end
140
244
 
141
- def resolve_cluster_ssl_verify(tls_config)
142
- return true if tls_config.nil?
245
+ def build_vault_client(signature, name:)
246
+ building = Thread.current[:legion_vault_clients_building] ||= []
247
+ build_key = [self, name]
248
+ raise IOError, 'Circular Vault TLS material dependency' if building.include?(build_key)
143
249
 
144
- verify = tls_config[:verify]&.to_s
145
- verify != 'none'
250
+ building << build_key
251
+ begin
252
+ tls = Legion::Crypt::TLS.resolve(signature.slice(:verify, :ca, :cert, :key))
253
+ %i[ca cert key].each do |attribute|
254
+ raise IOError, "Configured Vault TLS #{attribute} could not be resolved" if signature[attribute] && tls[attribute].nil?
255
+ end
256
+ options = signature.slice(:address, :namespace, :token, :timeout).merge(tls: tls)
257
+ log.info "Building Vault client address=#{options[:address]} namespace=#{options[:namespace].inspect} tls_verify=#{tls[:verify]}"
258
+ Legion::Crypt::VaultClient.new(**options)
259
+ ensure
260
+ building.delete(build_key)
261
+ end
262
+ end
263
+
264
+ def retire_vault_client(client:, deadline: nil, **)
265
+ return unless client.respond_to?(:shutdown)
266
+
267
+ Legion::Crypt::VaultOperation.with_timeout(deadline: deadline) do
268
+ client.shutdown
269
+ end
270
+ rescue StandardError => e
271
+ handle_exception(e, level: :warn, operation: 'crypt.vault_cluster.retire_vault_client')
146
272
  end
147
273
 
148
274
  def log_vault_error(name, error, operation: 'crypt.vault_cluster.error')
@@ -173,11 +299,7 @@ module Legion
173
299
  auth_path: auth_path
174
300
  )
175
301
 
176
- config[:token] = result[:token]
177
- config[:lease_duration] = result[:lease_duration]
178
- config[:renewable] = result[:renewable]
179
- config[:connected] = true
180
- vault_client(name).token = result[:token]
302
+ publish_vault_grant(client: client, config: config, grant: result, auth_method: 'kerberos')
181
303
  log_vault_debug("connect_kerberos_cluster[#{name}]: policies=#{result[:policies].inspect}")
182
304
  log_cluster_connected(name, config)
183
305
  true
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'legion/logging/helper'
4
+ require 'legion/crypt/vault_client'
5
+ require 'legion/crypt/lease_manager'
4
6
 
5
7
  module Legion
6
8
  module Crypt
@@ -10,17 +12,19 @@ module Legion
10
12
  # Create or lookup a Vault entity for a Legion principal.
11
13
  # Returns the Vault entity ID string, or nil on failure.
12
14
  def self.ensure_entity(principal_id:, canonical_name:, metadata: {})
13
- existing = find_by_name(canonical_name)
15
+ existing = find_by_name(canonical_name: canonical_name)
14
16
  return existing if existing
15
17
 
16
- response = vault_logical.write(
17
- 'identity/entity',
18
- name: "legion-#{canonical_name}",
19
- metadata: metadata.merge(
20
- legion_principal_id: principal_id,
21
- legion_canonical_name: canonical_name,
22
- managed_by: 'legion'
23
- )
18
+ response = Legion::Crypt::LeaseManager.instance.vault_logical.write(
19
+ path: 'identity/entity',
20
+ data: {
21
+ name: "legion-#{canonical_name}",
22
+ metadata: metadata.merge(
23
+ legion_principal_id: principal_id,
24
+ legion_canonical_name: canonical_name,
25
+ managed_by: 'legion'
26
+ )
27
+ }
24
28
  )
25
29
  extract_id(response)
26
30
  rescue StandardError => e
@@ -31,13 +35,11 @@ module Legion
31
35
  # Create an alias linking an auth method mount to the entity.
32
36
  # Idempotent — swallows "already exists" 4xx errors.
33
37
  def self.ensure_alias(entity_id:, mount_accessor:, alias_name:)
34
- vault_logical.write(
35
- 'identity/entity-alias',
36
- name: alias_name,
37
- canonical_id: entity_id,
38
- mount_accessor: mount_accessor
38
+ Legion::Crypt::LeaseManager.instance.vault_logical.write(
39
+ path: 'identity/entity-alias',
40
+ data: { name: alias_name, canonical_id: entity_id, mount_accessor: mount_accessor }
39
41
  )
40
- rescue ::Vault::HTTPClientError => e
42
+ rescue Legion::Crypt::Vault::HTTPClientError => e
41
43
  if e.message.include?('already exists')
42
44
  log.debug 'Vault entity alias already exists (idempotent)'
43
45
  else
@@ -51,14 +53,9 @@ module Legion
51
53
 
52
54
  # Look up a Vault entity by its Legion canonical name.
53
55
  # Returns the Vault entity ID string, or nil if not found.
54
- def self.find_by_name(canonical_name)
55
- response = vault_logical.read("identity/entity/name/legion-#{canonical_name}")
56
+ def self.find_by_name(canonical_name:)
57
+ response = Legion::Crypt::LeaseManager.instance.vault_logical.read(path: "identity/entity/name/legion-#{canonical_name}")
56
58
  extract_id(response)
57
- rescue ::Vault::HTTPClientError => e
58
- unless e.message.match?(/not found|does not exist|404/i)
59
- handle_exception(e, level: :warn, operation: 'crypt.vault_entity.find_by_name', canonical_name: canonical_name)
60
- end
61
- nil
62
59
  rescue StandardError => e
63
60
  handle_exception(e, level: :warn, operation: 'crypt.vault_entity.find_by_name', canonical_name: canonical_name)
64
61
  nil
@@ -68,22 +65,12 @@ module Legion
68
65
  # Private helpers
69
66
  # ---------------------------------------------------------------------------
70
67
 
71
- def self.vault_logical
72
- if defined?(Legion::Crypt::LeaseManager)
73
- Legion::Crypt::LeaseManager.instance.vault_logical
74
- else
75
- ::Vault.logical
76
- end
77
- end
78
- private_class_method :vault_logical
79
-
80
- # Extract entity ID from a Vault response, supporting both symbol and
81
- # string keys (Vault SDK may return either depending on version/transport).
68
+ # Extract the entity ID from the client's symbol-keyed response data.
82
69
  def self.extract_id(response)
83
70
  data = response&.data
84
71
  return nil unless data
85
72
 
86
- data[:id] || data['id']
73
+ data[:id]
87
74
  end
88
75
  private_class_method :extract_id
89
76
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'legion/logging/helper'
4
+ require 'legion/crypt/vault_client'
4
5
 
5
6
  module Legion
6
7
  module Crypt
@@ -30,14 +31,10 @@ module Legion
30
31
  # @param auth_path [String] Vault auth mount path (default: 'auth/jwt/login')
31
32
  # @return [Hash] { token:, lease_duration:, policies:, metadata: }
32
33
  def self.login(jwt:, role: DEFAULT_ROLE, auth_path: DEFAULT_AUTH_PATH)
33
- raise AuthError, 'Vault is not connected' unless vault_connected?
34
+ raise AuthError, 'Vault is not connected' unless Legion::Crypt.vault_connected?
34
35
 
35
36
  log.info "[crypt:vault_jwt] authenticating role=#{role} auth_path=#{auth_path}"
36
- response = ::Vault.logical.write(
37
- auth_path,
38
- role: role,
39
- jwt: jwt
40
- )
37
+ response = Legion::Crypt.vault_client.logical.write(path: auth_path, data: { role: role, jwt: jwt })
41
38
 
42
39
  raise AuthError, 'Vault JWT auth returned no auth data' unless response&.auth
43
40
 
@@ -48,11 +45,11 @@ module Legion
48
45
  policies: response.auth.policies,
49
46
  metadata: response.auth.metadata
50
47
  }
51
- rescue ::Vault::HTTPClientError => e
48
+ rescue Legion::Crypt::Vault::HTTPClientError => e
52
49
  handle_exception(e, level: :warn, operation: 'crypt.vault_jwt_auth.login', role: role, auth_path: auth_path,
53
50
  category: 'client_error')
54
51
  raise AuthError, "Vault JWT auth failed: #{e.message}"
55
- rescue ::Vault::HTTPServerError => e
52
+ rescue Legion::Crypt::Vault::HTTPServerError => e
56
53
  handle_exception(e, level: :warn, operation: 'crypt.vault_jwt_auth.login', role: role, auth_path: auth_path,
57
54
  category: 'server_error')
58
55
  raise AuthError, "Vault server error during JWT auth: #{e.message}"
@@ -68,8 +65,11 @@ module Legion
68
65
  #
69
66
  # @return [Hash] Same as login
70
67
  def self.login!(jwt:, role: DEFAULT_ROLE, auth_path: DEFAULT_AUTH_PATH)
68
+ generation = Legion::Crypt.vault_auth_generation
69
+ client = Legion::Crypt.vault_client
70
+ config = Legion::Crypt.cluster || Legion::Settings[:crypt][:vault]
71
71
  result = login(jwt: jwt, role: role, auth_path: auth_path)
72
- ::Vault.token = result[:token]
72
+ Legion::Crypt.install_vault_grant(client: client, config: config, grant: result, auth_method: 'jwt', generation: generation)
73
73
  log.info "[crypt:vault_jwt] authenticated via JWT auth, policies=#{result[:policies].join(',')}"
74
74
  result
75
75
  end
@@ -92,17 +92,6 @@ module Legion
92
92
 
93
93
  login(jwt: jwt, role: role)
94
94
  end
95
-
96
- def self.vault_connected?
97
- defined?(::Vault) &&
98
- defined?(Legion::Settings) &&
99
- Legion::Settings[:crypt][:vault][:connected] == true
100
- rescue StandardError => e
101
- handle_exception(e, level: :debug, operation: 'crypt.vault_jwt_auth.vault_connected?')
102
- false
103
- end
104
-
105
- private_class_method :vault_connected?
106
95
  end
107
96
  end
108
97
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'legion/logging/helper'
4
+ require 'legion/crypt/vault_client'
4
5
 
5
6
  module Legion
6
7
  module Crypt
@@ -12,10 +13,13 @@ module Legion
12
13
  extend Legion::Logging::Helper
13
14
 
14
15
  def self.login(spnego_token:, auth_path: DEFAULT_AUTH_PATH)
15
- raise AuthError, 'Vault is not connected' unless vault_connected?
16
+ raise AuthError, 'Vault is not connected' unless Legion::Crypt.vault_connected?
16
17
 
17
18
  log.info "[crypt:vault_kerberos] login requested auth_path=#{auth_path}"
18
- response = ::Vault.logical.write(auth_path, authorization: "Negotiate #{spnego_token}")
19
+ data = Legion::Crypt.vault_client.put(
20
+ path: auth_path, body: '{}', headers: { 'Authorization' => "Negotiate #{spnego_token}" }
21
+ )
22
+ response = Legion::Crypt::VaultClient::Secret.decode(data: data)
19
23
  raise AuthError, 'Vault Kerberos auth returned no auth data' unless response&.auth
20
24
 
21
25
  {
@@ -25,7 +29,7 @@ module Legion
25
29
  policies: response.auth.policies,
26
30
  metadata: response.auth.metadata
27
31
  }
28
- rescue ::Vault::HTTPClientError => e
32
+ rescue Legion::Crypt::Vault::HTTPClientError => e
29
33
  handle_exception(e, level: :warn, operation: 'crypt.vault_kerberos_auth.login', auth_path: auth_path)
30
34
  raise AuthError, "Vault Kerberos auth failed: #{e.message}"
31
35
  rescue StandardError => e
@@ -34,21 +38,14 @@ module Legion
34
38
  end
35
39
 
36
40
  def self.login!(spnego_token:, auth_path: DEFAULT_AUTH_PATH)
41
+ generation = Legion::Crypt.vault_auth_generation
42
+ client = Legion::Crypt.vault_client
43
+ config = Legion::Crypt.cluster || Legion::Settings[:crypt][:vault]
37
44
  result = login(spnego_token: spnego_token, auth_path: auth_path)
38
- ::Vault.token = result[:token]
45
+ Legion::Crypt.install_vault_grant(client: client, config: config, grant: result, auth_method: 'kerberos', generation: generation)
39
46
  log.info "[crypt:vault_kerberos] authenticated via Kerberos auth, policies=#{result[:policies].join(',')}"
40
47
  result
41
48
  end
42
-
43
- def self.vault_connected?
44
- defined?(::Vault) && defined?(Legion::Settings) &&
45
- Legion::Settings[:crypt][:vault][:connected] == true
46
- rescue StandardError => e
47
- handle_exception(e, level: :debug, operation: 'crypt.vault_kerberos_auth.vault_connected')
48
- false
49
- end
50
-
51
- private_class_method :vault_connected?
52
49
  end
53
50
  end
54
51
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'timeout'
4
+
5
+ module Legion
6
+ module Crypt
7
+ module VaultOperation
8
+ DEFAULT_TIMEOUT = 5
9
+ SHUTDOWN_TIMEOUT = 10
10
+
11
+ def self.with_timeout(deadline: nil, **)
12
+ configuration = defined?(Legion::Settings) ? Legion::Settings[:crypt] : nil
13
+ timeout = configuration&.dig(:vault, :operation_timeout) || DEFAULT_TIMEOUT
14
+ raise ArgumentError, 'Vault operation_timeout must be a finite positive number' unless timeout.is_a?(Numeric) && timeout.finite? && timeout.positive?
15
+
16
+ if deadline
17
+ remaining = deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
18
+ raise Timeout::Error, 'Vault operation deadline expired' unless remaining.positive?
19
+
20
+ timeout = [timeout, remaining].min
21
+ end
22
+ operation_deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + timeout
23
+ Timeout.timeout(timeout) { yield(operation_deadline) }
24
+ end
25
+ end
26
+ end
27
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module Crypt
5
- VERSION = '1.5.12'
5
+ VERSION = '1.5.37'
6
6
  end
7
7
  end