lex-identity-entra 0.5.3 → 0.6.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 85cdc120eb3f0b5d4d17c8e5a946cc6597ad05c4281f6b058a3782f9a868c139
4
- data.tar.gz: 8f74ba8695ef29922d7a058453b7ca1726c458bd5630db3ac9ed0e921185afb8
3
+ metadata.gz: ea7d2caf79ed2c6025cbad1ee6daae0a5f5250aada92cf26f42c71bf8e849680
4
+ data.tar.gz: 3f7afe033551273e7ebe6345c40e24f824529b0f4df79a1f3e7677002dcb006c
5
5
  SHA512:
6
- metadata.gz: cedb8ba0c30d800b9ec234d10fe40a67cacfcf21e320786cdb735ee6025cf23b59d90dc115a05d60ee32cf61791c46cf3328e5b00c4387602715419799659361
7
- data.tar.gz: cb74ced2592eb80c0d3bf06576798d44c9531af4fcac3c4215926a0e018c6c2f6d892987149792692a9b53e1c700572f2c9c7a1456e6d2e5075e933ea23799a2
6
+ metadata.gz: 55af5175673b2d9371c0e1d8b8ff03479299e7425b8f6f79b88b941e2b31bcb44069f026bcae90d898a87a7a6511b69a2c0618279ed73b1ac6631369d9733db2
7
+ data.tar.gz: fc672dfff7aa30a9325b7046752f5cdefe737f39b5e8bb69066657c0873d2f8ae600bf758a2ad42fcc382cb5a47467090e074931581486124e89eb52b66dd53b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.0] - 2026-09-25
4
+ ### Changed
5
+ - **Breaking**: Vault token persistence is now opt-in in both directions. New per-pattern settings `identity.entra.<pattern>.token.vault_read_enabled` and `vault_write_enabled`, both defaulting to `false`. With the defaults, Entra tokens are never read from or written to Vault. Set the flags explicitly to restore 0.5.x Vault behavior (each direction is independent).
6
+ - **Breaking**: Removed local disk token persistence (`~/.legionio/tokens/entra_<qualifier>.json`) and its `local_token_path` setting. Tokens are held in-memory by default and in Vault only when explicitly opted in — nothing is ever written to disk. In the default configuration tokens no longer survive a process restart: re-auth via `auto_authenticate` or `legion lex exec entra auth login`.
7
+ - Upgrades from 0.5.x: tokens that exist only in Vault are not read by default (expect a one-time re-auth, or set `vault_read_enabled: true`), and stale on-disk token files are no longer read or written.
8
+
9
+ ## [0.5.4] - 2026-08-04
10
+ ### Fixed
11
+ - Stop writing delegated Entra tokens to bootstrap Vault paths. Token persistence now uses only the canonical `users/<identity>/entra/<qualifier>/auth` Vault path and falls back to local disk until a canonical identity is available.
12
+
3
13
  ## [0.5.3] - 2026-07-15
4
14
  ### Fixed
5
15
  - Fix token refresh dead-end: refresh when token exists regardless of authenticated? state
data/README.md CHANGED
@@ -7,7 +7,7 @@ and workload identity authentication patterns.
7
7
  ## Features
8
8
 
9
9
  - **Delegated auth** — OAuth2 PKCE browser flow with local callback server; device-code fallback
10
- - **Token persistence** — Vault-first (`kv/data/users/<identity>/entra/<qualifier>/auth`), disk fallback, in-memory fallback; disk file deleted once Vault write succeeds
10
+ - **Token persistence** — in-memory by default; Vault (`kv/data/users/<identity>/entra/<qualifier>/auth`) reading and writing are separate opt-ins via `vault_read_enabled` / `vault_write_enabled`. Tokens are never written to local disk
11
11
  - **Scope fingerprinting** — MD5 fingerprint of active scopes stored with token; any scope change forces re-authentication on next boot
12
12
  - **Identity integration** — `AuthValidator` upgrades `Legion::Identity::Process` via `Resolver.upgrade!` and registers with `Legion::Identity::Broker` for cross-extension token access
13
13
  - **Application credentials** — client credentials flow for service-to-service auth
@@ -36,6 +36,8 @@ identity:
36
36
  auto_authenticate: false # set true to open browser automatically at boot
