legion-crypt 1.2.0 → 1.4.0

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.
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'uri'
5
+ require 'json'
6
+ require 'openssl'
7
+ require 'jwt'
8
+
9
+ module Legion
10
+ module Crypt
11
+ module JwksClient
12
+ CACHE_TTL = 3600
13
+
14
+ @cache = {}
15
+ @mutex = Mutex.new
16
+
17
+ class << self
18
+ def fetch_keys(jwks_url)
19
+ @mutex.synchronize do
20
+ response = http_get(jwks_url)
21
+ jwks_data = parse_response(response)
22
+ keys = parse_jwks(jwks_data)
23
+
24
+ @cache[jwks_url] = { keys: keys, fetched_at: Time.now }
25
+ keys
26
+ end
27
+ end
28
+
29
+ def find_key(jwks_url, kid)
30
+ cached = @mutex.synchronize { @cache[jwks_url] }
31
+
32
+ if cached && !expired?(cached[:fetched_at])
33
+ key = cached[:keys][kid]
34
+ return key if key
35
+ end
36
+
37
+ # Re-fetch once on cache miss or expiry
38
+ keys = fetch_keys(jwks_url)
39
+ key = keys[kid]
40
+ return key if key
41
+
42
+ raise Legion::Crypt::JWT::InvalidTokenError, "signing key not found: #{kid}"
43
+ end
44
+
45
+ def clear_cache
46
+ @mutex.synchronize { @cache = {} }
47
+ end
48
+
49
+ private
50
+
51
+ def expired?(fetched_at)
52
+ Time.now - fetched_at > CACHE_TTL
53
+ end
54
+
55
+ def http_get(url)
56
+ uri = URI.parse(url)
57
+ http = Net::HTTP.new(uri.host, uri.port)
58
+ http.use_ssl = uri.scheme == 'https'
59
+ http.open_timeout = 10
60
+ http.read_timeout = 10
61
+
62
+ request = Net::HTTP::Get.new(uri.request_uri)
63
+ response = http.request(request)
64
+
65
+ raise Legion::Crypt::JWT::Error, "failed to fetch JWKS: HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
66
+
67
+ response.body
68
+ rescue StandardError => e
69
+ raise Legion::Crypt::JWT::Error, "failed to fetch JWKS: #{e.message}" unless e.is_a?(Legion::Crypt::JWT::Error)
70
+
71
+ raise
72
+ end
73
+
74
+ def parse_response(body)
75
+ parsed = ::JSON.parse(body)
76
+ raise Legion::Crypt::JWT::Error, 'invalid JWKS response: missing keys' unless parsed.is_a?(Hash) && parsed['keys'].is_a?(Array)
77
+
78
+ parsed
79
+ rescue ::JSON::ParserError => e
80
+ raise Legion::Crypt::JWT::Error, "invalid JWKS response: #{e.message}"
81
+ end
82
+
83
+ def parse_jwks(jwks_data)
84
+ keys = {}
85
+
86
+ jwks_data['keys'].each do |jwk_hash|
87
+ kid = jwk_hash['kid']
88
+ next unless kid
89
+
90
+ jwk = ::JWT::JWK.new(jwk_hash)
91
+ keys[kid] = jwk.public_key
92
+ rescue StandardError
93
+ # Skip malformed keys, continue with valid ones
94
+ next
95
+ end
96
+
97
+ keys
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jwt'
4
+ require 'securerandom'
5
+ require 'legion/crypt/jwks_client'
6
+
7
+ module Legion
8
+ module Crypt
9
+ module JWT
10
+ class Error < StandardError; end
11
+ class ExpiredTokenError < Error; end
12
+ class InvalidTokenError < Error; end
13
+ class DecodeError < Error; end
14
+
15
+ SUPPORTED_ALGORITHMS = %w[HS256 RS256].freeze
16
+
17
+ def self.issue(payload, signing_key:, algorithm: 'HS256', ttl: 3600, issuer: 'legion')
18
+ validate_algorithm!(algorithm)
19
+
20
+ now = Time.now.to_i
21
+ claims = {
22
+ iss: issuer,
23
+ iat: now,
24
+ exp: now + ttl,
25
+ jti: SecureRandom.uuid
26
+ }.merge(payload)
27
+
28
+ ::JWT.encode(claims, signing_key, algorithm)
29
+ end
30
+
31
+ def self.verify(token, verification_key:, **opts)
32
+ algorithm = opts.fetch(:algorithm, 'HS256')
33
+ verify_expiration = opts.fetch(:verify_expiration, true)
34
+ verify_issuer = opts.fetch(:verify_issuer, true)
35
+ issuer = opts.fetch(:issuer, 'legion')
36
+
37
+ validate_algorithm!(algorithm)
38
+
39
+ decode_opts = {
40
+ algorithm: algorithm,
41
+ verify_expiration: verify_expiration,
42
+ verify_iss: verify_issuer
43
+ }
44
+ decode_opts[:iss] = issuer if verify_issuer
45
+
46
+ payload, _header = ::JWT.decode(token, verification_key, true, decode_opts)
47
+ symbolize_keys(payload)
48
+ rescue ::JWT::ExpiredSignature
49
+ raise ExpiredTokenError, 'token has expired'
50
+ rescue ::JWT::VerificationError, ::JWT::IncorrectAlgorithm
51
+ raise InvalidTokenError, 'token signature verification failed'
52
+ rescue ::JWT::DecodeError => e
53
+ raise DecodeError, "failed to decode token: #{e.message}"
54
+ end
55
+
56
+ def self.decode(token)
57
+ payload, _header = ::JWT.decode(token, nil, false)
58
+ symbolize_keys(payload)
59
+ rescue ::JWT::DecodeError => e
60
+ raise DecodeError, "failed to decode token: #{e.message}"
61
+ end
62
+
63
+ def self.verify_with_jwks(token, jwks_url:, **opts)
64
+ header = decode_header(token)
65
+ kid = header['kid']
66
+ algorithm = header['alg'] || 'RS256'
67
+
68
+ raise InvalidTokenError, 'token header missing kid' unless kid
69
+
70
+ validate_algorithm!(algorithm)
71
+
72
+ public_key = Legion::Crypt::JwksClient.find_key(jwks_url, kid)
73
+
74
+ verify_expiration = opts.fetch(:verify_expiration, true)
75
+ issuers = opts[:issuers]
76
+ audience = opts[:audience]
77
+
78
+ decode_opts = {
79
+ algorithm: algorithm,
80
+ verify_expiration: verify_expiration
81
+ }
82
+
83
+ if issuers
84
+ decode_opts[:verify_iss] = true
85
+ decode_opts[:iss] = issuers
86
+ end
87
+
88
+ if audience
89
+ decode_opts[:verify_aud] = true
90
+ decode_opts[:aud] = audience
91
+ end
92
+
93
+ payload, _header = ::JWT.decode(token, public_key, true, decode_opts)
94
+ symbolize_keys(payload)
95
+ rescue ::JWT::ExpiredSignature
96
+ raise ExpiredTokenError, 'token has expired'
97
+ rescue ::JWT::VerificationError, ::JWT::IncorrectAlgorithm
98
+ raise InvalidTokenError, 'token signature verification failed'
99
+ rescue ::JWT::InvalidIssuerError
100
+ raise InvalidTokenError, 'token issuer not allowed'
101
+ rescue ::JWT::InvalidAudError
102
+ raise InvalidTokenError, 'token audience mismatch'
103
+ rescue ::JWT::DecodeError => e
104
+ raise DecodeError, "failed to decode token: #{e.message}"
105
+ end
106
+
107
+ def self.decode_header(token)
108
+ parts = token.to_s.split('.')
109
+ raise DecodeError, 'invalid token format' unless parts.size == 3
110
+
111
+ header_json = Base64.urlsafe_decode64(parts[0])
112
+ ::JSON.parse(header_json)
113
+ rescue ::JSON::ParserError, ArgumentError => e
114
+ raise DecodeError, "failed to decode token header: #{e.message}"
115
+ end
116
+
117
+ def self.validate_algorithm!(algorithm)
118
+ return if SUPPORTED_ALGORITHMS.include?(algorithm)
119
+
120
+ raise ArgumentError, "unsupported algorithm: #{algorithm}. Supported: #{SUPPORTED_ALGORITHMS.join(', ')}"
121
+ end
122
+
123
+ def self.symbolize_keys(hash)
124
+ hash.transform_keys(&:to_sym)
125
+ end
126
+
127
+ private_class_method :validate_algorithm!, :symbolize_keys, :decode_header
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'singleton'
4
+
5
+ module Legion
6
+ module Crypt
7
+ class LeaseManager
8
+ include Singleton
9
+
10
+ RENEWAL_CHECK_INTERVAL = 5
11
+
12
+ def initialize
13
+ @lease_cache = {}
14
+ @active_leases = {}
15
+ @refs = {}
16
+ @running = false
17
+ @renewal_thread = nil
18
+ end
19
+
20
+ def start(definitions)
21
+ return if definitions.nil? || definitions.empty?
22
+
23
+ definitions.each do |name, opts|
24
+ path = opts['path'] || opts[:path]
25
+ next unless path
26
+
27
+ begin
28
+ response = ::Vault.logical.read(path)
29
+ next unless response
30
+
31
+ @lease_cache[name] = response.data || {}
32
+ @active_leases[name] = {
33
+ lease_id: response.lease_id,
34
+ lease_duration: response.lease_duration,
35
+ renewable: response.renewable,
36
+ expires_at: Time.now + (response.lease_duration || 0),
37
+ fetched_at: Time.now
38
+ }
39
+ log_debug("LeaseManager: fetched lease for '#{name}' from #{path}")
40
+ rescue StandardError => e
41
+ log_warn("LeaseManager: failed to fetch lease '#{name}' from #{path}: #{e.message}")
42
+ end
43
+ end
44
+ end
45
+
46
+ def fetch(name, key)
47
+ data = @lease_cache[name]
48
+ return nil unless data
49
+
50
+ data[key.to_sym] || data[key.to_s]
51
+ end
52
+
53
+ def lease_data(name)
54
+ @lease_cache[name]
55
+ end
56
+
57
+ attr_reader :active_leases
58
+
59
+ def register_ref(name, key, path)
60
+ @refs[name] ||= {}
61
+ @refs[name][key] = path
62
+ end
63
+
64
+ def push_to_settings(name)
65
+ refs = @refs[name]
66
+ return if refs.nil? || refs.empty?
67
+
68
+ data = @lease_cache[name]
69
+ return unless data
70
+
71
+ refs.each do |key, path|
72
+ value = data[key.to_sym] || data[key.to_s]
73
+ write_setting(path, value)
74
+ end
75
+
76
+ log_debug("Lease '#{name}' rotated — updated #{refs.size} settings reference(s)")
77
+ end
78
+
79
+ def start_renewal_thread
80
+ return if renewal_thread_alive?
81
+
82
+ @running = true
83
+ @renewal_thread = Thread.new { renewal_loop }
84
+ end
85
+
86
+ def renewal_thread_alive?
87
+ @renewal_thread&.alive? || false
88
+ end
89
+
90
+ def shutdown
91
+ stop_renewal_thread
92
+
93
+ @active_leases.each do |name, meta|
94
+ lease_id = meta[:lease_id]
95
+ next if lease_id.nil? || lease_id.empty?
96
+
97
+ begin
98
+ ::Vault.sys.revoke(lease_id)
99
+ log_debug("LeaseManager: revoked lease '#{name}' (#{lease_id})")
100
+ rescue StandardError => e
101
+ log_warn("LeaseManager: failed to revoke lease '#{name}' (#{lease_id}): #{e.message}")
102
+ end
103
+ end
104
+
105
+ @lease_cache.clear
106
+ @active_leases.clear
107
+ @refs.clear
108
+ end
109
+
110
+ def reset!
111
+ @running = false
112
+ @lease_cache.clear
113
+ @active_leases.clear
114
+ @refs.clear
115
+ end
116
+
117
+ private
118
+
119
+ def stop_renewal_thread
120
+ @running = false
121
+ if @renewal_thread&.alive?
122
+ @renewal_thread.kill
123
+ @renewal_thread.join(2)
124
+ end
125
+ @renewal_thread = nil
126
+ end
127
+
128
+ def renewal_loop
129
+ while @running
130
+ sleep(RENEWAL_CHECK_INTERVAL)
131
+ renew_approaching_leases if @running
132
+ end
133
+ rescue StandardError => e
134
+ log_warn("LeaseManager: renewal loop error: #{e.message}")
135
+ retry if @running
136
+ end
137
+
138
+ def renew_approaching_leases
139
+ @active_leases.each do |name, lease|
140
+ next unless lease[:renewable]
141
+ next unless approaching_expiry?(lease)
142
+
143
+ renew_lease(name, lease)
144
+ end
145
+ end
146
+
147
+ def renew_lease(name, lease)
148
+ response = ::Vault.sys.renew(lease[:lease_id])
149
+ lease[:expires_at] = Time.now + (response.lease_duration || 0)
150
+
151
+ if response.data && response.data != @lease_cache[name]
152
+ @lease_cache[name] = response.data
153
+ push_to_settings(name)
154
+ end
155
+ rescue StandardError => e
156
+ log_warn("LeaseManager: failed to renew lease '#{name}': #{e.message}")
157
+ end
158
+
159
+ def approaching_expiry?(lease)
160
+ expires_at = lease[:expires_at]
161
+ lease_duration = lease[:lease_duration]
162
+
163
+ return true if expires_at.nil? || lease_duration.nil?
164
+
165
+ remaining = expires_at - Time.now
166
+ remaining < (lease_duration * 0.5)
167
+ end
168
+
169
+ def write_setting(path, value)
170
+ return if path.nil? || path.empty?
171
+
172
+ target = path[1..-2].reduce(Legion::Settings[path[0]]) do |node, segment|
173
+ break nil unless node.is_a?(Hash)
174
+
175
+ node[segment]
176
+ end
177
+ target[path.last] = value if target.is_a?(Hash)
178
+ rescue StandardError => e
179
+ log_warn("LeaseManager: failed to write setting at #{path.join('.')}: #{e.message}")
180
+ end
181
+
182
+ def log_debug(message)
183
+ if defined?(Legion::Logging)
184
+ Legion::Logging.debug(message)
185
+ else
186
+ $stdout.puts("[DEBUG] #{message}")
187
+ end
188
+ end
189
+
190
+ def log_warn(message)
191
+ if defined?(Legion::Logging)
192
+ Legion::Logging.warn(message)
193
+ else
194
+ warn("[WARN] #{message}")
195
+ end
196
+ end
197
+ end
198
+ end
199
+ end
@@ -1,30 +1,45 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Legion
2
4
  module Crypt
