lex-identity 0.3.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 +4 -6
- data/lex-identity.gemspec +12 -15
- data/lib/legion/extensions/identity/actors/credential_refresh.rb +45 -0
- data/lib/legion/extensions/identity/actors/orphan_check.rb +48 -0
- data/lib/legion/extensions/identity/client.rb +23 -0
- data/lib/legion/extensions/identity/helpers/dimensions.rb +71 -0
- data/lib/legion/extensions/identity/helpers/fingerprint.rb +166 -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/helpers/vault_secrets.rb +76 -0
- data/lib/legion/extensions/identity/local_migrations/20260316000030_create_fingerprint.rb +20 -0
- data/lib/legion/extensions/identity/runners/entra.rb +398 -0
- data/lib/legion/extensions/identity/runners/identity.rb +86 -0
- data/lib/legion/extensions/identity/version.rb +1 -1
- data/lib/legion/extensions/identity.rb +14 -8
- data/spec/legion/extensions/identity/actors/orphan_check_spec.rb +104 -0
- data/spec/legion/extensions/identity/client_spec.rb +32 -0
- data/spec/legion/extensions/identity/helpers/dimensions_spec.rb +51 -0
- data/spec/legion/extensions/identity/helpers/fingerprint_spec.rb +66 -0
- 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 +655 -0
- data/spec/legion/extensions/identity/runners/identity_spec.rb +61 -0
- data/spec/local_persistence_spec.rb +329 -0
- data/spec/spec_helper.rb +33 -0
- metadata +40 -67
- data/CHANGELOG.md +0 -27
- data/LICENSE +0 -21
- data/README.md +0 -230
- data/lib/legion/extensions/identity/helpers/browser.rb +0 -44
- data/lib/legion/extensions/identity/helpers/callback_server.rb +0 -170
- data/lib/legion/extensions/identity/helpers/oauth.rb +0 -62
- data/lib/legion/extensions/identity/helpers/pkce.rb +0 -20
- data/lib/legion/extensions/identity/helpers/provider.rb +0 -52
- data/lib/legion/extensions/identity/runners/base/oauth.rb +0 -140
- data/spec/support/provider_contract.rb +0 -49
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/identity/runners/entra'
|
|
4
|
+
|
|
5
|
+
# Minimal stubs for Legion::Logging so the runner can call it without the full framework
|
|
6
|
+
unless defined?(Legion::Logging)
|
|
7
|
+
module Legion
|
|
8
|
+
module Logging
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def debug(*); end
|
|
12
|
+
def info(*); end
|
|
13
|
+
def warn(*); end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
RSpec.describe Legion::Extensions::Identity::Runners::Entra do
|
|
19
|
+
# Thin host class that includes the runner module so we can call runner methods directly
|
|
20
|
+
let(:host_class) do
|
|
21
|
+
Class.new do
|
|
22
|
+
include Legion::Extensions::Identity::Runners::Entra
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
let(:client) { host_class.new }
|
|
27
|
+
|
|
28
|
+
# A reusable worker record hash returned by the model double
|
|
29
|
+
let(:worker_record) do
|
|
30
|
+
{
|
|
31
|
+
worker_id: 'worker-abc',
|
|
32
|
+
entra_app_id: 'app-id-123',
|
|
33
|
+
entra_object_id: 'obj-id-456',
|
|
34
|
+
owner_msid: 'alice@example.com',
|
|
35
|
+
lifecycle_state: 'active'
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Helper: build a model double that responds to .first and .where(...).all
|
|
40
|
+
def build_model_double(worker_hash: worker_record, active_all: [])
|
|
41
|
+
worker_double = instance_double('DigitalWorker')
|
|
42
|
+
allow(worker_double).to receive(:to_hash).and_return(worker_hash)
|
|
43
|
+
allow(worker_double).to receive(:update)
|
|
44
|
+
|
|
45
|
+
scope_double = double('Scope')
|
|
46
|
+
allow(scope_double).to receive(:all).and_return(active_all)
|
|
47
|
+
allow(scope_double).to receive(:update)
|
|
48
|
+
|
|
49
|
+
model_double = double('DigitalWorker model')
|
|
50
|
+
allow(model_double).to receive(:first).and_return(worker_double)
|
|
51
|
+
allow(model_double).to receive(:where).and_return(scope_double)
|
|
52
|
+
|
|
53
|
+
model_double
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Helper: stub Legion::Data and the DigitalWorker model constant so `defined?` guards pass
|
|
57
|
+
def stub_data_model(model_double)
|
|
58
|
+
stub_const('Legion::Data', Module.new)
|
|
59
|
+
stub_const('Legion::Data::Model', Module.new)
|
|
60
|
+
stub_const('Legion::Data::Model::DigitalWorker', model_double)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
# validate_worker_identity
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
describe '#validate_worker_identity' do
|
|
68
|
+
context 'when the worker exists with an entra_app_id' do
|
|
69
|
+
it 'returns valid: true with identity fields' do
|
|
70
|
+
stub_data_model(build_model_double)
|
|
71
|
+
|
|
72
|
+
result = client.validate_worker_identity(worker_id: 'worker-abc')
|
|
73
|
+
|
|
74
|
+
expect(result[:valid]).to be true
|
|
75
|
+
expect(result[:worker_id]).to eq('worker-abc')
|
|
76
|
+
expect(result[:entra_app_id]).to eq('app-id-123')
|
|
77
|
+
expect(result[:owner_msid]).to eq('alice@example.com')
|
|
78
|
+
expect(result[:lifecycle]).to eq('active')
|
|
79
|
+
expect(result[:validated_at]).to be_a(Time)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
it 'uses the caller-supplied entra_app_id when provided' do
|
|
83
|
+
stub_data_model(build_model_double)
|
|
84
|
+
|
|
85
|
+
result = client.validate_worker_identity(worker_id: 'worker-abc', entra_app_id: 'override-app-id')
|
|
86
|
+
|
|
87
|
+
expect(result[:valid]).to be true
|
|
88
|
+
expect(result[:entra_app_id]).to eq('override-app-id')
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
context 'when the worker does not exist' do
|
|
93
|
+
it 'returns valid: false with an error message' do
|
|
94
|
+
model_double = double('DigitalWorker model')
|
|
95
|
+
allow(model_double).to receive(:first).and_return(nil)
|
|
96
|
+
stub_data_model(model_double)
|
|
97
|
+
|
|
98
|
+
result = client.validate_worker_identity(worker_id: 'no-such-worker')
|
|
99
|
+
|
|
100
|
+
expect(result[:valid]).to be false
|
|
101
|
+
expect(result[:error]).to eq('worker not found')
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
context 'when Legion::Data is not available' do
|
|
106
|
+
it 'returns valid: false because find_worker returns nil' do
|
|
107
|
+
result = client.validate_worker_identity(worker_id: 'worker-abc')
|
|
108
|
+
|
|
109
|
+
expect(result[:valid]).to be false
|
|
110
|
+
expect(result[:error]).to eq('worker not found')
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
context 'when the worker record has no entra_app_id' do
|
|
115
|
+
it 'returns valid: false with no entra_app_id error' do
|
|
116
|
+
worker_without_app = worker_record.merge(entra_app_id: nil)
|
|
117
|
+
stub_data_model(build_model_double(worker_hash: worker_without_app))
|
|
118
|
+
|
|
119
|
+
result = client.validate_worker_identity(worker_id: 'worker-abc')
|
|
120
|
+
|
|
121
|
+
expect(result[:valid]).to be false
|
|
122
|
+
expect(result[:error]).to eq('no entra_app_id')
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
# validate_worker_identity — JWKS token validation
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
describe '#validate_worker_identity (JWKS)' do
|
|
132
|
+
context 'without token' do
|
|
133
|
+
it 'returns valid without token validation' do
|
|
134
|
+
stub_data_model(build_model_double)
|
|
135
|
+
|
|
136
|
+
result = client.validate_worker_identity(worker_id: 'worker-abc')
|
|
137
|
+
expect(result[:valid]).to be true
|
|
138
|
+
expect(result).not_to have_key(:claims)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
context 'with token and JWKS support' do
|
|
143
|
+
let(:claims) { { sub: 'worker-abc', iss: 'https://login.microsoftonline.com/tenant-1/v2.0' } }
|
|
144
|
+
|
|
145
|
+
before do
|
|
146
|
+
stub_data_model(build_model_double)
|
|
147
|
+
jwt_mod = Module.new do
|
|
148
|
+
def self.verify_with_jwks(*, **)
|
|
149
|
+
nil
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
stub_const('Legion::Crypt::JWT', jwt_mod)
|
|
153
|
+
allow(Legion::Crypt::JWT).to receive(:verify_with_jwks).and_return(claims)
|
|
154
|
+
allow(client).to receive(:resolve_tenant_id).and_return('tenant-1')
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
it 'validates the token via JWKS' do
|
|
158
|
+
result = client.validate_worker_identity(worker_id: 'worker-abc', token: 'jwt-token')
|
|
159
|
+
expect(result[:valid]).to be true
|
|
160
|
+
expect(result[:claims]).to eq(claims)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
it 'passes correct JWKS URL and issuer' do
|
|
164
|
+
expect(Legion::Crypt::JWT).to receive(:verify_with_jwks).with(
|
|
165
|
+
'jwt-token',
|
|
166
|
+
jwks_url: 'https://login.microsoftonline.com/tenant-1/discovery/v2.0/keys',
|
|
167
|
+
issuers: ['https://login.microsoftonline.com/tenant-1/v2.0'],
|
|
168
|
+
audience: 'app-id-123'
|
|
169
|
+
)
|
|
170
|
+
client.validate_worker_identity(worker_id: 'worker-abc', token: 'jwt-token')
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
context 'when token is expired' do
|
|
175
|
+
before do
|
|
176
|
+
stub_data_model(build_model_double)
|
|
177
|
+
jwt_mod = Module.new do
|
|
178
|
+
def self.verify_with_jwks(*, **)
|
|
179
|
+
nil
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
stub_const('Legion::Crypt::JWT', jwt_mod)
|
|
183
|
+
stub_const('Legion::Crypt::JWT::Error', Class.new(StandardError))
|
|
184
|
+
stub_const('Legion::Crypt::JWT::ExpiredTokenError', Class.new(Legion::Crypt::JWT::Error))
|
|
185
|
+
allow(Legion::Crypt::JWT).to receive(:verify_with_jwks)
|
|
186
|
+
.and_raise(Legion::Crypt::JWT::ExpiredTokenError, 'token has expired')
|
|
187
|
+
allow(client).to receive(:resolve_tenant_id).and_return('tenant-1')
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
it 'returns valid false with token_expired error' do
|
|
191
|
+
result = client.validate_worker_identity(worker_id: 'worker-abc', token: 'expired-jwt')
|
|
192
|
+
expect(result[:valid]).to be false
|
|
193
|
+
expect(result[:error]).to eq('token_expired')
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
context 'when token signature is invalid' do
|
|
198
|
+
before do
|
|
199
|
+
stub_data_model(build_model_double)
|
|
200
|
+
jwt_mod = Module.new do
|
|
201
|
+
def self.verify_with_jwks(*, **)
|
|
202
|
+
nil
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
stub_const('Legion::Crypt::JWT', jwt_mod)
|
|
206
|
+
stub_const('Legion::Crypt::JWT::Error', Class.new(StandardError))
|
|
207
|
+
stub_const('Legion::Crypt::JWT::ExpiredTokenError', Class.new(Legion::Crypt::JWT::Error))
|
|
208
|
+
stub_const('Legion::Crypt::JWT::InvalidTokenError', Class.new(Legion::Crypt::JWT::Error))
|
|
209
|
+
allow(Legion::Crypt::JWT).to receive(:verify_with_jwks)
|
|
210
|
+
.and_raise(Legion::Crypt::JWT::InvalidTokenError, 'signature verification failed')
|
|
211
|
+
allow(client).to receive(:resolve_tenant_id).and_return('tenant-1')
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
it 'returns valid false with token_invalid error' do
|
|
215
|
+
result = client.validate_worker_identity(worker_id: 'worker-abc', token: 'bad-jwt')
|
|
216
|
+
expect(result[:valid]).to be false
|
|
217
|
+
expect(result[:error]).to eq('token_invalid')
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
context 'when no tenant_id configured' do
|
|
222
|
+
before do
|
|
223
|
+
stub_data_model(build_model_double)
|
|
224
|
+
jwt_mod = Module.new do
|
|
225
|
+
def self.verify_with_jwks(*, **)
|
|
226
|
+
nil
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
stub_const('Legion::Crypt::JWT', jwt_mod)
|
|
230
|
+
allow(client).to receive(:resolve_tenant_id).and_return(nil)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
it 'returns valid false with no tenant_id error' do
|
|
234
|
+
result = client.validate_worker_identity(worker_id: 'worker-abc', token: 'jwt-token')
|
|
235
|
+
expect(result[:valid]).to be false
|
|
236
|
+
expect(result[:error]).to eq('no tenant_id configured')
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# ---------------------------------------------------------------------------
|
|
242
|
+
# refresh_access_token
|
|
243
|
+
# ---------------------------------------------------------------------------
|
|
244
|
+
|
|
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
|
|
253
|
+
|
|
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
|
|
260
|
+
|
|
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
|
|
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)
|
|
327
|
+
end
|
|
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
|
|
348
|
+
context 'when the worker does not exist' do
|
|
349
|
+
it 'returns synced: false with error' do
|
|
350
|
+
model_double = double('DigitalWorker model')
|
|
351
|
+
allow(model_double).to receive(:first).and_return(nil)
|
|
352
|
+
stub_data_model(model_double)
|
|
353
|
+
|
|
354
|
+
result = client.sync_owner(worker_id: 'no-such-worker')
|
|
355
|
+
|
|
356
|
+
expect(result[:synced]).to be false
|
|
357
|
+
expect(result[:error]).to eq('worker not found')
|
|
358
|
+
end
|
|
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
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# ---------------------------------------------------------------------------
|
|
418
|
+
# transfer_ownership
|
|
419
|
+
# ---------------------------------------------------------------------------
|
|
420
|
+
|
|
421
|
+
describe '#transfer_ownership' do
|
|
422
|
+
context 'when the worker exists and the new owner differs' do
|
|
423
|
+
it 'returns transferred: true with full audit fields' do
|
|
424
|
+
model_double = build_model_double
|
|
425
|
+
stub_data_model(model_double)
|
|
426
|
+
|
|
427
|
+
result = client.transfer_ownership(
|
|
428
|
+
worker_id: 'worker-abc',
|
|
429
|
+
new_owner_msid: 'bob@example.com',
|
|
430
|
+
transferred_by: 'admin@example.com',
|
|
431
|
+
reason: 'role change'
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
expect(result[:transferred]).to be true
|
|
435
|
+
expect(result[:event]).to eq(:ownership_transferred)
|
|
436
|
+
expect(result[:from_owner]).to eq('alice@example.com')
|
|
437
|
+
expect(result[:to_owner]).to eq('bob@example.com')
|
|
438
|
+
expect(result[:transferred_by]).to eq('admin@example.com')
|
|
439
|
+
expect(result[:reason]).to eq('role change')
|
|
440
|
+
expect(result[:at]).to be_a(Time)
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
it 'emits a Legion::Events event when Legion::Events is available' do
|
|
444
|
+
model_double = build_model_double
|
|
445
|
+
stub_data_model(model_double)
|
|
446
|
+
events_double = double('Legion::Events')
|
|
447
|
+
stub_const('Legion::Events', events_double)
|
|
448
|
+
expect(events_double).to receive(:emit).with('worker.ownership_transferred', hash_including(event: :ownership_transferred))
|
|
449
|
+
|
|
450
|
+
client.transfer_ownership(
|
|
451
|
+
worker_id: 'worker-abc',
|
|
452
|
+
new_owner_msid: 'bob@example.com',
|
|
453
|
+
transferred_by: 'admin@example.com'
|
|
454
|
+
)
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
it 'updates the database record when Legion::Data is available' do
|
|
458
|
+
worker_double = instance_double('DigitalWorker')
|
|
459
|
+
allow(worker_double).to receive(:to_hash).and_return(worker_record)
|
|
460
|
+
expect(worker_double).to receive(:update).with(hash_including(owner_msid: 'bob@example.com'))
|
|
461
|
+
|
|
462
|
+
model_double = double('DigitalWorker model')
|
|
463
|
+
allow(model_double).to receive(:first).and_return(worker_double)
|
|
464
|
+
stub_data_model(model_double)
|
|
465
|
+
|
|
466
|
+
client.transfer_ownership(
|
|
467
|
+
worker_id: 'worker-abc',
|
|
468
|
+
new_owner_msid: 'bob@example.com',
|
|
469
|
+
transferred_by: 'admin@example.com'
|
|
470
|
+
)
|
|
471
|
+
end
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
context 'when new_owner_msid is the same as the current owner' do
|
|
475
|
+
it 'returns transferred: false with same owner error' do
|
|
476
|
+
stub_data_model(build_model_double)
|
|
477
|
+
|
|
478
|
+
result = client.transfer_ownership(
|
|
479
|
+
worker_id: 'worker-abc',
|
|
480
|
+
new_owner_msid: 'alice@example.com',
|
|
481
|
+
transferred_by: 'admin@example.com'
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
expect(result[:transferred]).to be false
|
|
485
|
+
expect(result[:error]).to eq('same owner')
|
|
486
|
+
end
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
context 'when the worker does not exist' do
|
|
490
|
+
it 'returns transferred: false with worker not found error' do
|
|
491
|
+
model_double = double('DigitalWorker model')
|
|
492
|
+
allow(model_double).to receive(:first).and_return(nil)
|
|
493
|
+
stub_data_model(model_double)
|
|
494
|
+
|
|
495
|
+
result = client.transfer_ownership(
|
|
496
|
+
worker_id: 'no-such-worker',
|
|
497
|
+
new_owner_msid: 'bob@example.com',
|
|
498
|
+
transferred_by: 'admin@example.com'
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
expect(result[:transferred]).to be false
|
|
502
|
+
expect(result[:error]).to eq('worker not found')
|
|
503
|
+
end
|
|
504
|
+
end
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
# ---------------------------------------------------------------------------
|
|
508
|
+
# check_orphans
|
|
509
|
+
# ---------------------------------------------------------------------------
|
|
510
|
+
|
|
511
|
+
describe '#check_orphans' do
|
|
512
|
+
context 'when Legion::Data is not available' do
|
|
513
|
+
it 'returns empty orphans with source: :unavailable' do
|
|
514
|
+
result = client.check_orphans
|
|
515
|
+
|
|
516
|
+
expect(result[:orphans]).to eq([])
|
|
517
|
+
expect(result[:checked]).to eq(0)
|
|
518
|
+
expect(result[:source]).to eq(:unavailable)
|
|
519
|
+
end
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
context 'when there are no active workers' do
|
|
523
|
+
it 'returns empty orphans list with checked count of 0' do
|
|
524
|
+
stub_data_model(build_model_double(active_all: []))
|
|
525
|
+
allow(client).to receive(:resolve_graph_credentials).and_return(nil)
|
|
526
|
+
|
|
527
|
+
result = client.check_orphans
|
|
528
|
+
|
|
529
|
+
expect(result[:orphans]).to eq([])
|
|
530
|
+
expect(result[:checked]).to eq(0)
|
|
531
|
+
expect(result[:source]).to eq(:local)
|
|
532
|
+
expect(result[:checked_at]).to be_a(Time)
|
|
533
|
+
end
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
context 'when credentials are unavailable' do
|
|
537
|
+
it 'returns source: :local with workers scanned' do
|
|
538
|
+
active_worker = double('DigitalWorker', worker_id: 'worker-abc', owner_msid: 'alice@example.com',
|
|
539
|
+
entra_app_id: 'entra-app-abc')
|
|
540
|
+
stub_data_model(build_model_double(active_all: [active_worker]))
|
|
541
|
+
allow(client).to receive(:resolve_graph_credentials).and_return(nil)
|
|
542
|
+
|
|
543
|
+
result = client.check_orphans
|
|
544
|
+
|
|
545
|
+
expect(result[:checked]).to eq(1)
|
|
546
|
+
expect(result[:orphans]).to eq([])
|
|
547
|
+
expect(result[:source]).to eq(:local)
|
|
548
|
+
end
|
|
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
|
|
654
|
+
end
|
|
655
|
+
end
|