37
37
  callback_timeout: 120
38
38
  token:
39
+ vault_read_enabled: false # set true to read the token from Vault
40
+ vault_write_enabled: false # set true to write the token to Vault
39
41
  refresh_buffer: 60
40
42
  refresh_interval: 900
41
43
  scopes:
@@ -98,11 +100,10 @@ identity:
98
100
 
99
101
  ## Token storage
100
102
 
101
- | Backend | Path | Priority |
102
- |---------|------|----------|
103
- | HashiCorp Vault | `kv/data/users/<identity>/entra/delegated/auth` | 1 (preferred) |
104
- | Local disk | `~/.legionio/tokens/entra_delegated.json` | 2 (fallback, deleted when Vault succeeds) |
105
- | Memory | In-process store | 3 (runtime fallback) |
103
+ | Backend | Path | When used |
104
+ |---------|------|-----------|
105
+ | HashiCorp Vault | `kv/data/users/<identity>/entra/delegated/auth` | Only when opted in: `vault_read_enabled` / `vault_write_enabled` (both default `false`) |
106
+ | Memory | In-process store | Default persistence; tokens are never written to local disk |
106
107
 
107
108
  ## Identity provider contract
108
109
 
@@ -32,10 +32,11 @@ module Legion
32
32
  category_overrides: {}
33
33
  },
34
34
  token: {
35
- vault_path: nil,
36
- local_token_path: nil,
37
- refresh_buffer: 60,
38
- refresh_interval: 2700
35
+ vault_read_enabled: false,
36
+ vault_write_enabled: false,
37
+ vault_path: nil,
38
+ refresh_buffer: 60,
39
+ refresh_interval: 2700
39
40
  }
40
41
  }
41
42
  end
@@ -139,8 +139,7 @@ module Legion
139
139
  end
140
140
 
141
141
  def previously_authenticated?
142
- path = Legion::Extensions::Identity::Entra::Helpers::TokenManager.local_path(:delegated)
143
- File.exist?(path)
142
+ !stored_token_data.nil?
144
143
  end
145
144
 
146
145
  def stored_token_data
@@ -57,7 +57,6 @@ module Legion
57
57
 
58
58
  def stored_raw_data
59
59
  Legion::Extensions::Identity::Entra::Helpers::TokenManager.from_vault_data(:delegated) ||
60
- Legion::Extensions::Identity::Entra::Helpers::TokenManager.from_local_data(:delegated) ||
61
60
  Legion::Extensions::Identity::Entra::Helpers::TokenManager.from_memory(:delegated)
62
61
  end
63
62
 
@@ -35,10 +35,11 @@ module Legion
35
35
  category_overrides: {}
36
36
  },
37
37
  token: {
38
- vault_path: nil,
39
- local_token_path: nil,
40
- refresh_buffer: 60,
41
- refresh_interval: 900
38
+ vault_read_enabled: false,
39
+ vault_write_enabled: false,
40
+ vault_path: nil,
41
+ refresh_buffer: 60,
42
+ refresh_interval: 900
42
43
  },
43
44
  browser_auth: {
44
45
  auto_authenticate: false,
@@ -16,19 +16,13 @@ module Legion
16
16
  include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers, false) &&
17
17
  Legion::Extensions::Helpers.const_defined?(:Lex, false)
18
18
 
19
- # Returns an array of qualifier symbols for which tokens exist locally.
19
+ # Returns an array of qualifier symbols for which tokens exist in-process.
20
20
  def discovered_qualifiers
21
- (local_qualifiers + broker_qualifiers).uniq
21
+ (memory_qualifiers + broker_qualifiers).uniq
22
22
  end
23
23
 
24
- def local_qualifiers
25
- return [] unless File.directory?(Legion::Extensions::Identity::Entra::Helpers::TokenManager::TOKEN_DIR)
26
-
27
- Dir.glob(File.join(Legion::Extensions::Identity::Entra::Helpers::TokenManager::TOKEN_DIR, 'entra_*.json')).filter_map do |path|
28
- basename = File.basename(path, '.json')
29
- match = basename.match(/\Aentra_(.+)\z/)
30
- match[1].to_sym if match
31
- end
24
+ def memory_qualifiers
25
+ Legion::Extensions::Identity::Entra::Helpers::TokenManager.memory_store.keys
32
26
  end
