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 +4 -4
- data/Gemfile +1 -0
- data/lib/legion/extensions/identity/actors/credential_refresh.rb +45 -0
- data/lib/legion/extensions/identity/helpers/graph_client.rb +25 -0
- data/lib/legion/extensions/identity/helpers/graph_token.rb +32 -0
- data/lib/legion/extensions/identity/helpers/token_cache.rb +55 -0
- data/lib/legion/extensions/identity/runners/entra.rb +196 -21
- data/lib/legion/extensions/identity/version.rb +1 -1
- data/spec/legion/extensions/identity/helpers/graph_client_spec.rb +19 -0
- data/spec/legion/extensions/identity/helpers/graph_token_spec.rb +31 -0
- data/spec/legion/extensions/identity/helpers/token_cache_spec.rb +50 -0
- data/spec/legion/extensions/identity/runners/entra_spec.rb +263 -13
- metadata +8 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 402e57fb1c2fafdaac3bbbfd79ab0cd34d448798fe9f61e14511002845dc3d7c
|
|
4
|
+
data.tar.gz: 30585e986691ac0bee3bd2111f291fdc4adcef5c6ef83179c16a4e12d3e6eb67
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 87d225ef73a724bcbf18649664670e56ed5eb93c7f5f2312ee19cf70550191c41849a4e25bb303aee50e98b330962269cd2c0c53c3781f5926a5fca89ed047f4
|
|
7
|
+
data.tar.gz: 798d6de090c2666919a3bc778adb4decaa0c08e586a14d03fc6e781a13ad2d33ecad0cb35f852b41c12d59c830c0f69b248aaddafe54e6b59e96d4800129dc47
|
data/Gemfile
CHANGED
|
@@ -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
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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: :
|
|
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:
|
|
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
|
|
|
@@ -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
|
-
#
|
|
242
|
+
# refresh_access_token
|
|
242
243
|
# ---------------------------------------------------------------------------
|
|
243
244
|
|
|
244
|
-
describe '#
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
|
392
|
-
it 'returns
|
|
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.
|
|
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
|