legion-crypt 1.5.5 → 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 +25 -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 +96 -20
- 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 +2 -0
- 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,31 @@
|
|
|
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
|
+
|
|
5
30
|
## [1.5.5] - 2026-04-07
|
|
6
31
|
|
|
7
32
|
### 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}")
|
|
@@ -129,14 +131,21 @@ module Legion
|
|
|
129
131
|
|
|
130
132
|
def reissue_lease(name)
|
|
131
133
|
lease = @state_mutex.synchronize { @active_leases[name]&.dup }
|
|
132
|
-
|
|
134
|
+
unless lease && lease[:path]
|
|
135
|
+
log.warn("LeaseManager: cannot reissue lease '#{name}' — no path stored for re-read")
|
|
136
|
+
return
|
|
137
|
+
end
|
|
133
138
|
|
|
139
|
+
log.info("LeaseManager: reissuing lease '#{name}' from #{lease[:path]}")
|
|
134
140
|
response = logical.read(lease[:path])
|
|
135
|
-
|
|
141
|
+
unless response&.data
|
|
142
|
+
log.warn("LeaseManager: reissue for '#{name}' returned no data from #{lease[:path]}")
|
|
143
|
+
return
|
|
144
|
+
end
|
|
136
145
|
|
|
137
|
-
@state_mutex.synchronize do
|
|
146
|
+
updated = @state_mutex.synchronize do
|
|
138
147
|
active_lease = @active_leases[name]
|
|
139
|
-
next unless active_lease
|
|
148
|
+
next false unless active_lease
|
|
140
149
|
|
|
141
150
|
@lease_cache[name] = response.data
|
|
142
151
|
active_lease.merge!(
|
|
@@ -146,13 +155,18 @@ module Legion
|
|
|
146
155
|
fetched_at: Time.now,
|
|
147
156
|
renewable: response.renewable?
|
|
148
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
|
|
149
163
|
end
|
|
150
|
-
push_to_settings(name)
|
|
151
|
-
|
|
152
|
-
return unless name == :rabbitmq && defined?(Legion::Transport::Connection)
|
|
153
164
|
|
|
154
|
-
|
|
155
|
-
log.info("LeaseManager: reissued lease '#{name}'
|
|
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)
|
|
156
170
|
rescue StandardError => e
|
|
157
171
|
handle_exception(e, level: :warn, operation: 'crypt.lease_manager.reissue_lease', lease_name: name)
|
|
158
172
|
log.warn("LeaseManager: failed to reissue lease '#{name}': #{e.message}")
|
|
@@ -227,7 +241,7 @@ module Legion
|
|
|
227
241
|
@at_exit_registered = true
|
|
228
242
|
end
|
|
229
243
|
|
|
230
|
-
def cache_lease(name, response)
|
|
244
|
+
def cache_lease(name, response, path: nil)
|
|
231
245
|
@state_mutex.synchronize do
|
|
232
246
|
@lease_cache[name] = response.data || {}
|
|
233
247
|
@active_leases[name] = {
|
|
@@ -235,7 +249,8 @@ module Legion
|
|
|
235
249
|
lease_duration: response.lease_duration,
|
|
236
250
|
renewable: response.renewable?,
|
|
237
251
|
expires_at: Time.now + (response.lease_duration || 0),
|
|
238
|
-
fetched_at: Time.now
|
|
252
|
+
fetched_at: Time.now,
|
|
253
|
+
path: path
|
|
239
254
|
}
|
|
240
255
|
end
|
|
241
256
|
end
|
|
@@ -286,13 +301,15 @@ module Legion
|
|
|
286
301
|
end
|
|
287
302
|
|
|
288
303
|
def renewal_loop
|
|
304
|
+
log.info 'LeaseManager: renewal loop started'
|
|
289
305
|
while running?
|
|
290
306
|
interruptible_sleep(RENEWAL_CHECK_INTERVAL)
|
|
291
307
|
renew_approaching_leases if running?
|
|
292
308
|
end
|
|
309
|
+
log.info 'LeaseManager: renewal loop exiting'
|
|
293
310
|
rescue StandardError => e
|
|
294
311
|
handle_exception(e, level: :error, operation: 'crypt.lease_manager.renewal_loop')
|
|
295
|
-
log.error("LeaseManager: renewal loop error: #{e.message}")
|
|
312
|
+
log.error("LeaseManager: renewal loop error: #{e.message} — restarting")
|
|
296
313
|
retry if running?
|
|
297
314
|
end
|
|
298
315
|
|
|
@@ -303,36 +320,71 @@ module Legion
|
|
|
303
320
|
next unless lease
|
|
304
321
|
next unless approaching_expiry?(lease)
|
|
305
322
|
|
|
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
|
+
|
|
306
327
|
if lease[:renewable]
|
|
307
328
|
renew_lease(name, lease)
|
|
308
329
|
elsif lease[:path]
|
|
309
|
-
log.info("LeaseManager: lease '#{name}' is non-renewable
|
|
330
|
+
log.info("LeaseManager: lease '#{name}' is non-renewable — re-issuing from #{lease[:path]}")
|
|
310
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]}")
|
|
311
335
|
end
|
|
312
336
|
end
|
|
313
337
|
end
|
|
314
338
|
|
|
315
339
|
def renew_lease(name, lease)
|
|
316
|
-
|
|
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
|
|
317
356
|
@state_mutex.synchronize do
|
|
318
357
|
current_lease = @active_leases[name]
|
|
319
358
|
next unless current_lease
|
|
320
359
|
|
|
321
|
-
|
|
360
|
+
if new_ttl
|
|
361
|
+
current_lease[:lease_duration] = new_ttl
|
|
362
|
+
current_lease[:expires_at] = Time.now + new_ttl
|
|
363
|
+
end
|
|
322
364
|
current_lease[:renewable] = response.renewable? if response.respond_to?(:renewable?)
|
|
323
|
-
current_lease[:expires_at] = Time.now + (response.lease_duration || 0)
|
|
324
365
|
end
|
|
325
|
-
|
|
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
|
|
326
371
|
|
|
327
372
|
cached_data = @state_mutex.synchronize { @lease_cache[name] }
|
|
328
373
|
if response.data && response.data != cached_data
|
|
329
374
|
@state_mutex.synchronize { @lease_cache[name] = response.data }
|
|
330
375
|
push_to_settings(name)
|
|
376
|
+
log.info("LeaseManager: lease '#{name}' credentials changed during renewal — settings updated")
|
|
331
377
|
end
|
|
332
378
|
rescue StandardError => e
|
|
333
379
|
handle_exception(e, level: :warn, operation: 'crypt.lease_manager.renew_lease', lease_name: name)
|
|
334
380
|
log.warn("LeaseManager: failed to renew lease '#{name}': #{e.message}")
|
|
335
|
-
|
|
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
|
|
336
388
|
end
|
|
337
389
|
|
|
338
390
|
def lease_valid?(name)
|
|
@@ -390,6 +442,30 @@ module Legion
|
|
|
390
442
|
log.warn("LeaseManager: failed to write setting at #{path.join('.')}: #{e.message}")
|
|
391
443
|
end
|
|
392
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
|
+
|
|
393
469
|
def running?
|
|
394
470
|
@state_mutex.synchronize { @running }
|
|
395
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
|
@@ -138,6 +138,7 @@ module Legion
|
|
|
138
138
|
|
|
139
139
|
log.info "Bootstrap RMQ credentials acquired (lease: #{@bootstrap_lease_id[0..7]}...)"
|
|
140
140
|
rescue StandardError => e
|
|
141
|
+
handle_exception(e, level: :warn, operation: 'crypt.fetch_bootstrap_rmq_creds')
|
|
141
142
|
log.warn "Bootstrap RMQ credential fetch failed: #{e.message}"
|
|
142
143
|
end
|
|
143
144
|
|
|
@@ -188,6 +189,7 @@ module Legion
|
|
|
188
189
|
@bootstrap_lease_id = nil
|
|
189
190
|
@bootstrap_lease_expires = nil
|
|
190
191
|
rescue StandardError => e
|
|
192
|
+
handle_exception(e, level: :warn, operation: 'crypt.revoke_bootstrap_lease')
|
|
191
193
|
log.warn "Bootstrap lease revocation failed: #{e.message} — lease will expire naturally"
|
|
192
194
|
@bootstrap_lease_id = nil
|
|
193
195
|
@bootstrap_lease_expires = nil
|
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
|