33
27
 
34
28
  def broker_qualifiers
@@ -2,7 +2,6 @@
2
2
 
3
3
  require 'concurrent'
4
4
  require 'digest'
5
- require 'fileutils'
6
5
  require 'time'
7
6
 
8
7
  module Legion
@@ -16,7 +15,6 @@ module Legion
16
15
  include Legion::Settings::Helper
17
16
  include Legion::JSON::Helper
18
17
 
19
- TOKEN_DIR = File.join(Dir.home, '.legionio', 'tokens')
20
18
  REFRESH_BUFFER = 60
21
19
  MEMORY_STORE = Concurrent::Hash.new
22
20
 
@@ -34,20 +32,7 @@ module Legion
34
32
 
35
33
  def token_data(qualifier = :delegated, refresh: true)
36
34
  log.debug("TokenManager.token_data: qualifier=#{qualifier} refresh=#{refresh}")
37
- vault_data = from_vault_data(qualifier)
38
- other_data = vault_data || from_local_data(qualifier) || from_memory(qualifier)
39
- if other_data && !vault_data && vault_available? && canonical_name_available?
40
- log.info("TokenManager.token_data: backfilling #{qualifier} token to vault")
41
- backfill_saved = save_to_vault(qualifier, access_token: other_data[:access_token],
42
- refresh_token: other_data[:refresh_token],
43
- expires_at: other_data[:expires_at],
44
- scopes: other_data[:scopes],
45
- tenant_id: other_data[:tenant_id],
46
- client_id: other_data[:client_id],
47
- scope_fingerprint: other_data[:scope_fingerprint])
48
- delete_local(qualifier) if backfill_saved
49
- end
50
- data = other_data
35
+ data = from_vault_data(qualifier) || from_memory(qualifier)
51
36
  return nil unless data
52
37
 
53
38
  if scope_fingerprint_stale?(qualifier, data)
@@ -70,18 +55,10 @@ module Legion
70
55
  log.debug("TokenManager.save_token: qualifier=#{qualifier} expires_in=#{expires_in}")
71
56
  expires_at ||= Time.now + expires_in.to_i if expires_in
72
57
  fingerprint = current_scope_fingerprint(qualifier)
