legion-crypt 1.5.4 → 1.5.7

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: 63ba23115939920477655378eaff3adacb4f37dac754cffc7e888a9eebc8ab08
4
- data.tar.gz: 88960246bf06ae1e228529958d9e431693a9fa8e8279234ae1498c130bf1490d
3
+ metadata.gz: 3b695e8ac3853b730218da5e1b40b9f7bdc9c7fbffbd1ec4eddccd5539fe296a
4
+ data.tar.gz: 544bf702cdcc9114b7fa0b99c14141fa6f58b37ee9dbde767e953076ffaedb4c
5
5
  SHA512:
6
- metadata.gz: abf411468507856a834cc3e06df344eaecc5deebcb552a77d4cfd30ac1c091506f8b284eea79660d3dc109f2e622816c32871d4c4eda047a51ec3a9dbb689467
7
- data.tar.gz: 557b616f75af3857df71a6530ea4a7e26680163fc2274e3a09ecfb2a12de628cc9bf64b90fb8e527c8ae43b1ac741a070b3f68d281591369ab460e2e348bd4e8
6
+ metadata.gz: dcf4c2dd40eeb403ec1708be60992abf7970d73e1f084f84cd840c4a1718c70497cee70a6bfe5b3d402852a9289f8498a3afbc23973a557e3f1cca355d2fefbe
7
+ data.tar.gz: a90c05a4b59d770f8cd0276008e9454e15e966e570ebf0248f8d5bb8fa48c67120c91d357e8b7225d14ec98547c41cc2914c130a65022e416e6739e489d4c526
data/CHANGELOG.md CHANGED
@@ -2,6 +2,47 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.5.7] - 2026-04-08
6
+
7
+ ### Fixed
8
+ - `LeaseManager#cache_lease` now stores the `:path` from static lease definitions, enabling `reissue_lease` fallback when `sys.renew` fails or leases hit max_ttl — previously static leases (configured via `crypt.vault.leases`) would silently expire after their TTL with no recovery (fixes #28)
9
+ - `LeaseManager#renew_lease` now logs a warning and falls back to reissue when the path is available, or warns explicitly when no path is available — previously renewal failures for pathless leases were silent
10
+
11
+ ### Added
12
+ - `LeaseManager#trigger_reconnect(name)` — dispatches reconnect to the appropriate service after credential reissue: `:rabbitmq` → `Transport::Connection.force_reconnect`, `:postgresql` → `Data.reconnect`, `:redis` → `Cache.reconnect`; all guarded with `defined?`/`respond_to?` and rescue-safe
13
+ - Comprehensive INFO/WARN logging across the entire lease lifecycle:
14
+ - INFO on lease fetch attempt, fetch success (with lease_id/ttl/renewable), renewal attempt, renewal success (with new_ttl), reissue attempt, reissue success (with new_lease_id/ttl), approaching expiry detection (with remaining/renewable/has_path), credentials changed during renewal, reconnect triggered, renewal loop start/exit
15
+ - WARN on non-renewable lease with no reissue path, renewal failure with no reissue path, reissue returning no data, reconnect failure, cannot reissue due to missing path
16
+
17
+ ### Changed
18
+ - `LeaseManager#reissue_lease` now calls `trigger_reconnect(name)` instead of inline `:rabbitmq`-only `force_reconnect`, extending credential rotation reconnect support to PostgreSQL and Redis
19
+
20
+ ## [1.5.6] - 2026-04-07
21
+
22
+ ### Added
23
+ - `VaultEntity` module (`lib/legion/crypt/vault_entity.rb`) — Phase 7 Vault identity tracking
24
+ - `ensure_entity(principal_id:, canonical_name:, metadata: {})` — creates or finds a Vault entity for a Legion principal; entity names are prefixed with `legion-` to avoid collision; metadata includes `legion_principal_id`, `legion_canonical_name`, and `managed_by: 'legion'`; returns entity ID string or nil on failure (non-fatal)
25
+ - `ensure_alias(entity_id:, mount_accessor:, alias_name:)` — creates an entity alias linking an auth method mount to the entity; idempotent (`already exists` HTTPClientError is swallowed); all other `Vault::HTTPClientError` responses log warn and return nil (non-fatal)
26
+ - `find_by_name(canonical_name)` — looks up a Vault entity by its Legion canonical name via `identity/entity/name/legion-{name}`; returns entity ID or nil
27
+ - All operations are non-fatal — rescue and log warn on failure; boot/request flow is never blocked by entity tracking errors
28
+ - Delegates Vault API calls to `LeaseManager.instance.vault_logical` (public delegator) when available; falls back to `::Vault.logical`
29
+
30
+ ## [1.5.5] - 2026-04-07
31
+
32
+ ### Added
33
+ - `RMQ_ROLE_MAP` constant mapping `:agent`/`:infra` → `'legionio-infra'` and `:worker` → `'legionio-worker'` for Vault RabbitMQ role selection (Phase 5 credential scoping)
34
+ - `dynamic_rmq_creds?` helper reads `Settings[:crypt][:vault][:dynamic_rmq_creds]` flag
35
+ - `fetch_bootstrap_rmq_creds` — fetches short-lived bootstrap RabbitMQ credentials from `rabbitmq/creds/legionio-bootstrap` and writes them to `Settings[:transport][:connection]`; gated on `vault_connected? && dynamic_rmq_creds?`; stores `@bootstrap_lease_id` for later revocation; rescue-safe
36
+ - `swap_to_identity_creds(mode:)` — fetches identity-scoped RabbitMQ credentials from the role matching `mode`, registers them with `LeaseManager` for renewal, updates transport settings, calls `Transport::Connection.force_reconnect`, and revokes the bootstrap lease; raises if reconnect fails (before revoking bootstrap)
37
+ - `revoke_bootstrap_lease` — revokes `@bootstrap_lease_id` via `LeaseManager#vault_sys`; non-fatal on failure; idempotent
38
+ - `LeaseManager#register_dynamic_lease` — registers a dynamically-fetched Vault lease into the cache and active lease tracking with mutex, stores `path` for `reissue_lease`, registers settings refs for rotation push-back
39
+ - `LeaseManager#reissue_lease(name)` — performs a full re-read (`logical.read(path)`) at credential rotation time, updates cache + active_leases in mutex, calls `push_to_settings`, triggers `Transport::Connection.force_reconnect` for `:rabbitmq` leases
40
+ - `LeaseManager#vault_logical` and `LeaseManager#vault_sys` — public delegators to the private `logical`/`sys` methods for use by `Crypt` bootstrap/swap operations
41
+ - `dynamic_rmq_creds: false` and `dynamic_pg_creds: false` defaults added to vault settings
42
+
43
+ ### Changed
44
+ - `start_lease_manager` now starts the renewal thread when `dynamic_rmq_creds: true` even if no static leases are configured, ensuring the renewal loop is running before identity-scoped leases are registered post-boot
45
+
5
46
  ## [1.5.4] - 2026-04-06
6
47
 
7
48
  ### Added
data/CLAUDE.md CHANGED
@@ -8,7 +8,7 @@
8
8
  Handles encryption, decryption, secrets management, JWT token management, and HashiCorp Vault connectivity for the LegionIO framework. Provides AES-256-CBC message encryption, RSA key pair generation, cluster secret management, JWT issue/verify operations, and Vault token lifecycle management.
9
9
 
10
10
  **GitHub**: https://github.com/LegionIO/legion-crypt
11
- **Version**: 1.4.15
11
+ **Version**: 1.5.7
12
12
  **License**: Apache-2.0
13
13
 
14
14
  ## Architecture
@@ -63,6 +63,11 @@ Legion::Crypt (singleton module)
63
63
  ├── Tls # TLS settings (cert/key/CA/verify_peer/Vault PKI)
64
64
  ├── Mtls # mTLS cert issuance (Vault PKI) + CertRotation background thread (50% TTL renewal)
65
65
  ├── TokenRenewer # Background renewal thread: 75% TTL renew, Kerberos re-auth on failure, exponential backoff
66
+ ├── Spiffe # SPIFFE identity support: parse_id, valid_id?, X509Svid, JwtSvid structs; reads security.spiffe settings
67
+ ├── Spiffe::IdentityHelpers # Mixin for SPIFFE identity operations
68
+ ├── Spiffe::SvidRotation # Background SVID renewal at 50% TTL
69
+ ├── Spiffe::WorkloadApiClient # gRPC workload API client for SPIRE agent (unix socket)
70
+ ├── VaultEntity # Vault entity/alias lifecycle (ensure_entity, ensure_alias, find_by_name); all non-fatal
66
71
  ├── MockVault # In-memory Vault mock for local development mode
67
72
  ├── Settings # Default crypt config
68
73
  └── Version
@@ -132,6 +137,11 @@ Dev dependencies: `legion-logging`, `legion-settings`
132
137
  | `lib/legion/crypt/tls.rb` | TLS settings module (cert, key, CA paths, verify_peer, Vault PKI flag) |
133
138
  | `lib/legion/crypt/mtls.rb` | mTLS certificate issuance from Vault PKI; `CertRotation` background renewal thread (50% TTL) |
134
139
  | `lib/legion/crypt/token_renewer.rb` | Plain Thread renewer: renews at 75% TTL, re-auths via Kerberos on failure, exponential backoff |
140
+ | `lib/legion/crypt/spiffe.rb` | SPIFFE identity: parse/validate SPIFFE IDs, X509Svid/JwtSvid structs, settings helpers |
141
+ | `lib/legion/crypt/spiffe/identity_helpers.rb` | Mixin for SPIFFE identity operations |
142
+ | `lib/legion/crypt/spiffe/svid_rotation.rb` | Background SVID renewal thread (50% TTL) |
143
+ | `lib/legion/crypt/spiffe/workload_api_client.rb` | gRPC workload API client for SPIRE agent |
144
+ | `lib/legion/crypt/vault_entity.rb` | Vault entity/alias lifecycle: `ensure_entity`, `ensure_alias`, `find_by_name`; all operations non-fatal |
135
145
  | `lib/legion/crypt/version.rb` | VERSION constant |
136
146
 
137
147
  ## Role in LegionIO
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Encryption, secrets management, JWT token management, and HashiCorp Vault integration for the [LegionIO](https://github.com/LegionIO/LegionIO) framework. Provides AES-256-CBC message encryption, RSA key pair generation, cluster secret management, JWT issue/verify operations, Vault token lifecycle management, and multi-cluster Vault connectivity.
4
4
 
5
- **Version**: 1.4.22
5
+ **Version**: 1.5.6
6
6
 
7
7
  ## Installation
8
8
 
@@ -61,6 +61,7 @@ module Legion
61
61
  Thread.new do
62
62
  fetch_keys(jwks_url)
63
63
  rescue StandardError => e
64
+ handle_exception(e, level: :debug, operation: 'crypt.jwks.prefetch', jwks_url: jwks_url) if respond_to?(:handle_exception)
64
65
  log.debug "JWKS prefetch failed for #{jwks_url}: #{e.message}" if respond_to?(:log)
65
66
  end
66
67
  end
@@ -71,6 +72,7 @@ module Legion
71
72
  @refresh_task = Concurrent::TimerTask.new(execution_interval: interval, run_now: false) do
72
73
  fetch_keys(jwks_url)
73
74
  rescue StandardError => e
75
+ handle_exception(e, level: :debug, operation: 'crypt.jwks.background_refresh', jwks_url: jwks_url) if respond_to?(:handle_exception)
74
76
  log.debug "JWKS background refresh failed: #{e.message}" if respond_to?(:log)
75
77
  end
76
78
  @refresh_task.execute
@@ -121,7 +123,10 @@ module Legion
121
123
 
122
124
  response.body
123
125
  rescue StandardError => e
124
- raise Legion::Crypt::JWT::Error, "failed to fetch JWKS: #{e.message}" unless e.is_a?(Legion::Crypt::JWT::Error)
126
+ unless e.is_a?(Legion::Crypt::JWT::Error)
127
+ handle_exception(e, level: :warn, operation: 'crypt.jwks.http_get', url: url) if respond_to?(:handle_exception)
128
+ raise Legion::Crypt::JWT::Error, "failed to fetch JWKS: #{e.message}"
129
+ end
125
130
 
126
131
  raise
127
132
  end
@@ -40,6 +40,7 @@ module Legion
40
40
  revoke_expired_lease(name)
41
41
 
42
42
  begin
43
+ log.info("LeaseManager: fetching lease '#{name}' from #{path}")
43
44
  response = logical.read(path)
44
45
  unless response
45
46
  log.warn("LeaseManager: no data at '#{name}' (#{path}) — path may not exist or role not configured")
@@ -47,8 +48,9 @@ module Legion
47
48
  end
48
49
 
49
50
  log_lease_response(name, response)
50
- cache_lease(name, response)
51
- log.info("LeaseManager: fetched lease for '#{name}' from #{path}")
51
+ cache_lease(name, response, path: path)
52
+ log.info("LeaseManager: fetched lease '#{name}' from #{path} " \
53
+ "(lease_id=#{response.lease_id.to_s[0..11]}... ttl=#{response.lease_duration}s renewable=#{response.renewable?})")
52
54
  rescue StandardError => e
53
55
  handle_exception(e, level: :warn, operation: 'crypt.lease_manager.start', lease_name: name, path: path)
54
56
  log.warn("LeaseManager: failed to fetch lease '#{name}' from #{path}: #{e.message}")
@@ -97,6 +99,79 @@ module Legion
97
99
  log.info("Lease '#{name}' rotated — updated #{refs.size} settings reference(s)")
98
100
  end
99
101
 
102
+ # Public Vault client accessors used by Crypt for bootstrap/swap operations.
103
+ # Delegates to the configured vault_client or falls back to ::Vault.
104
+ def vault_logical
105
+ logical
106
+ end
107
+
108
+ def vault_sys
109
+ sys
110
+ end
111
+
112
+ def register_dynamic_lease(name:, path:, response:, settings_refs:)
113
+ register_at_exit_hook
114
+
115
+ @state_mutex.synchronize do
116
+ @lease_cache[name] = response.data || {}
117
+ @active_leases[name] = {
118
+ lease_id: response.lease_id,
119
+ lease_duration: response.lease_duration,
120
+ expires_at: Time.now + (response.lease_duration || 0),
121
+ fetched_at: Time.now,
122
+ renewable: response.renewable?,
123
+ path: path
124
+ }
125
+ end
126
+ settings_refs.each do |ref|
127
+ register_ref(name, ref[:key], ref[:path])
128
+ end
129
+ log.info("LeaseManager: registered dynamic lease '#{name}' (path: #{path})")
130
+ end
131
+
132
+ def reissue_lease(name)
133
+ lease = @state_mutex.synchronize { @active_leases[name]&.dup }
134
+ unless lease && lease[:path]
135
+ log.warn("LeaseManager: cannot reissue lease '#{name}' — no path stored for re-read")
136
+ return
137
+ end
138
+
139
+ log.info("LeaseManager: reissuing lease '#{name}' from #{lease[:path]}")
140
+ response = logical.read(lease[:path])
141
+ unless response&.data
142
+ log.warn("LeaseManager: reissue for '#{name}' returned no data from #{lease[:path]}")
143
+ return
144
+ end
145
+
146
+ updated = @state_mutex.synchronize do
147
+ active_lease = @active_leases[name]
148
+ next false unless active_lease
149
+
150
+ @lease_cache[name] = response.data
151
+ active_lease.merge!(
152
+ lease_id: response.lease_id,
153
+ lease_duration: response.lease_duration,
154
+ expires_at: Time.now + (response.lease_duration || 0),
155
+ fetched_at: Time.now,
156
+ renewable: response.renewable?
157
+ )
158
+ true
159
+ end
160
+ unless updated
161
+ log.warn("LeaseManager: reissue for '#{name}' skipped — lease was removed during reissue (likely shutdown)")
162
+ return
163
+ end
164
+
165
+ lease_id_preview = response.lease_id.to_s[0..11]
166
+ log.info("LeaseManager: reissued lease '#{name}' " \
167
+ "(new_lease_id=#{lease_id_preview}... ttl=#{response.lease_duration}s)")
168
+ push_to_settings(name)
169
+ trigger_reconnect(name)
170
+ rescue StandardError => e
171
+ handle_exception(e, level: :warn, operation: 'crypt.lease_manager.reissue_lease', lease_name: name)
172
+ log.warn("LeaseManager: failed to reissue lease '#{name}': #{e.message}")
173
+ end
174
+
100
175
  def start_renewal_thread
101
176
  @state_mutex.synchronize do
102
177
  return if @renewal_thread&.alive?
@@ -166,7 +241,7 @@ module Legion
166
241
  @at_exit_registered = true
167
242
  end
168
243
 
169
- def cache_lease(name, response)
244
+ def cache_lease(name, response, path: nil)
170
245
  @state_mutex.synchronize do
171
246
  @lease_cache[name] = response.data || {}
172
247
  @active_leases[name] = {
@@ -174,7 +249,8 @@ module Legion
174
249
  lease_duration: response.lease_duration,
175
250
  renewable: response.renewable?,
176
251
  expires_at: Time.now + (response.lease_duration || 0),
177
- fetched_at: Time.now
252
+ fetched_at: Time.now,
253
+ path: path
178
254
  }
179
255
  end
180
256
  end
@@ -225,13 +301,15 @@ module Legion
225
301
  end
226
302
 
227
303
  def renewal_loop
304
+ log.info 'LeaseManager: renewal loop started'
228
305
  while running?
229
306
  interruptible_sleep(RENEWAL_CHECK_INTERVAL)
230
307
  renew_approaching_leases if running?
231
308
  end
309
+ log.info 'LeaseManager: renewal loop exiting'
232
310
  rescue StandardError => e
233
311
  handle_exception(e, level: :error, operation: 'crypt.lease_manager.renewal_loop')
234
- log.error("LeaseManager: renewal loop error: #{e.message}")
312
+ log.error("LeaseManager: renewal loop error: #{e.message} — restarting")
235
313
  retry if running?
236
314
  end
237
315
 
@@ -240,33 +318,73 @@ module Legion
240
318
  leases.each do |name|
241
319
  lease = @state_mutex.synchronize { @active_leases[name]&.dup }
242
320
  next unless lease
243
- next unless lease[:renewable]
244
321
  next unless approaching_expiry?(lease)
245
322
 
246
- renew_lease(name, lease)
323
+ remaining = lease[:expires_at] ? (lease[:expires_at] - Time.now).round(1) : 'unknown'
324
+ log.debug("LeaseManager: lease '#{name}' approaching expiry " \
325
+ "(remaining=#{remaining}s renewable=#{lease[:renewable]} has_path=#{!lease[:path].nil?})")
326
+
327
+ if lease[:renewable]
328
+ renew_lease(name, lease)
329
+ elsif lease[:path]
330
+ log.info("LeaseManager: lease '#{name}' is non-renewable — re-issuing from #{lease[:path]}")
331
+ reissue_lease(name)
332
+ else
333
+ log.warn("LeaseManager: lease '#{name}' is non-renewable and has no path for reissue — " \
334
+ "will expire at #{lease[:expires_at]}")
335
+ end
247
336
  end
248
337
  end
249
338
 
250
339
  def renew_lease(name, lease)
251
- response = sys.renew(lease[:lease_id])
340
+ lease_id = lease[:lease_id].to_s
341
+ if lease_id.empty?
342
+ log.warn("LeaseManager: lease '#{name}' is renewable but has no lease_id")
343
+ if lease[:path]
344
+ log.warn("LeaseManager: falling back to reissue for '#{name}' from #{lease[:path]}")
345
+ reissue_lease(name)
346
+ else
347
+ log.warn("LeaseManager: lease '#{name}' renewal failed and no path available for reissue — " \
348
+ "lease will expire at #{lease[:expires_at]}")
349
+ end
350
+ return
351
+ end
352
+
353
+ log.info("LeaseManager: renewing lease '#{name}' (lease_id=#{lease_id[0..11]}...)")
354
+ response = sys.renew(lease_id)
355
+ new_ttl = response.respond_to?(:lease_duration) ? response.lease_duration : nil
252
356
  @state_mutex.synchronize do
253
357
  current_lease = @active_leases[name]
254
358
  next unless current_lease
255
359
 
256
- current_lease[:lease_duration] = response.lease_duration if response.respond_to?(:lease_duration)
360
+ if new_ttl
361
+ current_lease[:lease_duration] = new_ttl
362
+ current_lease[:expires_at] = Time.now + new_ttl
363
+ end
257
364
  current_lease[:renewable] = response.renewable? if response.respond_to?(:renewable?)
258
- current_lease[:expires_at] = Time.now + (response.lease_duration || 0)
259
365
  end
260
- log.info("LeaseManager: renewed lease '#{name}'")
366
+ if new_ttl
367
+ log.info("LeaseManager: renewed lease '#{name}' (new_ttl=#{new_ttl}s)")
368
+ else
369
+ log.warn("LeaseManager: renewed lease '#{name}' but Vault returned no lease_duration — keeping previous TTL")
370
+ end
261
371
 
262
372
  cached_data = @state_mutex.synchronize { @lease_cache[name] }
263
373
  if response.data && response.data != cached_data
264
374
  @state_mutex.synchronize { @lease_cache[name] = response.data }
265
375
  push_to_settings(name)
376
+ log.info("LeaseManager: lease '#{name}' credentials changed during renewal — settings updated")
266
377
  end
267
378
  rescue StandardError => e
268
379
  handle_exception(e, level: :warn, operation: 'crypt.lease_manager.renew_lease', lease_name: name)
269
380
  log.warn("LeaseManager: failed to renew lease '#{name}': #{e.message}")
381
+ if lease[:path]
382
+ log.warn("LeaseManager: falling back to reissue for '#{name}' from #{lease[:path]}")
383
+ reissue_lease(name)
384
+ else
385
+ log.warn("LeaseManager: lease '#{name}' renewal failed and no path available for reissue — " \
386
+ "lease will expire at #{lease[:expires_at]}")
387
+ end
270
388
  end
271
389
 
272
390
  def lease_valid?(name)
@@ -324,6 +442,30 @@ module Legion
324
442
  log.warn("LeaseManager: failed to write setting at #{path.join('.')}: #{e.message}")
325
443
  end
326
444
 
445
+ def trigger_reconnect(name)
446
+ name = name.to_sym if name.respond_to?(:to_sym)
447
+ case name
448
+ when :rabbitmq
449
+ return unless defined?(Legion::Transport::Connection)
450
+
451
+ Legion::Transport::Connection.force_reconnect
452
+ log.info("LeaseManager: triggered transport reconnect after '#{name}' reissue")
453
+ when :postgresql
454
+ return unless defined?(Legion::Data) && Legion::Data.respond_to?(:reconnect)
455
+
456
+ Legion::Data.reconnect
457
+ log.info("LeaseManager: triggered data reconnect after '#{name}' reissue")
458
+ when :redis
459
+ return unless defined?(Legion::Cache) && Legion::Cache.respond_to?(:reconnect)
460
+
461
+ Legion::Cache.reconnect
462
+ log.info("LeaseManager: triggered cache reconnect after '#{name}' reissue")
463
+ end
464
+ rescue StandardError => e
465
+ handle_exception(e, level: :warn, operation: 'crypt.lease_manager.trigger_reconnect', lease_name: name)
466
+ log.warn("LeaseManager: reconnect for '#{name}' failed: #{e.message}")
467
+ end
468
+
327
469
  def running?
328
470
  @state_mutex.synchronize { @running }
329
471
  end
@@ -73,7 +73,9 @@ module Legion
73
73
  auth_path: 'auth/kerberos/login'
74
74
  },
75
75
  clusters: {},
76
- bootstrap_lease_ttl: 300
76
+ bootstrap_lease_ttl: 300,
77
+ dynamic_rmq_creds: false,
78
+ dynamic_pg_creds: false
77
79
  }