3
5
  module Settings
4
6
  def self.default
5
7
  {
6
- vault: vault,
8
+ vault: vault,
9
+ jwt: jwt,
7
10
  cs_encrypt_ready: false,
8
- dynamic_keys: true,
9
- cluster_secret: nil,
11
+ dynamic_keys: true,
12
+ cluster_secret: nil,
10
13
  save_private_key: true,
11
14
  read_private_key: true
12
15
  }
13
16
  end
14
17
 
18
+ def self.jwt
19
+ {
20
+ enabled: true,
21
+ default_algorithm: 'HS256',
22
+ default_ttl: 3600,
23
+ issuer: 'legion',
24
+ verify_expiration: true,
25
+ verify_issuer: true
26
+ }
27
+ end
28
+
15
29
  def self.vault
16
30
  {
17
- enabled: !Gem::Specification.find_by_name('vault').nil?,
18
- protocol: 'http',
19
- address: 'localhost',
20
- port: 8200,
21
- token: ENV['VAULT_DEV_ROOT_TOKEN_ID'] || ENV['VAULT_TOKEN_ID'] || nil,
22
- connected: false,
23
- renewer_time: 5,
24
- renewer: true,
31
+ enabled: !Gem::Specification.find_by_name('vault').nil?,
32
+ protocol: 'http',
33
+ address: 'localhost',
34
+ port: 8200,
35
+ token: ENV['VAULT_DEV_ROOT_TOKEN_ID'] || ENV['VAULT_TOKEN_ID'] || nil,
36
+ connected: false,
37
+ renewer_time: 5,
38
+ renewer: true,
25
39
  push_cluster_secret: true,
26
40
  read_cluster_secret: true,
27
- kv_path: ENV['LEGION_VAULT_KV_PATH'] || 'legion'
41
+ kv_path: ENV['LEGION_VAULT_KV_PATH'] || 'legion',
42
+ leases: {}
28
43
  }
