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 +4 -4
- data/CHANGELOG.md +41 -0
- data/CLAUDE.md +11 -1
- data/README.md +1 -1
- data/lib/legion/crypt/jwks_client.rb +6 -1
- data/lib/legion/crypt/lease_manager.rb +153 -11
- data/lib/legion/crypt/settings.rb +3 -1
- data/lib/legion/crypt/vault_cluster.rb +1 -0
- data/lib/legion/crypt/vault_entity.rb +91 -0
- data/lib/legion/crypt/version.rb +1 -1
- data/lib/legion/crypt.rb +112 -8
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3b695e8ac3853b730218da5e1b40b9f7bdc9c7fbffbd1ec4eddccd5539fe296a
|
|
4
|
+
data.tar.gz: 544bf702cdcc9114b7fa0b99c14141fa6f58b37ee9dbde767e953076ffaedb4c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|
data/lib/legion/crypt/version.rb
CHANGED
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
|
-
|
|
160
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
+
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
|