78
80
  end
79
81
  end
@@ -81,6 +81,7 @@ module Legion
81
81
  rescue ::Vault::HTTPError => e
82
82
  return true if e.message =~ /\b(429|472|473)\b/
83
83
 
84
+ handle_exception(e, level: :warn, operation: 'crypt.vault_cluster.cluster_healthy')
84
85
  raise
85
86
  end
86
87
 
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/logging/helper'
4
+
5
+ module Legion
6
+ module Crypt
7
+ module VaultEntity
8
+ extend Legion::Logging::Helper
9
+
10
+ # Create or lookup a Vault entity for a Legion principal.
11
+ # Returns the Vault entity ID string, or nil on failure.
12
+ def self.ensure_entity(principal_id:, canonical_name:, metadata: {})
13
+ existing = find_by_name(canonical_name)
14
+ return existing if existing
15
+
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
+ )
24
+ )
25
+ extract_id(response)
26
+ rescue StandardError => e
27
+ handle_exception(e, level: :warn, operation: 'crypt.vault_entity.ensure_entity', canonical_name: canonical_name)
28
+ nil
29
+ end
30
+
31
+ # Create an alias linking an auth method mount to the entity.
32
+ # Idempotent — swallows "already exists" 4xx errors.
33
+ 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
39
+ )
40
+ rescue ::Vault::HTTPClientError => e
41
+ if e.message.include?('already exists')
42
+ log.debug 'Vault entity alias already exists (idempotent)'
43
+ else
44
+ handle_exception(e, level: :warn, operation: 'crypt.vault_entity.ensure_alias', alias_name: alias_name)
45
+ end
46
+ nil
47
+ rescue StandardError => e
48
+ handle_exception(e, level: :warn, operation: 'crypt.vault_entity.ensure_alias', alias_name: alias_name)
49
+ nil
50
+ end
51
+
52
+ # Look up a Vault entity by its Legion canonical name.
53
+ # 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
+ 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
+ rescue StandardError => e
63
+ handle_exception(e, level: :warn, operation: 'crypt.vault_entity.find_by_name', canonical_name: canonical_name)
64
+ nil
65
+ end
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Private helpers
69
+ # ---------------------------------------------------------------------------
70
+
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).
82
+ def self.extract_id(response)
83
+ data = response&.data
84
+ return nil unless data
85
+
86
+ data[:id] || data['id']
87
+ end
88
+ private_class_method :extract_id
89
+ end
90
+ end
91
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module Crypt
5
- VERSION = '1.5.4'
5
+ VERSION = '1.5.7'
6
6
  end
7
7
  end
data/lib/legion/crypt.rb CHANGED
@@ -25,6 +25,12 @@ module Legion
25
25
  extend Legion::Crypt::VaultCluster
26
26
  extend Legion::Crypt::LdapAuth
27
27
 
28
+ RMQ_ROLE_MAP = {
29
+ agent: 'legionio-infra',
30
+ worker: 'legionio-worker',
31
+ infra: 'legionio-infra'
32
+ }.freeze
33
+
28
34
  class << self
29
35
  attr_reader :sessions
30
36
 
@@ -97,6 +103,98 @@ module Legion
97
103
  settings[:jwt] || Legion::Crypt::Settings.jwt
98
104
  end
99
105
 
106
+ def dynamic_rmq_creds?
107
+ Legion::Settings.dig(:crypt, :vault, :dynamic_rmq_creds) == true
108
+ end
109
+
110
+ def fetch_bootstrap_rmq_creds
111
+ return unless vault_connected? && dynamic_rmq_creds?
112
+
113
+ Legion::Settings.merge_settings('transport', Legion::Transport::Settings.default) if defined?(Legion::Transport::Settings)
114
+
115
+ response = LeaseManager.instance.vault_logical.read('rabbitmq/creds/legionio-bootstrap')
116
+ return unless response&.data
117
+
118
+ bootstrap_lease_ttl = Legion::Settings.dig(:crypt, :vault, :bootstrap_lease_ttl).to_i
119
+ bootstrap_lease_ttl = 300 if bootstrap_lease_ttl <= 0
120
+
121
+ @bootstrap_lease_id = response.lease_id
122
+ @bootstrap_lease_expires = Time.now + [response.lease_duration, bootstrap_lease_ttl].min
123
+
124
+ settings = Legion::Settings.loader.settings
125
+ settings[:transport] ||= {}
126
+ settings[:transport][:connection] ||= {}
127
+ conn = settings[:transport][:connection]
128
+ username = response.data[:username] || response.data['username']
129
+ password = response.data[:password] || response.data['password']
130
+
131
+ unless username && password
132
+ log.warn 'Bootstrap RMQ credential fetch returned nil username or password — skipping settings update'
133
+ return
134
+ end
135
+
136
+ conn[:user] = username
137
+ conn[:password] = password
138
+
139
+ log.info "Bootstrap RMQ credentials acquired (lease: #{@bootstrap_lease_id[0..7]}...)"
140
+ rescue StandardError => e
141
+ handle_exception(e, level: :warn, operation: 'crypt.fetch_bootstrap_rmq_creds')
142
+ log.warn "Bootstrap RMQ credential fetch failed: #{e.message}"
143
+ end
144
+
145
+ def swap_to_identity_creds(mode:)
146
+ return unless vault_connected? && dynamic_rmq_creds?
147
+ return if mode == :lite
148
+
149
+ role = RMQ_ROLE_MAP.fetch(mode, "legionio-#{mode}")
150
+ response = LeaseManager.instance.vault_logical.read("rabbitmq/creds/#{role}")
151
+ raise "Failed to fetch identity-scoped RMQ creds for role #{role}" unless response&.data
152
+
153
+ LeaseManager.instance.register_dynamic_lease(
154
+ name: :rabbitmq,
155
+ path: "rabbitmq/creds/#{role}",
156
+ response: response,
157
+ settings_refs: [
158
+ { path: %i[transport connection user], key: :username },
159
+ { path: %i[transport connection password], key: :password }
160
+ ]
161
+ )
162
+
163
+ settings = Legion::Settings.loader.settings
164
+ settings[:transport] ||= {}
165
+ settings[:transport][:connection] ||= {}
166
+ conn = settings[:transport][:connection]
167
+ username = response.data[:username] || response.data['username']
168
+ password = response.data[:password] || response.data['password']
169
+ raise "Identity-scoped RMQ creds for role #{role} missing username or password" unless username && password
170
+
171
+ conn[:user] = username
172
+ conn[:password] = password
173
+
174
+ if defined?(Legion::Transport::Connection)
175
+ Legion::Transport::Connection.force_reconnect
176
+ raise 'Transport reconnect failed after credential swap — bootstrap lease NOT revoked' unless Legion::Transport::Connection.session_open?
177
+
178
+ log.info "Transport reconnected with identity-scoped creds (role: #{role})"
179
+ end
180
+
181
+ revoke_bootstrap_lease
182
+ end
183
+
184
+ def revoke_bootstrap_lease
185
+ return unless @bootstrap_lease_id
186
+
187
+ LeaseManager.instance.vault_sys.revoke(@bootstrap_lease_id)
188
+ log.info "Bootstrap RMQ lease revoked (#{@bootstrap_lease_id[0..7]}...)"
189
+ @bootstrap_lease_id = nil
190
+ @bootstrap_lease_expires = nil
191
+ rescue StandardError => e
192
+ handle_exception(e, level: :warn, operation: 'crypt.revoke_bootstrap_lease')
193
+ log.warn "Bootstrap lease revocation failed: #{e.message} — lease will expire naturally"
194
+ @bootstrap_lease_id = nil
195
+ @bootstrap_lease_expires = nil
196
+ end
197
+
100
198
  def vault_connected?