29
44
  end
30
45
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'vault'
2
4
 
3
5
  module Legion
@@ -9,7 +11,7 @@ module Legion
9
11
  Legion::Settings[:crypt][:vault]
10
12
  end
11
13
 
12
- def connect_vault # rubocop:disable Metrics/AbcSize
14
+ def connect_vault
13
15
  @sessions = []
14
16
  ::Vault.address = "#{Legion::Settings[:crypt][:vault][:protocol]}://#{Legion::Settings[:crypt][:vault][:address]}:#{Legion::Settings[:crypt][:vault][:port]}" # rubocop:disable Layout/LineLength
15
17
 
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Crypt
5
+ # Vault JWT auth backend integration.
6
+ #
7
+ # Allows Legion workers to authenticate to Vault using JWT tokens
8
+ # via Vault's JWT/OIDC auth method. The worker presents a signed JWT
9
+ # and receives a Vault token with policies scoped to the worker's role.
10
+ #
11
+ # Vault config prerequisites:
12
+ # vault auth enable jwt
13
+ # vault write auth/jwt/config jwks_url="..." (or bound_issuer + jwt_validation_pubkeys)
14
+ # vault write auth/jwt/role/legion-worker bound_audiences="legion" ...
15
+ module VaultJwtAuth
16
+ DEFAULT_AUTH_PATH = 'auth/jwt/login'
17
+ DEFAULT_ROLE = 'legion-worker'
18
+
19
+ class AuthError < StandardError; end
20
+
21
+ # Authenticate to Vault using a JWT token.
22
+ # Returns a Vault token string on success.
23
+ #
24
+ # @param jwt [String] Signed JWT token (issued by Legion or Entra ID)
25
+ # @param role [String] Vault JWT auth role name (default: 'legion-worker')
26
+ # @param auth_path [String] Vault auth mount path (default: 'auth/jwt/login')
27
+ # @return [Hash] { token:, lease_duration:, policies:, metadata: }
28
+ def self.login(jwt:, role: DEFAULT_ROLE, auth_path: DEFAULT_AUTH_PATH)
29
+ raise AuthError, 'Vault is not connected' unless vault_connected?
30
+
31
+ response = ::Vault.logical.write(
32
+ auth_path,
33
+ role: role,
34
+ jwt: jwt
35
+ )
36
+
37
+ raise AuthError, 'Vault JWT auth returned no auth data' unless response&.auth
38
+
39
+ {
40
+ token: response.auth.client_token,
41
+ lease_duration: response.auth.lease_duration,
42
+ renewable: response.auth.renewable,
43
+ policies: response.auth.policies,
44
+ metadata: response.auth.metadata
45
+ }
46
+ rescue ::Vault::HTTPClientError => e
47
+ raise AuthError, "Vault JWT auth failed: #{e.message}"
48
+ rescue ::Vault::HTTPServerError => e
49
+ raise AuthError, "Vault server error during JWT auth: #{e.message}"
50
+ end
51
+
52
+ # Authenticate and set the Vault client token for subsequent operations.
53
+ # This replaces the current Vault token with the JWT-authenticated one.
54
+ #
55
+ # @return [Hash] Same as login
56
+ def self.login!(jwt:, role: DEFAULT_ROLE, auth_path: DEFAULT_AUTH_PATH)
57
+ result = login(jwt: jwt, role: role, auth_path: auth_path)
58
+ ::Vault.token = result[:token]
59
+ Legion::Logging.info "[crypt:vault_jwt] authenticated via JWT auth, policies=#{result[:policies].join(',')}"
60
+ result
61
+ end
62
+
63
+ # Issue a Legion JWT and use it to authenticate to Vault in one step.
64
+ # Convenience method for workers that need Vault access.
65
+ #
66
+ # @param worker_id [String] Digital worker ID
67
+ # @param owner_msid [String] Worker's owner MSID
68
+ # @param role [String] Vault JWT auth role name
69
+ # @return [Hash] Same as login
70
+ def self.worker_login(worker_id:, owner_msid:, role: DEFAULT_ROLE)
71
+ jwt = Legion::Crypt::JWT.issue(
72
+ { worker_id: worker_id, sub: owner_msid, scope: 'vault', aud: 'legion' },
73
+ signing_key: Legion::Crypt.cluster_secret,
74
+ ttl: 300,
75
+ issuer: 'legion'
76
+ )
77
+
78
+ login(jwt: jwt, role: role)
79
+ end
80
+
81
+ def self.vault_connected?
82
+ defined?(::Vault) &&
83
+ defined?(Legion::Settings) &&
84
+ Legion::Settings[:crypt][:vault][:connected] == true
85
+ rescue StandardError
86
+ false
87
+ end
88
+
89
+ private_class_method :vault_connected?
90
+ end
91
+ end
92
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'legion/extensions/actors/every'
2
4
 