73
- vault_saved = save_to_vault(qualifier, access_token: access_token, refresh_token: refresh_token,
74
- expires_at: expires_at, scopes: scopes,
75
- tenant_id: tenant_id, client_id: client_id,
76
- scope_fingerprint: fingerprint)
77
- if vault_saved
78
- delete_local(qualifier)
79
- else
80
- save_to_local(qualifier, access_token: access_token, refresh_token: refresh_token,
81
- expires_at: expires_at, scopes: scopes,
82
- tenant_id: tenant_id, client_id: client_id,
83
- scope_fingerprint: fingerprint)
84
- end
58
+ save_to_vault(qualifier, access_token: access_token, refresh_token: refresh_token,
59
+ expires_at: expires_at, scopes: scopes,
60
+ tenant_id: tenant_id, client_id: client_id,
61
+ scope_fingerprint: fingerprint)
85
62
  save_to_memory(qualifier, access_token: access_token, refresh_token: refresh_token,
86
63
  expires_at: expires_at, scopes: scopes,
87
64
  tenant_id: tenant_id, client_id: client_id,
@@ -89,13 +66,10 @@ module Legion
89
66
  end
90
67
 
91
68
  def from_vault_data(qualifier)
92
- return nil unless vault_available?
69
+ return nil unless vault_read_enabled?(qualifier)
70
+ return nil unless vault_available? && canonical_name_available?
93
71
 
94
- # Prefer the canonical (post-resolution) path, then fall back to the
95
- # bootstrap path so a fresh process can recover a Vault-only token
96
- # before Legion::Identity::Process is resolved (issue #4).
97
- read_vault_token(qualifier, vault_path(qualifier)) ||
98
- read_vault_token(qualifier, bootstrap_vault_path(qualifier))
72
+ read_vault_token(qualifier, vault_path(qualifier))
99
73
  end
100
74
 
101
75
  def read_vault_token(qualifier, path)
@@ -110,41 +84,25 @@ module Legion
110
84
  nil
111
85
  end
112
86
 
113
- def from_local_data(qualifier)
114
- path = local_path(qualifier)
115
- return nil unless File.exist?(path)
116
-
117
- log.debug("TokenManager.from_local_data: reading #{path}")
118
- normalize_token_data(json_load(File.read(path)))
119
- rescue StandardError => e
120
- handle_exception(e, level: :warn, operation: 'token_manager.from_local_data',
121
- qualifier: qualifier, path: path)
122
- nil
123
- end
124
-
125
87
  def save_to_vault(qualifier, access_token:, refresh_token:, expires_at:,
126
88
  scopes: nil, tenant_id: nil, client_id: nil, scope_fingerprint: nil)
89
+ return unless vault_write_enabled?(qualifier)
127
90
  return unless vault_available?
91
+ return unless canonical_name_available?
128
92
 
129
- # When canonical identity is resolved, write the canonical key (and
130
- # alias to the bootstrap key so a later fresh process can recover it
131
- # before resolution). Before resolution, fall back to the bootstrap
132
- # key so we never write to a placeholder (anonymous) canonical path.
133
- paths = [vault_path(qualifier), bootstrap_vault_path(qualifier)].compact.uniq
134
- return if paths.empty?
93
+ path = vault_path(qualifier)
94
+ return unless path
135
95
 
136
96
  cluster = Legion::Crypt.respond_to?(:default_cluster_name) ? Legion::Crypt.default_cluster_name : 'vault'
137
- paths.each do |path|
138
- log.info("TokenManager.save_to_vault: writing to #{cluster} :: kv/data/#{path} qualifier=#{qualifier}")
139
- vault_kv_client.write(path,
140
- access_token: access_token,
141
- refresh_token: refresh_token,
142
- expires_at: expires_at&.utc&.iso8601,
143
- scopes: scopes,
144
- tenant_id: tenant_id,
145
- client_id: client_id,
146
- scope_fingerprint: scope_fingerprint)
147
- end
97
+ log.info("TokenManager.save_to_vault: writing to #{cluster} :: kv/data/#{path} qualifier=#{qualifier}")
98
+ vault_kv_client.write(path,
99
+ access_token: access_token,
100
+ refresh_token: refresh_token,
101
+ expires_at: expires_at&.utc&.iso8601,
102
+ scopes: scopes,
103
+ tenant_id: tenant_id,
104
+ client_id: client_id,
105
+ scope_fingerprint: scope_fingerprint)
148
106
  log.info('TokenManager.save_to_vault: success')
149
107
  true
150
108
  rescue StandardError => e
@@ -153,38 +111,6 @@ module Legion
153
111
  nil
154
112
  end
155
113
 
156
- def save_to_local(qualifier, access_token:, refresh_token:, expires_at:,
157
- scopes: nil, tenant_id: nil, client_id: nil, scope_fingerprint: nil)
158
- path = local_path(qualifier)
159
- log.debug("TokenManager.save_to_local: writing #{path}")
160
- FileUtils.mkdir_p(File.dirname(path))
161
- File.write(path, json_dump({
162
- access_token: access_token,
163
- refresh_token: refresh_token,
164
- expires_at: expires_at&.utc&.iso8601,
165
- scopes: scopes,
166
- tenant_id: tenant_id,
167
- client_id: client_id,
168
- scope_fingerprint: scope_fingerprint
169
- }))
170
- File.chmod(0o600, path)
171
- log.debug('TokenManager.save_to_local: success')
172
- rescue StandardError => e
173
- handle_exception(e, level: :warn, operation: 'token_manager.save_to_local',
174
- qualifier: qualifier, path: path)
175
- nil
176
- end
177
-
178
- def delete_local(qualifier)
179
- path = local_path(qualifier)
180
- return unless File.exist?(path)
181
-
182
- File.delete(path)
183
- log.info("TokenManager.delete_local: removed #{path} (vault is authoritative)")
184
- rescue StandardError => e
185
- handle_exception(e, level: :warn, operation: 'token_manager.delete_local', qualifier: qualifier)
186
- end
187
-
188
114
  def from_memory(qualifier)
189
115
  data = TokenManager.memory_store[qualifier.to_sym]
190
116
  return nil unless data
@@ -213,7 +139,7 @@ module Legion
213
139
  end
214
140
 
215
141
  def scope_fingerprint_stale?(qualifier, data = nil)
216
- data ||= from_local_data(qualifier) || from_memory(qualifier)
142
+ data ||= from_memory(qualifier)
217
143
  return false unless data
218
144
 
219
145
  stored = data[:scope_fingerprint]
@@ -289,6 +215,14 @@ module Legion
289
215
  !load_token(qualifier).nil?
290
216
  end
291
217
 
218
+ def vault_read_enabled?(qualifier)
219
+ Legion::Settings.dig(:identity, :entra, qualifier.to_sym, :token, :vault_read_enabled)
220
+ end
221
+
222
+ def vault_write_enabled?(qualifier)
223
+ Legion::Settings.dig(:identity, :entra, qualifier.to_sym, :token, :vault_write_enabled)
224
+ end
225
+
292
226
  def vault_available?
293
227
  defined?(Legion::Crypt) &&
294
228
  Legion::Crypt.respond_to?(:vault_connected?) &&
@@ -305,34 +239,6 @@ module Legion
305
239
  "users/#{Legion::Identity::Process.canonical_name}/entra/#{qualifier}/auth"
306
240
  end
307
241
 
308
- # Pre-resolution Vault key for delegated tokens, derived from stable
309
- # inputs (tenant id + client id) that are available before canonical
310
- # identity resolution. Lets a fresh process recover a Vault-only
311
- # delegated token so it can resolve identity (issue #4). Scoped to
312
- # delegated only — other patterns are unaffected.
313
- def bootstrap_vault_path(qualifier)
314
- return nil unless qualifier.to_sym == :delegated
315
-
316
- auth = settings_auth
317
- pattern_settings = auth[qualifier.to_sym]
318
- return pattern_settings[:vault_path] if pattern_settings.is_a?(Hash) && pattern_settings[:vault_path]
319
-
320
- tenant_id = auth[:tenant_id]
321
- client_id = auth[:client_id]
322
- return nil unless tenant_id && client_id
323
-
324
- account = Digest::MD5.hexdigest("#{tenant_id}:#{client_id}")
325
- "bootstrap/entra/#{qualifier}/#{account}/auth"
326
- end
327
-
328
- def local_path(qualifier)
329
- auth = settings_auth
330
- pattern_settings = auth[qualifier.to_sym]
331
- return File.expand_path(pattern_settings[:local_token_path]) if pattern_settings.is_a?(Hash) && pattern_settings[:local_token_path]
332
-
333
- File.join(TOKEN_DIR, "entra_#{qualifier}.json")
334
- end
335
-
336
242
  def settings_auth
337
243
  identity_entra = Legion::Settings.dig(:identity, :entra) || {}
338
244
  auth = identity_entra[:auth].is_a?(Hash) ? identity_entra[:auth].dup : identity_entra.dup
@@ -28,10 +28,11 @@ module Legion
28
28
  category_overrides: {}
29
29
  },
30
30
  token: {
31
- vault_path: nil,
32
- local_token_path: nil,
33
- refresh_buffer: 60,
34
- refresh_interval: 2700
31
+ vault_read_enabled: false,
32
+ vault_write_enabled: false,
33
+ vault_path: nil,
34
+ refresh_buffer: 60,
35
+ refresh_interval: 2700
35
36
  }
36
37
  }
37
38
  end
@@ -4,7 +4,7 @@ module Legion
4
4
  module Extensions
5
5
  module Identity
6
6
  module Entra
7
- VERSION = '0.5.3'
7
+ VERSION = '0.6.0'
8
8
  end
9
9
  end
10
10
  end
@@ -29,10 +29,11 @@ module Legion
29
29
  category_overrides: {}
30
30
  },
31
31
  token: {
32
- vault_path: nil,
33
- local_token_path: nil,
34
- refresh_buffer: 60,
35
- refresh_interval: 2700
32
+ vault_read_enabled: false,
33
+ vault_write_enabled: false,
34
+ vault_path: nil,
35
+ refresh_buffer: 60,
36
+ refresh_interval: 2700
36
37
  }
37
38
  }
38
39
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-identity-entra
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.3
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity