legion-crypt 1.5.4 → 1.5.5

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: 85275c53d61c6172fcabf74c69df85324c94d828372894e7d29e2c550501fc88
4
+ data.tar.gz: 61a939f7fb79750ca314840c2c16766e7d325eb956e2d9e3d6196174bde1f0ea
5
5
  SHA512:
6
- metadata.gz: abf411468507856a834cc3e06df344eaecc5deebcb552a77d4cfd30ac1c091506f8b284eea79660d3dc109f2e622816c32871d4c4eda047a51ec3a9dbb689467
7
- data.tar.gz: 557b616f75af3857df71a6530ea4a7e26680163fc2274e3a09ecfb2a12de628cc9bf64b90fb8e527c8ae43b1ac741a070b3f68d281591369ab460e2e348bd4e8
6
+ metadata.gz: 3d7d33ed75be9d7263b32e9cb3830bff352dddd68fff8a8f060c26a7856a1962a8be31fa2955cabb76b6c961a7891890fae22b94c001e718b91ecdb5a2df3286
7
+ data.tar.gz: c6df0c72440d9ac6cb134bbd1498d68f51a582431d974ee42a7084e9077170125047e0d55293623109c8c14562f34b86577482d100e2d82ad06125a09ade17d1
data/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.5.5] - 2026-04-07
6
+
7
+ ### Added
8
+ - `RMQ_ROLE_MAP` constant mapping `:agent`/`:infra` → `'legionio-infra'` and `:worker` → `'legionio-worker'` for Vault RabbitMQ role selection (Phase 5 credential scoping)
9
+ - `dynamic_rmq_creds?` helper reads `Settings[:crypt][:vault][:dynamic_rmq_creds]` flag
10
+ - `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
11
+ - `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)
12
+ - `revoke_bootstrap_lease` — revokes `@bootstrap_lease_id` via `LeaseManager#vault_sys`; non-fatal on failure; idempotent
13
+ - `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
14
+ - `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
15
+ - `LeaseManager#vault_logical` and `LeaseManager#vault_sys` — public delegators to the private `logical`/`sys` methods for use by `Crypt` bootstrap/swap operations
16
+ - `dynamic_rmq_creds: false` and `dynamic_pg_creds: false` defaults added to vault settings
17
+
18
+ ### Changed
19
+ - `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
20
+
5
21
  ## [1.5.4] - 2026-04-06
6
22
 
7
23
  ### Added
@@ -97,6 +97,67 @@ module Legion
97
97
  log.info("Lease '#{name}' rotated — updated #{refs.size} settings reference(s)")
98
98
  end
99
99
 
100
+ # Public Vault client accessors used by Crypt for bootstrap/swap operations.
101
+ # Delegates to the configured vault_client or falls back to ::Vault.
102
+ def vault_logical
103
+ logical
104
+ end
105
+
106
+ def vault_sys
107
+ sys
108
+ end
109
+
110
+ def register_dynamic_lease(name:, path:, response:, settings_refs:)
111
+ register_at_exit_hook
112
+
113
+ @state_mutex.synchronize do
114
+ @lease_cache[name] = response.data || {}
115
+ @active_leases[name] = {
116
+ lease_id: response.lease_id,
117
+ lease_duration: response.lease_duration,
118
+ expires_at: Time.now + (response.lease_duration || 0),
119
+ fetched_at: Time.now,
120
+ renewable: response.renewable?,
121
+ path: path
122
+ }
123
+ end
124
+ settings_refs.each do |ref|
125
+ register_ref(name, ref[:key], ref[:path])
126
+ end
127
+ log.info("LeaseManager: registered dynamic lease '#{name}' (path: #{path})")
128
+ end
129
+
130
+ def reissue_lease(name)
131
+ lease = @state_mutex.synchronize { @active_leases[name]&.dup }
132
+ return unless lease && lease[:path]
133
+
134
+ response = logical.read(lease[:path])
135
+ return unless response&.data
136
+
137
+ @state_mutex.synchronize do
138
+ active_lease = @active_leases[name]
139
+ next unless active_lease
140
+
141
+ @lease_cache[name] = response.data
142
+ active_lease.merge!(
143
+ lease_id: response.lease_id,
144
+ lease_duration: response.lease_duration,
145
+ expires_at: Time.now + (response.lease_duration || 0),
146
+ fetched_at: Time.now,
147
+ renewable: response.renewable?
148
+ )
149
+ end
150
+ push_to_settings(name)
151
+
152
+ return unless name == :rabbitmq && defined?(Legion::Transport::Connection)
153
+
154
+ Legion::Transport::Connection.force_reconnect
155
+ log.info("LeaseManager: reissued lease '#{name}' and triggered transport reconnect")
156
+ rescue StandardError => e
157
+ handle_exception(e, level: :warn, operation: 'crypt.lease_manager.reissue_lease', lease_name: name)
158
+ log.warn("LeaseManager: failed to reissue lease '#{name}': #{e.message}")
159
+ end
160
+
100
161
  def start_renewal_thread
101
162
  @state_mutex.synchronize do
102
163
  return if @renewal_thread&.alive?
@@ -240,10 +301,14 @@ module Legion
240
301
  leases.each do |name|
241
302
  lease = @state_mutex.synchronize { @active_leases[name]&.dup }
242
303
  next unless lease
243
- next unless lease[:renewable]
244
304
  next unless approaching_expiry?(lease)
245
305
 
246
- renew_lease(name, lease)
306
+ if lease[:renewable]
307
+ renew_lease(name, lease)
308
+ elsif lease[:path]
309
+ log.info("LeaseManager: lease '#{name}' is non-renewable and approaching expiry — re-issuing")
310
+ reissue_lease(name)
311
+ end
247
312
  end
248
313
  end
249
314
 
@@ -267,6 +332,7 @@ module Legion
267
332
  rescue StandardError => e
268
333
  handle_exception(e, level: :warn, operation: 'crypt.lease_manager.renew_lease', lease_name: name)
269
334
  log.warn("LeaseManager: failed to renew lease '#{name}': #{e.message}")
335
+ reissue_lease(name) if lease[:path]
270
336
  end
271
337
 
272
338
  def lease_valid?(name)
@@ -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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module Crypt
5
- VERSION = '1.5.4'
5
+ VERSION = '1.5.5'
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,96 @@ 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
+ log.warn "Bootstrap RMQ credential fetch failed: #{e.message}"
142
+ end
143
+
144
+ def swap_to_identity_creds(mode:)
145
+ return unless vault_connected? && dynamic_rmq_creds?
146
+ return if mode == :lite
147
+
148
+ role = RMQ_ROLE_MAP.fetch(mode, "legionio-#{mode}")
149
+ response = LeaseManager.instance.vault_logical.read("rabbitmq/creds/#{role}")
150
+ raise "Failed to fetch identity-scoped RMQ creds for role #{role}" unless response&.data
151
+
152
+ LeaseManager.instance.register_dynamic_lease(
153
+ name: :rabbitmq,
154
+ path: "rabbitmq/creds/#{role}",
155
+ response: response,
156
+ settings_refs: [
157
+ { path: %i[transport connection user], key: :username },
158
+ { path: %i[transport connection password], key: :password }
159
+ ]
160
+ )
161
+
162
+ settings = Legion::Settings.loader.settings
163
+ settings[:transport] ||= {}
164
+ settings[:transport][:connection] ||= {}
165
+ conn = settings[:transport][:connection]
166
+ username = response.data[:username] || response.data['username']
167
+ password = response.data[:password] || response.data['password']
168
+ raise "Identity-scoped RMQ creds for role #{role} missing username or password" unless username && password
169
+
170
+ conn[:user] = username
171
+ conn[:password] = password
172
+
173
+ if defined?(Legion::Transport::Connection)
174
+ Legion::Transport::Connection.force_reconnect
175
+ raise 'Transport reconnect failed after credential swap — bootstrap lease NOT revoked' unless Legion::Transport::Connection.session_open?
176
+
177
+ log.info "Transport reconnected with identity-scoped creds (role: #{role})"
178
+ end
179
+
180
+ revoke_bootstrap_lease
181
+ end
182
+
183
+ def revoke_bootstrap_lease
184
+ return unless @bootstrap_lease_id
185
+
186
+ LeaseManager.instance.vault_sys.revoke(@bootstrap_lease_id)
187
+ log.info "Bootstrap RMQ lease revoked (#{@bootstrap_lease_id[0..7]}...)"
188
+ @bootstrap_lease_id = nil
189
+ @bootstrap_lease_expires = nil
190
+ rescue StandardError => e
191
+ log.warn "Bootstrap lease revocation failed: #{e.message} — lease will expire naturally"
192
+ @bootstrap_lease_id = nil
193
+ @bootstrap_lease_expires = nil
194
+ end
195
+
100
196
  def vault_connected?
101
197
  return true if settings.dig(:vault, :connected) == true
102
198
  return true if respond_to?(:connected_clusters) && connected_clusters.any?
@@ -156,8 +252,10 @@ module Legion
156
252
 
157
253
  def start_lease_manager
158
254
  leases = settings.dig(:vault, :leases) || {}
159
- return if leases.empty?
160
- return unless connected_clusters.any? || settings.dig(:vault, :connected)
255
+ vault_ok = connected_clusters.any? || settings.dig(:vault, :connected)
256
+
257
+ return if leases.empty? && !dynamic_rmq_creds?
258
+ return unless vault_ok
161
259
 
162
260
  client = nil
163
261
 
@@ -167,15 +265,19 @@ module Legion
167
265
  elsif settings.dig(:vault, :connected)
168
266
  client = vault_client
169
267
  end
268
+
170
269
  lease_manager = Legion::Crypt::LeaseManager.instance
171
270
  lease_manager.start(leases, vault_client: client)
172
271
  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)"
272
+
273
+ unless leases.empty?
274
+ fetched = lease_manager.fetched_count
275
+ defined = leases.size
276
+ if fetched == defined
277
+ log.info "LeaseManager: #{fetched} lease(s) initialized"
278
+ else
279
+ log.warn "LeaseManager: #{fetched}/#{defined} lease(s) initialized (#{defined - fetched} failed)"
280
+ end
179
281
  end
180
282
  rescue StandardError => e
181
283
  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.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity