lex-identity 0.2.0 → 0.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3681b4b6aab75c906d15647f1f359d19760d42994803b8c89d733480e77eacff
4
- data.tar.gz: 4b49caa8730568c80e4e84a8d43f9053d83e6bef70a22730115a63492d7d5a06
3
+ metadata.gz: 402e57fb1c2fafdaac3bbbfd79ab0cd34d448798fe9f61e14511002845dc3d7c
4
+ data.tar.gz: 30585e986691ac0bee3bd2111f291fdc4adcef5c6ef83179c16a4e12d3e6eb67
5
5
  SHA512:
6
- metadata.gz: 527ef0472ad306da82d94ebea24a2b48c0375e164248b322aff1a721897d84f7518eac789852f17af88df14117ddfc9523dd65b42047a84d27af4fb907797d03
7
- data.tar.gz: d7e69edd6068f138112c1a685423fc34fd44b8c5d83482c4c4ec8a1179b628955f2043a2e0d43a2b0dc0035b71129f8f56d5d6119dfc69ca64317087a7905849
6
+ metadata.gz: 87d225ef73a724bcbf18649664670e56ed5eb93c7f5f2312ee19cf70550191c41849a4e25bb303aee50e98b330962269cd2c0c53c3781f5926a5fca89ed047f4
7
+ data.tar.gz: 798d6de090c2666919a3bc778adb4decaa0c08e586a14d03fc6e781a13ad2d33ecad0cb35f852b41c12d59c830c0f69b248aaddafe54e6b59e96d4800129dc47
data/Gemfile CHANGED
@@ -4,5 +4,6 @@ source 'https://rubygems.org'
4
4
 
5
5
  gemspec
6
6
 
7
+ gem 'faraday', '~> 2.0'
7
8
  gem 'rspec', '~> 3.13'
8
9
  gem 'rubocop', '~> 1.75', require: false
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/actors/every'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Identity
8
+ module Actor
9
+ class CredentialRefresh < Legion::Extensions::Actors::Every
10
+ CREDENTIAL_REFRESH_INTERVAL = 21_600 # 6 hours
11
+
12
+ def runner_class
13
+ Legion::Extensions::Identity::Runners::Entra
14
+ end
15
+
16
+ def runner_function
17
+ 'credential_refresh_cycle'
18
+ end
19
+
20
+ def time
21
+ CREDENTIAL_REFRESH_INTERVAL
22
+ end
23
+
24
+ def enabled?
25
+ defined?(Legion::Data) && Legion::Settings[:data][:connected] != false
26
+ rescue StandardError
27
+ false
28
+ end
29
+
30
+ def use_runner?
31
+ false
32
+ end
33
+
34
+ def check_subtask?
35
+ false
36
+ end
37
+
38
+ def generate_task?
39
+ false
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Identity
6
+ module Helpers
7
+ module GraphClient
8
+ GRAPH_BASE = 'https://graph.microsoft.com/v1.0'
9
+
10
+ module_function
11
+
12
+ def connection(token:, base: GRAPH_BASE)
13
+ require 'faraday'
14
+ Faraday.new(url: base) do |conn|
15
+ conn.request :json
16
+ conn.response :json, content_type: /\bjson$/
17
+ conn.headers['Authorization'] = "Bearer #{token}"
18
+ conn.headers['Content-Type'] = 'application/json'
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Identity
6
+ module Helpers
7
+ module GraphToken
8
+ TOKEN_ENDPOINT = 'https://login.microsoftonline.com/%<tenant_id>s/oauth2/v2.0/token'
9
+ GRAPH_SCOPE = 'https://graph.microsoft.com/.default'
10
+
11
+ class GraphTokenError < StandardError; end
12
+
13
+ module_function
14
+
15
+ def fetch(tenant_id:, client_id:, client_secret:)
16
+ require 'faraday'
17
+ url = format(TOKEN_ENDPOINT, tenant_id: tenant_id)
18
+ conn = Faraday.new(url: url) do |c|
19
+ c.request :url_encoded
20
+ c.response :json, content_type: /\bjson$/
21
+ end
22
+ resp = conn.post('', grant_type: 'client_credentials', client_id: client_id,
23
+ client_secret: client_secret, scope: GRAPH_SCOPE)
24
+ raise GraphTokenError, resp.body['error_description'] unless resp.success?
25
+
26
+ resp.body['access_token']
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Identity
6
+ module Helpers
7
+ module TokenCache
8
+ REFRESH_BUFFER = 300
9
+
10
+ @mutex = Mutex.new
11
+ @tokens = {}
12
+
13
+ module_function
14
+
15
+ def store(worker_id:, token:, expires_in:)
16
+ @mutex.synchronize do
17
+ @tokens[worker_id] = {
18
+ access_token: token,
19
+ expires_at: Time.now + expires_in,
20
+ acquired_at: Time.now
21
+ }
22
+ end
23
+ end
24
+
25
+ def fetch(worker_id:)
26
+ @mutex.synchronize do
27
+ entry = @tokens[worker_id]
28
+ return nil unless entry
29
+ return nil if Time.now >= entry[:expires_at]
30
+
31
+ entry
32
+ end
33
+ end
34
+
35
+ def approaching_expiry?(worker_id:, buffer: REFRESH_BUFFER)
36
+ @mutex.synchronize do
37
+ entry = @tokens[worker_id]
38
+ return true unless entry
39
+
40
+ (entry[:expires_at] - Time.now) < buffer
41
+ end
42
+ end
43
+
44
+ def clear(worker_id:)
45
+ @mutex.synchronize { @tokens.delete(worker_id) }
46
+ end
47
+
48
+ def clear_all
49
+ @mutex.synchronize { @tokens.clear }
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -77,23 +77,47 @@ module Legion
77
77
 
78
78
  # Sync the worker's owner from Entra app ownership.
79
79
  # Requires: Application.Read.All or Directory.Read.All (read-only)
80
+ # Falls back to local record when Graph API credentials unavailable.
80
81
  def sync_owner(worker_id:, **)
81
82
  worker = find_worker(worker_id)
82
83
  return { synced: false, error: 'worker not found' } unless worker
83
84
 
84
- # TODO: With Application.Read.All, call:
85
- # GET #{GRAPH_API_BASE}/applications/#{worker[:entra_object_id]}/owners
86
- # Parse owner MSID from response, update local record
85
+ entra_object_id = worker[:entra_object_id]
86
+ return { synced: false, worker_id: worker_id, error: 'no entra_object_id', source: :local } unless entra_object_id
87
87
 
88
- Legion::Logging.debug "[identity:entra] sync_owner: worker=#{worker_id} current_owner=#{worker[:owner_msid]}"
88
+ creds = resolve_graph_credentials
89
+ unless creds
90
+ Legion::Logging.debug "[identity:entra] sync_owner fallback to local: worker=#{worker_id}"
91
+ return { synced: true, worker_id: worker_id, source: :local,
92
+ owner_msid: worker[:owner_msid], synced_at: Time.now.utc }
93
+ end
89
94
 
90
- {
91
- synced: true,
92
- worker_id: worker_id,
93
- owner_msid: worker[:owner_msid],
94
- source: :local, # will be :graph_api when read permission granted
95
- synced_at: Time.now.utc
96
- }
95
+ token = Helpers::GraphToken.fetch(**creds)
96
+ conn = Helpers::GraphClient.connection(token: token)
97
+ resp = conn.get("applications/#{entra_object_id}/owners")
98
+
99
+ unless resp.success?
100
+ Legion::Logging.warn "[identity:entra] graph owner sync failed: #{resp.status}"
101
+ return { synced: false, worker_id: worker_id, source: :local, owner_msid: worker[:owner_msid] }
102
+ end
103
+
104
+ owners = resp.body['value'] || []
105
+ graph_owner_msid = owners.first&.dig('id')
106
+ changed = graph_owner_msid && graph_owner_msid != worker[:owner_msid].to_s
107
+
108
+ if changed && defined?(Legion::Data::Model::DigitalWorker)
109
+ Legion::Data::Model::DigitalWorker.where(worker_id: worker_id).update(owner_msid: graph_owner_msid)
110
+ if defined?(Legion::Events)
111
+ Legion::Events.emit('worker.owner_changed', { worker_id: worker_id, old: worker[:owner_msid],
112
+ new: graph_owner_msid })
113
+ end
114
+ end
115
+
116
+ { synced: true, source: :graph_api, worker_id: worker_id,
117
+ owner_msid: graph_owner_msid || worker[:owner_msid], changed: !changed.nil?, synced_at: Time.now.utc }
118
+ rescue Helpers::GraphToken::GraphTokenError, Faraday::Error => e
119
+ Legion::Logging.warn "[identity:entra] graph sync error: #{e.message}"
120
+ { synced: false, worker_id: worker_id, source: :local, error: e.message }
97
121
  end
98
122
 
99
123
  # Transfer ownership of a digital worker to a new human.
@@ -140,6 +164,7 @@ module Legion
140
164
  # Requires: Application.Read.All or Directory.Read.All (read-only)
141
165
  # Orphan REMEDIATION (disabling apps) requires human action since Legion
142
166
  # does not have Application.ReadWrite.All.
167
+ # Falls back to local-only scan when Graph API credentials unavailable.
143
168
  def check_orphans(**)
144
169
  return { orphans: [], checked: 0, source: :unavailable } unless defined?(Legion::Data) && defined?(Legion::Data::Model::DigitalWorker)
145
170
 
@@ -147,30 +172,151 @@ module Legion
147
172
  orphans = []
148
173
  skipped = 0
149
174
 
175
+ creds = resolve_graph_credentials
176
+ conn = nil
177
+ if creds
178
+ token = Helpers::GraphToken.fetch(**creds)
179
+ conn = Helpers::GraphClient.connection(token: token)
180
+ end
181
+
150
182
  active_workers.each do |worker|
151
- # Skip auto-registered extension workers without real Entra apps
152
183
  if system_placeholder?(worker.entra_app_id, worker.worker_id)
153
184
  skipped += 1
154
185
  next
155
186
  end
156
187
 
157
- # TODO: With Application.Read.All, check:
158
- # GET #{GRAPH_API_BASE}/applications/#{entra_object_id} — is app disabled?
159
- # GET #{GRAPH_API_BASE}/users/#{owner_msid} — is owner active?
160
- # If either is disabled/deleted:
161
- # orphans << worker
162
- # auto_pause_orphan(worker, reason: :entra_app_disabled)
188
+ next unless conn
189
+
190
+ orphan_reason = check_worker_orphan_status(conn, worker)
191
+ if orphan_reason
192
+ orphans << worker
193
+ auto_pause_orphan(worker, reason: orphan_reason)
194
+ end
195
+ rescue Faraday::Error => e
196
+ Legion::Logging.warn "[identity:entra] graph error scanning #{worker.worker_id}: #{e.message}"
163
197
  end
164
198
 
165
- Legion::Logging.debug "[identity:entra] orphan check: scanned #{active_workers.size} active workers, skipped #{skipped} system workers"
199
+ source = conn ? :graph_api : :local
200
+ Legion::Logging.debug "[identity:entra] orphan check (#{source}): scanned #{active_workers.size}, skipped #{skipped}"
166
201
 
167
202
  {
168
- orphans: orphans.map { |w| { worker_id: w.worker_id, owner_msid: w.owner_msid, reason: :pending_entra_validation } },
203
+ orphans: orphans.map { |w| { worker_id: w.worker_id, owner_msid: w.owner_msid, reason: :entra_orphan } },
169
204
  checked: active_workers.size - skipped,
170
205
  skipped: skipped,
171
- source: :local,
206
+ source: source,
172
207
  checked_at: Time.now.utc
173
208
  }
209
+ rescue Helpers::GraphToken::GraphTokenError => e
210
+ Legion::Logging.warn "[identity:entra] orphan check token error: #{e.message}"
211
+ { orphans: [], checked: 0, source: :local, error: e.message, checked_at: Time.now.utc }
212
+ end
213
+
214
+ # Map Entra security group OIDs to Legion governance roles
215
+ def resolve_governance_roles(groups:, **)
216
+ group_map = Legion::Settings.dig(:rbac, :entra, :group_map) || {}
217
+ default_role = Legion::Settings.dig(:rbac, :entra, :default_role) || 'governance-observer'
218
+ matched = Array(groups).filter_map { |oid| group_map[oid] }.uniq
219
+ matched = [default_role] if matched.empty?
220
+ { success: true, groups: groups, roles: matched }
221
+ end
222
+
223
+ def refresh_access_token(worker_id:, force: false, **)
224
+ require_relative '../helpers/token_cache'
225
+
226
+ unless force
227
+ cached = Helpers::TokenCache.fetch(worker_id: worker_id)
228
+ if cached && !Helpers::TokenCache.approaching_expiry?(worker_id: worker_id)
229
+ return { refreshed: false, worker_id: worker_id, source: :cache, expires_at: cached[:expires_at] }
230
+ end
231
+ end
232
+
233
+ secret = Helpers::VaultSecrets.read_client_secret(worker_id: worker_id)
234
+ return { refreshed: false, worker_id: worker_id, error: 'vault_unavailable' } unless secret
235
+
236
+ tenant_id = resolve_tenant_id
237
+ return { refreshed: false, worker_id: worker_id, error: 'no_tenant_id' } unless tenant_id
238
+
239
+ scope = Legion::Settings.dig(:identity, :entra, :token_scope) || 'https://graph.microsoft.com/.default'
240
+ url = "https://login.microsoftonline.com/#{tenant_id}/oauth2/v2.0/token"
241
+
242
+ require 'faraday'
243
+ resp = Faraday.post(url, {
244
+ grant_type: 'client_credentials',
245
+ client_id: secret[:client_id] || secret[:entra_app_id],
246
+ client_secret: secret[:client_secret],
247
+ scope: scope
248
+ })
249
+
250
+ unless resp.success?
251
+ Legion::Logging.warn "[identity] token refresh failed for #{worker_id}: #{resp.status}"
252
+ return { refreshed: false, worker_id: worker_id, error: 'token_request_failed' }
253
+ end
254
+
255
+ body = Legion::JSON.load(resp.body)
256
+ expires_in = body[:expires_in]&.to_i || 3600
257
+ Helpers::TokenCache.store(worker_id: worker_id, token: body[:access_token], expires_in: expires_in)
258
+
259
+ { refreshed: true, worker_id: worker_id, expires_at: Time.now + expires_in }
260
+ rescue StandardError => e
261
+ Legion::Logging.warn "[identity] token refresh error: #{e.message}"
262
+ { refreshed: false, worker_id: worker_id, error: e.message }
263
+ end
264
+
265
+ def rotate_client_secret(worker_id:, dry_run: false, **)
266
+ rotation_enabled = Legion::Settings.dig(:identity, :entra, :rotation_enabled)
267
+ buffer_days = Legion::Settings.dig(:identity, :entra, :rotation_buffer_days) || 30
268
+
269
+ secret = Helpers::VaultSecrets.read_client_secret(worker_id: worker_id)
270
+ return { rotated: false, worker_id: worker_id, error: 'vault_unavailable' } unless secret
271
+
272
+ expires_at = secret[:client_secret_expires_at]
273
+ return { rotated: false, worker_id: worker_id, action_required: false, reason: 'no_expiry_tracked' } unless expires_at
274
+
275
+ days_remaining = (Time.parse(expires_at.to_s) - Time.now) / 86_400
276
+ unless days_remaining < buffer_days
277
+ return { rotated: false, worker_id: worker_id, action_required: false,
278
+ days_remaining: days_remaining.round(1) }
279
+ end
280
+
281
+ unless rotation_enabled
282
+ Legion::Logging.warn "[identity] credential expiring for #{worker_id} in #{days_remaining.round(1)} days"
283
+ if defined?(Legion::Events)
284
+ Legion::Events.emit('worker.credential_expiry_warning', {
285
+ worker_id: worker_id, days_remaining: days_remaining.round(1)
286
+ })
287
+ end
288
+ return { rotated: false, worker_id: worker_id, action_required: true,
289
+ days_remaining: days_remaining.round(1) }
290
+ end
291
+
292
+ return { rotated: false, worker_id: worker_id, dry_run: true, would_rotate: true } if dry_run
293
+
294
+ # Graph API rotation would go here when permission is granted
295
+ { rotated: false, worker_id: worker_id, error: 'graph_api_rotation_not_implemented' }
296
+ end
297
+
298
+ def credential_refresh_cycle(**)
299
+ return { workers_checked: 0, error: 'data_unavailable' } unless defined?(Legion::Data::Model::DigitalWorker)
300
+
301
+ workers = Legion::Data::Model::DigitalWorker.where(lifecycle_state: 'active').all
302
+ results = { workers_checked: 0, refreshed: 0, warned: 0 }
303
+
304
+ workers.each do |worker|
305
+ next if system_placeholder?(worker.entra_app_id, worker.worker_id)
306
+
307
+ results[:workers_checked] += 1
308
+
309
+ token_result = refresh_access_token(worker_id: worker.worker_id)
310
+ results[:refreshed] += 1 if token_result[:refreshed]
311
+
312
+ rotation_result = rotate_client_secret(worker_id: worker.worker_id)
313
+ results[:warned] += 1 if rotation_result[:action_required]
314
+ end
315
+
316
+ results
317
+ rescue StandardError => e
318
+ Legion::Logging.warn "[identity] credential refresh cycle error: #{e.message}"
319
+ { workers_checked: 0, error: e.message }
174
320
  end
175
321
 
176
322
  private
@@ -200,6 +346,35 @@ module Legion
200
346
  nil
201
347
  end
202
348
 
349
+ def resolve_graph_credentials
350
+ tenant_id = resolve_tenant_id
351
+ return nil unless tenant_id
352
+
353
+ secret = Helpers::VaultSecrets.read_client_secret(worker_id: 'legion/identity')
354
+ return nil unless secret && secret[:client_id] && secret[:client_secret]
355
+
356
+ { tenant_id: tenant_id, client_id: secret[:client_id], client_secret: secret[:client_secret] }
357
+ rescue StandardError
358
+ nil
359
+ end
360
+
361
+ def check_worker_orphan_status(conn, worker)
362
+ # Check if the Entra app registration still exists
363
+ if worker.respond_to?(:entra_object_id) && worker.entra_object_id
364
+ app_resp = conn.get("applications/#{worker.entra_object_id}")
365
+ return :entra_app_deleted unless app_resp.success?
366
+ end
367
+
368
+ # Check if the owner account is still active
369
+ if worker.owner_msid
370
+ user_resp = conn.get("users/#{worker.owner_msid}")
371
+ return :owner_deleted unless user_resp.success?
372
+ return :owner_disabled if user_resp.body['accountEnabled'] == false
373
+ end
374
+
375
+ nil
376
+ end
377
+
203
378
  def auto_pause_orphan(worker, reason:)
204
379
  worker.update(lifecycle_state: 'paused', updated_at: Time.now.utc)
205
380
 
@@ -3,7 +3,7 @@
3
3
  module Legion
4
4
  module Extensions
5
5
  module Identity
6
- VERSION = '0.2.0'
6
+ VERSION = '0.4.0'
7
7
  end
8
8
  end
9
9
  end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'legion/extensions/identity/helpers/graph_client'
5
+
6
+ RSpec.describe Legion::Extensions::Identity::Helpers::GraphClient do
7
+ describe '.connection' do
8
+ it 'returns a Faraday connection with bearer token' do
9
+ conn = described_class.connection(token: 'test-token')
10
+ expect(conn).to be_a(Faraday::Connection)
11
+ expect(conn.headers['Authorization']).to eq('Bearer test-token')
12
+ end
13
+
14
+ it 'uses custom base URL when provided' do
15
+ conn = described_class.connection(token: 'tok', base: 'https://custom.api.com')
16
+ expect(conn.url_prefix.to_s).to eq('https://custom.api.com/')
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'legion/extensions/identity/helpers/graph_token'
5
+
6
+ RSpec.describe Legion::Extensions::Identity::Helpers::GraphToken do
7
+ describe '.fetch' do
8
+ it 'raises GraphTokenError on failure' do
9
+ stub_request = instance_double(Faraday::Response, success?: false,
10
+ body: { 'error_description' => 'invalid_client' })
11
+ conn = instance_double(Faraday::Connection)
12
+ allow(conn).to receive(:post).and_return(stub_request)
13
+ allow(Faraday).to receive(:new).and_return(conn)
14
+
15
+ expect do
16
+ described_class.fetch(tenant_id: 't1', client_id: 'c1', client_secret: 's1')
17
+ end.to raise_error(described_class::GraphTokenError, 'invalid_client')
18
+ end
19
+
20
+ it 'returns access_token on success' do
21
+ stub_request = instance_double(Faraday::Response, success?: true,
22
+ body: { 'access_token' => 'tok-123' })
23
+ conn = instance_double(Faraday::Connection)
24
+ allow(conn).to receive(:post).and_return(stub_request)
25
+ allow(Faraday).to receive(:new).and_return(conn)
26
+
27
+ token = described_class.fetch(tenant_id: 't1', client_id: 'c1', client_secret: 's1')
28
+ expect(token).to eq('tok-123')
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'legion/extensions/identity/helpers/token_cache'
5
+
6
+ RSpec.describe Legion::Extensions::Identity::Helpers::TokenCache do
7
+ before { described_class.clear_all }
8
+ after { described_class.clear_all }
9
+
10
+ describe '.store and .fetch' do
11
+ it 'stores and retrieves a token' do
12
+ described_class.store(worker_id: 'w1', token: 'abc', expires_in: 3600)
13
+ entry = described_class.fetch(worker_id: 'w1')
14
+ expect(entry[:access_token]).to eq('abc')
15
+ end
16
+
17
+ it 'returns nil for unknown worker' do
18
+ expect(described_class.fetch(worker_id: 'unknown')).to be_nil
19
+ end
20
+
21
+ it 'returns nil for expired token' do
22
+ described_class.store(worker_id: 'w1', token: 'abc', expires_in: -1)
23
+ expect(described_class.fetch(worker_id: 'w1')).to be_nil
24
+ end
25
+ end
26
+
27
+ describe '.approaching_expiry?' do
28
+ it 'returns true when no token exists' do
29
+ expect(described_class.approaching_expiry?(worker_id: 'w1')).to be true
30
+ end
31
+
32
+ it 'returns true when token is within buffer' do
33
+ described_class.store(worker_id: 'w1', token: 'abc', expires_in: 100)
34
+ expect(described_class.approaching_expiry?(worker_id: 'w1', buffer: 200)).to be true
35
+ end
36
+
37
+ it 'returns false when token has plenty of time' do
38
+ described_class.store(worker_id: 'w1', token: 'abc', expires_in: 3600)
39
+ expect(described_class.approaching_expiry?(worker_id: 'w1', buffer: 300)).to be false
40
+ end
41
+ end
42
+
43
+ describe '.clear' do
44
+ it 'removes a specific worker token' do
45
+ described_class.store(worker_id: 'w1', token: 'abc', expires_in: 3600)
46
+ described_class.clear(worker_id: 'w1')
47
+ expect(described_class.fetch(worker_id: 'w1')).to be_nil
48
+ end
49
+ end
50
+ end
@@ -44,6 +44,7 @@ RSpec.describe Legion::Extensions::Identity::Runners::Entra do
44
44
 
45
45
  scope_double = double('Scope')
46
46
  allow(scope_double).to receive(:all).and_return(active_all)
47
+ allow(scope_double).to receive(:update)
47
48
 
48
49
  model_double = double('DigitalWorker model')
49
50
  allow(model_double).to receive(:first).and_return(worker_double)
@@ -238,24 +239,112 @@ RSpec.describe Legion::Extensions::Identity::Runners::Entra do
238
239
  end
239
240
 
240
241
  # ---------------------------------------------------------------------------
241
- # sync_owner
242
+ # refresh_access_token
242
243
  # ---------------------------------------------------------------------------
243
244
 
244
- describe '#sync_owner' do
245
- context 'when the worker exists' do
246
- it 'returns synced: true with current owner info from local record' do
247
- stub_data_model(build_model_double)
245
+ describe '#refresh_access_token' do
246
+ let(:vault_secrets_mod) do
247
+ Module.new do
248
+ def self.read_client_secret(worker_id:) # rubocop:disable Lint/UnusedMethodArgument
249
+ { client_id: 'app-id', client_secret: 'secret-val' }
250
+ end
251
+ end
252
+ end
248
253
 
249
- result = client.sync_owner(worker_id: 'worker-abc')
254
+ before do
255
+ require 'legion/extensions/identity/helpers/token_cache'
256
+ Legion::Extensions::Identity::Helpers::TokenCache.clear_all
257
+ stub_const('Legion::Extensions::Identity::Helpers::VaultSecrets', vault_secrets_mod)
258
+ allow(client).to receive(:resolve_tenant_id).and_return('tenant-123')
259
+ end
250
260
 
251
- expect(result[:synced]).to be true
252
- expect(result[:worker_id]).to eq('worker-abc')
253
- expect(result[:owner_msid]).to eq('alice@example.com')
254
- expect(result[:source]).to eq(:local)
255
- expect(result[:synced_at]).to be_a(Time)
261
+ after { Legion::Extensions::Identity::Helpers::TokenCache.clear_all }
262
+
263
+ it 'returns cached token when available and not expiring' do
264
+ Legion::Extensions::Identity::Helpers::TokenCache.store(worker_id: 'w1', token: 'cached', expires_in: 3600)
265
+ result = client.refresh_access_token(worker_id: 'w1')
266
+ expect(result[:refreshed]).to be false
267
+ expect(result[:source]).to eq(:cache)
268
+ end
269
+
270
+ it 'returns error when vault unavailable' do
271
+ vault_nil = Module.new do
272
+ def self.read_client_secret(**) = nil
273
+ end
274
+ stub_const('Legion::Extensions::Identity::Helpers::VaultSecrets', vault_nil)
275
+ result = client.refresh_access_token(worker_id: 'w1')
276
+ expect(result[:error]).to eq('vault_unavailable')
277
+ end
278
+
279
+ it 'returns error when no tenant_id' do
280
+ allow(client).to receive(:resolve_tenant_id).and_return(nil)
281
+ result = client.refresh_access_token(worker_id: 'w1')
282
+ expect(result[:error]).to eq('no_tenant_id')
283
+ end
284
+ end
285
+
286
+ # ---------------------------------------------------------------------------
287
+ # rotate_client_secret
288
+ # ---------------------------------------------------------------------------
289
+
290
+ describe '#rotate_client_secret' do
291
+ before do
292
+ stub_const('Legion::Settings', Class.new do
293
+ def self.dig(*keys)
294
+ map = {
295
+ %i[identity entra rotation_enabled] => false,
296
+ %i[identity entra rotation_buffer_days] => 30
297
+ }
298
+ map[keys]
299
+ end
300
+
301
+ def self.[](_key) = {}
302
+ end)
303
+ end
304
+
305
+ it 'returns no action when no expiry tracked' do
306
+ vault_mod = Module.new do
307
+ def self.read_client_secret(**) = { client_secret: 'val' }
308
+ end
309
+ stub_const('Legion::Extensions::Identity::Helpers::VaultSecrets', vault_mod)
310
+
311
+ result = client.rotate_client_secret(worker_id: 'w1')
312
+ expect(result[:action_required]).to be false
313
+ expect(result[:reason]).to eq('no_expiry_tracked')
314
+ end
315
+
316
+ it 'emits warning when rotation not enabled and secret expiring' do
317
+ vault_mod = Module.new do
318
+ def self.read_client_secret(**)
319
+ { client_secret: 'val', client_secret_expires_at: (Time.now + (86_400 * 10)).iso8601 }
320
+ end
256
321
  end
322
+ stub_const('Legion::Extensions::Identity::Helpers::VaultSecrets', vault_mod)
323
+
324
+ result = client.rotate_client_secret(worker_id: 'w1')
325
+ expect(result[:action_required]).to be true
326
+ expect(result[:days_remaining]).to be_within(0.5).of(10.0)
257
327
  end
258
328
 
329
+ it 'returns no action when secret has plenty of time' do
330
+ vault_mod = Module.new do
331
+ def self.read_client_secret(**)
332
+ { client_secret: 'val', client_secret_expires_at: (Time.now + (86_400 * 60)).iso8601 }
333
+ end
334
+ end
335
+ stub_const('Legion::Extensions::Identity::Helpers::VaultSecrets', vault_mod)
336
+
337
+ result = client.rotate_client_secret(worker_id: 'w1')
338
+ expect(result[:action_required]).to be false
339
+ expect(result[:days_remaining]).to be > 30
340
+ end
341
+ end
342
+
343
+ # ---------------------------------------------------------------------------
344
+ # sync_owner
345
+ # ---------------------------------------------------------------------------
346
+
347
+ describe '#sync_owner' do
259
348
  context 'when the worker does not exist' do
260
349
  it 'returns synced: false with error' do
261
350
  model_double = double('DigitalWorker model')
@@ -268,6 +357,61 @@ RSpec.describe Legion::Extensions::Identity::Runners::Entra do
268
357
  expect(result[:error]).to eq('worker not found')
269
358
  end
270
359
  end
360
+
361
+ context 'when credentials are unavailable' do
362
+ it 'falls back to local source' do
363
+ stub_data_model(build_model_double)
364
+ allow(client).to receive(:resolve_graph_credentials).and_return(nil)
365
+
366
+ result = client.sync_owner(worker_id: 'worker-abc')
367
+
368
+ expect(result[:synced]).to be true
369
+ expect(result[:source]).to eq(:local)
370
+ expect(result[:owner_msid]).to eq('alice@example.com')
371
+ end
372
+ end
373
+
374
+ context 'when Graph API call succeeds' do
375
+ before do
376
+ stub_data_model(build_model_double)
377
+ allow(client).to receive(:resolve_graph_credentials)
378
+ .and_return({ tenant_id: 't', client_id: 'c', client_secret: 's' })
379
+
380
+ graph_token = Legion::Extensions::Identity::Helpers::GraphToken
381
+ allow(graph_token).to receive(:fetch).and_return('token')
382
+
383
+ @conn = instance_double(Faraday::Connection)
384
+ allow(Legion::Extensions::Identity::Helpers::GraphClient).to receive(:connection).and_return(@conn)
385
+ end
386
+
387
+ it 'returns source: :graph_api on success' do
388
+ allow(@conn).to receive(:get)
389
+ .and_return(double(success?: true, body: { 'value' => [{ 'id' => 'owner-123' }] }))
390
+
391
+ result = client.sync_owner(worker_id: 'worker-abc')
392
+ expect(result[:source]).to eq(:graph_api)
393
+ expect(result[:synced]).to be true
394
+ end
395
+
396
+ it 'returns source: :local when Graph API call fails' do
397
+ allow(@conn).to receive(:get).and_return(double(success?: false, status: 403))
398
+
399
+ result = client.sync_owner(worker_id: 'worker-abc')
400
+ expect(result[:synced]).to be false
401
+ expect(result[:source]).to eq(:local)
402
+ end
403
+ end
404
+
405
+ context 'when worker has no entra_object_id' do
406
+ it 'returns error with source: :local' do
407
+ worker_no_obj = worker_record.merge(entra_object_id: nil)
408
+ stub_data_model(build_model_double(worker_hash: worker_no_obj))
409
+
410
+ result = client.sync_owner(worker_id: 'worker-abc')
411
+ expect(result[:synced]).to be false
412
+ expect(result[:error]).to eq('no entra_object_id')
413
+ end
414
+ end
271
415
  end
272
416
 
273
417
  # ---------------------------------------------------------------------------
@@ -378,6 +522,7 @@ RSpec.describe Legion::Extensions::Identity::Runners::Entra do
378
522
  context 'when there are no active workers' do
379
523
  it 'returns empty orphans list with checked count of 0' do
380
524
  stub_data_model(build_model_double(active_all: []))
525
+ allow(client).to receive(:resolve_graph_credentials).and_return(nil)
381
526
 
382
527
  result = client.check_orphans
383
528
 
@@ -388,11 +533,12 @@ RSpec.describe Legion::Extensions::Identity::Runners::Entra do
388
533
  end
389
534
  end
390
535
 
391
- context 'when there are active workers' do
392
- it 'returns the count of workers scanned and an empty orphans list (pending Entra validation)' do
536
+ context 'when credentials are unavailable' do
537
+ it 'returns source: :local with workers scanned' do
393
538
  active_worker = double('DigitalWorker', worker_id: 'worker-abc', owner_msid: 'alice@example.com',
394
539
  entra_app_id: 'entra-app-abc')
395
540
  stub_data_model(build_model_double(active_all: [active_worker]))
541
+ allow(client).to receive(:resolve_graph_credentials).and_return(nil)
396
542
 
397
543
  result = client.check_orphans
398
544
 
@@ -401,5 +547,109 @@ RSpec.describe Legion::Extensions::Identity::Runners::Entra do
401
547
  expect(result[:source]).to eq(:local)
402
548
  end
403
549
  end
550
+
551
+ context 'when Graph API detects orphan (app deleted)' do
552
+ it 'auto-pauses the orphaned worker' do
553
+ active_worker = double('DigitalWorker', worker_id: 'worker-abc', owner_msid: 'alice@example.com',
554
+ entra_app_id: 'entra-app-abc', entra_object_id: 'obj-456')
555
+ allow(active_worker).to receive(:update)
556
+ stub_data_model(build_model_double(active_all: [active_worker]))
557
+
558
+ allow(client).to receive(:resolve_graph_credentials)
559
+ .and_return({ tenant_id: 't', client_id: 'c', client_secret: 's' })
560
+ allow(Legion::Extensions::Identity::Helpers::GraphToken).to receive(:fetch).and_return('token')
561
+
562
+ conn = instance_double(Faraday::Connection)
563
+ allow(Legion::Extensions::Identity::Helpers::GraphClient).to receive(:connection).and_return(conn)
564
+ allow(conn).to receive(:get).with('applications/obj-456').and_return(double(success?: false))
565
+
566
+ result = client.check_orphans
567
+ expect(result[:orphans].size).to eq(1)
568
+ expect(result[:source]).to eq(:graph_api)
569
+ end
570
+ end
571
+
572
+ context 'when one worker errors during scan' do
573
+ it 'continues scanning remaining workers' do
574
+ w1 = double('DigitalWorker', worker_id: 'w1', owner_msid: 'a@x.com',
575
+ entra_app_id: 'app1', entra_object_id: 'obj1')
576
+ w2 = double('DigitalWorker', worker_id: 'w2', owner_msid: 'b@x.com',
577
+ entra_app_id: 'app2', entra_object_id: 'obj2')
578
+ stub_data_model(build_model_double(active_all: [w1, w2]))
579
+
580
+ allow(client).to receive(:resolve_graph_credentials)
581
+ .and_return({ tenant_id: 't', client_id: 'c', client_secret: 's' })
582
+ allow(Legion::Extensions::Identity::Helpers::GraphToken).to receive(:fetch).and_return('token')
583
+
584
+ conn = instance_double(Faraday::Connection)
585
+ allow(Legion::Extensions::Identity::Helpers::GraphClient).to receive(:connection).and_return(conn)
586
+ allow(conn).to receive(:get).with('applications/obj1').and_raise(Faraday::ConnectionFailed, 'timeout')
587
+ allow(conn).to receive(:get).with('applications/obj2').and_return(double(success?: true))
588
+ allow(conn).to receive(:get).with('users/b@x.com').and_return(double(success?: true, body: { 'accountEnabled' => true }))
589
+
590
+ result = client.check_orphans
591
+ expect(result[:checked]).to eq(2)
592
+ end
593
+ end
594
+ end
595
+
596
+ # ---------------------------------------------------------------------------
597
+ # resolve_governance_roles
598
+ # ---------------------------------------------------------------------------
599
+
600
+ describe '#resolve_governance_roles' do
601
+ let(:group_map) do
602
+ {
603
+ '00000000-0000-0000-0000-000000000001' => 'admin',
604
+ '00000000-0000-0000-0000-000000000002' => 'governance-council',
605
+ '00000000-0000-0000-0000-000000000003' => 'owner'
606
+ }
607
+ end
608
+
609
+ before do
610
+ stub_const('Legion::Settings', Class.new do
611
+ def self.dig(*keys)
612
+ map = {
613
+ %i[rbac entra group_map] => {
614
+ '00000000-0000-0000-0000-000000000001' => 'admin',
615
+ '00000000-0000-0000-0000-000000000002' => 'governance-council',
616
+ '00000000-0000-0000-0000-000000000003' => 'owner'
617
+ },
618
+ %i[rbac entra default_role] => 'governance-observer'
619
+ }
620
+ map[keys]
621
+ end
622
+
623
+ def self.[](_key) = {}
624
+ end)
625
+ end
626
+
627
+ it 'returns matched role for known group OID' do
628
+ result = client.resolve_governance_roles(groups: ['00000000-0000-0000-0000-000000000001'])
629
+ expect(result[:success]).to be true
630
+ expect(result[:roles]).to eq(['admin'])
631
+ end
632
+
633
+ it 'returns all matched roles for multiple groups' do
634
+ result = client.resolve_governance_roles(
635
+ groups: %w[00000000-0000-0000-0000-000000000001 00000000-0000-0000-0000-000000000002]
636
+ )
637
+ expect(result[:roles]).to contain_exactly('admin', 'governance-council')
638
+ end
639
+
640
+ it 'returns default_role when no groups match' do
641
+ result = client.resolve_governance_roles(groups: ['unknown-oid'])
642
+ expect(result[:roles]).to eq(['governance-observer'])
643
+ end
644
+
645
+ it 'returns default_role when groups is nil' do
646
+ result = client.resolve_governance_roles(groups: nil)
647
+ expect(result[:roles]).to eq(['governance-observer'])
648
+ end
649
+
650
+ it 'returns default_role when groups is empty' do
651
+ result = client.resolve_governance_roles(groups: [])
652
+ expect(result[:roles]).to eq(['governance-observer'])
653
+ end
404
654
  end
405
655
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-identity
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -48,10 +48,14 @@ files:
48
48
  - Gemfile
49
49
  - lex-identity.gemspec
50
50
  - lib/legion/extensions/identity.rb
51
+ - lib/legion/extensions/identity/actors/credential_refresh.rb
51
52
  - lib/legion/extensions/identity/actors/orphan_check.rb
52
53
  - lib/legion/extensions/identity/client.rb
53
54
  - lib/legion/extensions/identity/helpers/dimensions.rb
54
55
  - lib/legion/extensions/identity/helpers/fingerprint.rb
56
+ - lib/legion/extensions/identity/helpers/graph_client.rb
57
+ - lib/legion/extensions/identity/helpers/graph_token.rb
58
+ - lib/legion/extensions/identity/helpers/token_cache.rb
55
59
  - lib/legion/extensions/identity/helpers/vault_secrets.rb
56
60
  - lib/legion/extensions/identity/local_migrations/20260316000030_create_fingerprint.rb
57
61
  - lib/legion/extensions/identity/runners/entra.rb
@@ -61,6 +65,9 @@ files:
61
65
  - spec/legion/extensions/identity/client_spec.rb
62
66
  - spec/legion/extensions/identity/helpers/dimensions_spec.rb
63
67
  - spec/legion/extensions/identity/helpers/fingerprint_spec.rb
68
+ - spec/legion/extensions/identity/helpers/graph_client_spec.rb
69
+ - spec/legion/extensions/identity/helpers/graph_token_spec.rb
70
+ - spec/legion/extensions/identity/helpers/token_cache_spec.rb
64
71
  - spec/legion/extensions/identity/runners/entra_spec.rb
65
72
  - spec/legion/extensions/identity/runners/identity_spec.rb
66
73
  - spec/local_persistence_spec.rb