101
199
  return true if settings.dig(:vault, :connected) == true
102
200
  return true if respond_to?(:connected_clusters) && connected_clusters.any?
@@ -156,8 +254,10 @@ module Legion
156
254
 
157
255
  def start_lease_manager
158
256
  leases = settings.dig(:vault, :leases) || {}
159
- return if leases.empty?
160
- return unless connected_clusters.any? || settings.dig(:vault, :connected)
257
+ vault_ok = connected_clusters.any? || settings.dig(:vault, :connected)
258
+
259
+ return if leases.empty? && !dynamic_rmq_creds?
260
+ return unless vault_ok
161
261
 
162
262
  client = nil
163
263
 
@@ -167,15 +267,19 @@ module Legion
167
267
  elsif settings.dig(:vault, :connected)
168
268
  client = vault_client
169
269
  end
270
+
170
271
  lease_manager = Legion::Crypt::LeaseManager.instance
171
272
  lease_manager.start(leases, vault_client: client)
172
273
  lease_manager.start_renewal_thread
173
- fetched = lease_manager.fetched_count
174
- defined = leases.size
175
- if fetched == defined
176
- log.info "LeaseManager: #{fetched} lease(s) initialized"
177
- else
178
- log.warn "LeaseManager: #{fetched}/#{defined} lease(s) initialized (#{defined - fetched} failed)"
274
+
275
+ unless leases.empty?
276
+ fetched = lease_manager.fetched_count
277
+ defined = leases.size
278
+ if fetched == defined
279
+ log.info "LeaseManager: #{fetched} lease(s) initialized"
280
+ else
281
+ log.warn "LeaseManager: #{fetched}/#{defined} lease(s) initialized (#{defined - fetched} failed)"
282
+ end
179
283
  end
180
284
  rescue StandardError => e
181
285
  handle_exception(e, level: :warn, operation: 'crypt.start_lease_manager')
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: legion-crypt
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.4
4
+ version: 1.5.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -127,6 +127,7 @@ files:
127
127
  - lib/legion/crypt/token_renewer.rb
128
128
  - lib/legion/crypt/vault.rb
129
129
  - lib/legion/crypt/vault_cluster.rb
130
+ - lib/legion/crypt/vault_entity.rb
130
131
  - lib/legion/crypt/vault_jwt_auth.rb
131
132
  - lib/legion/crypt/vault_kerberos_auth.rb
132
133
  - lib/legion/crypt/vault_renewer.rb