3
5
  module Legion
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module Crypt
5
- VERSION = '1.2.0'
5
+ VERSION = '1.4.0'
6
6
  end
7
7
  end
data/lib/legion/crypt.rb CHANGED
@@ -1,8 +1,13 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'openssl'
2
4
  require 'base64'
3
5
  require 'legion/crypt/version'
4
6
  require 'legion/crypt/settings'
5
7
  require 'legion/crypt/cipher'
8
+ require 'legion/crypt/jwt'
9
+ require 'legion/crypt/vault_jwt_auth'
10
+ require 'legion/crypt/lease_manager'
6
11
 
7
12
  module Legion
8
13
  module Crypt
@@ -21,6 +26,7 @@ module Legion
21
26
  ::File.write('./legionio.key', private_key) if settings[:save_private_key]
22
27
 
23
28
  connect_vault unless settings[:vault][:token].nil?
29
+ start_lease_manager
24
30
  end
25
31
 
26
32
  def settings
@@ -31,10 +37,57 @@ module Legion
31
37
  end
32
38
  end
33
39
 
40
+ def jwt_settings
41
+ settings[:jwt] || Legion::Crypt::Settings.jwt
42
+ end
43
+
44
+ def issue_token(payload = {}, ttl: nil, algorithm: nil)
45
+ jwt = jwt_settings
46
+ algo = algorithm || jwt[:default_algorithm]
47
+ token_ttl = ttl || jwt[:default_ttl]
48
+
49
+ signing_key = algo == 'RS256' ? private_key : settings[:cluster_secret]
50
+
51
+ Legion::Crypt::JWT.issue(payload, signing_key: signing_key, algorithm: algo, ttl: token_ttl,
52
+ issuer: jwt[:issuer])
53
+ end
54
+
55
+ def verify_token(token, algorithm: nil)
56
+ jwt = jwt_settings
57
+ algo = algorithm || jwt[:default_algorithm]
58
+
59
+ verification_key = algo == 'RS256' ? OpenSSL::PKey::RSA.new(public_key) : settings[:cluster_secret]
60
+
61
+ Legion::Crypt::JWT.verify(token, verification_key: verification_key, algorithm: algo,
62
+ verify_expiration: jwt[:verify_expiration],
63
+ verify_issuer: jwt[:verify_issuer],
64
+ issuer: jwt[:issuer])
65
+ end
66
+
67
+ def verify_external_token(token, jwks_url:, **)
68
+ Legion::Crypt::JWT.verify_with_jwks(token, jwks_url: jwks_url, **)
69
+ end
70
+
34
71
  def shutdown
72
+ Legion::Crypt::LeaseManager.instance.shutdown
35
73
  shutdown_renewer
36
74
  close_sessions
37
75
  end
76
+
77
+ private
78
+
79
+ def start_lease_manager
80
+ leases = settings.dig(:vault, :leases) || {}
81
+ return if leases.empty?
82
+ return unless settings.dig(:vault, :connected)
83
+
84
+ lease_manager = Legion::Crypt::LeaseManager.instance
85
+ lease_manager.start(leases)
86
+ lease_manager.start_renewal_thread
87
+ Legion::Logging.info "LeaseManager: #{leases.size} lease(s) initialized"
88
+ rescue StandardError => e
89
+ Legion::Logging.warn "LeaseManager startup failed: #{e.message}"
90
+ end
38
91
  end
39
92
  end
40
93
  end