lex-identity 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,405 @@
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
+
48
+ model_double = double('DigitalWorker model')
49
+ allow(model_double).to receive(:first).and_return(worker_double)
50
+ allow(model_double).to receive(:where).and_return(scope_double)
51
+
52
+ model_double
53
+ end
54
+
55
+ # Helper: stub Legion::Data and the DigitalWorker model constant so `defined?` guards pass
56
+ def stub_data_model(model_double)
57
+ stub_const('Legion::Data', Module.new)
58
+ stub_const('Legion::Data::Model', Module.new)
59
+ stub_const('Legion::Data::Model::DigitalWorker', model_double)
60
+ end
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # validate_worker_identity
64
+ # ---------------------------------------------------------------------------
65
+
66
+ describe '#validate_worker_identity' do
67
+ context 'when the worker exists with an entra_app_id' do
68
+ it 'returns valid: true with identity fields' do
69
+ stub_data_model(build_model_double)
70
+
71
+ result = client.validate_worker_identity(worker_id: 'worker-abc')
72
+
73
+ expect(result[:valid]).to be true
74
+ expect(result[:worker_id]).to eq('worker-abc')
75
+ expect(result[:entra_app_id]).to eq('app-id-123')
76
+ expect(result[:owner_msid]).to eq('alice@example.com')
77
+ expect(result[:lifecycle]).to eq('active')
78
+ expect(result[:validated_at]).to be_a(Time)
79
+ end
80
+
81
+ it 'uses the caller-supplied entra_app_id when provided' do
82
+ stub_data_model(build_model_double)
83
+
84
+ result = client.validate_worker_identity(worker_id: 'worker-abc', entra_app_id: 'override-app-id')
85
+
86
+ expect(result[:valid]).to be true
87
+ expect(result[:entra_app_id]).to eq('override-app-id')
88
+ end
89
+ end
90
+
91
+ context 'when the worker does not exist' do
92
+ it 'returns valid: false with an error message' do
93
+ model_double = double('DigitalWorker model')
94
+ allow(model_double).to receive(:first).and_return(nil)
95
+ stub_data_model(model_double)
96
+
97
+ result = client.validate_worker_identity(worker_id: 'no-such-worker')
98
+
99
+ expect(result[:valid]).to be false
100
+ expect(result[:error]).to eq('worker not found')
101
+ end
102
+ end
103
+
104
+ context 'when Legion::Data is not available' do
105
+ it 'returns valid: false because find_worker returns nil' do
106
+ result = client.validate_worker_identity(worker_id: 'worker-abc')
107
+
108
+ expect(result[:valid]).to be false
109
+ expect(result[:error]).to eq('worker not found')
110
+ end
111
+ end
112
+
113
+ context 'when the worker record has no entra_app_id' do
114
+ it 'returns valid: false with no entra_app_id error' do
115
+ worker_without_app = worker_record.merge(entra_app_id: nil)
116
+ stub_data_model(build_model_double(worker_hash: worker_without_app))
117
+
118
+ result = client.validate_worker_identity(worker_id: 'worker-abc')
119
+
120
+ expect(result[:valid]).to be false
121
+ expect(result[:error]).to eq('no entra_app_id')
122
+ end
123
+ end
124
+ end
125
+
126
+ # ---------------------------------------------------------------------------
127
+ # validate_worker_identity — JWKS token validation
128
+ # ---------------------------------------------------------------------------
129
+
130
+ describe '#validate_worker_identity (JWKS)' do
131
+ context 'without token' do
132
+ it 'returns valid without token validation' do
133
+ stub_data_model(build_model_double)
134
+
135
+ result = client.validate_worker_identity(worker_id: 'worker-abc')
136
+ expect(result[:valid]).to be true
137
+ expect(result).not_to have_key(:claims)
138
+ end
139
+ end
140
+
141
+ context 'with token and JWKS support' do
142
+ let(:claims) { { sub: 'worker-abc', iss: 'https://login.microsoftonline.com/tenant-1/v2.0' } }
143
+
144
+ before do
145
+ stub_data_model(build_model_double)
146
+ jwt_mod = Module.new do
147
+ def self.verify_with_jwks(*, **)
148
+ nil
149
+ end
150
+ end
151
+ stub_const('Legion::Crypt::JWT', jwt_mod)
152
+ allow(Legion::Crypt::JWT).to receive(:verify_with_jwks).and_return(claims)
153
+ allow(client).to receive(:resolve_tenant_id).and_return('tenant-1')
154
+ end
155
+
156
+ it 'validates the token via JWKS' do
157
+ result = client.validate_worker_identity(worker_id: 'worker-abc', token: 'jwt-token')
158
+ expect(result[:valid]).to be true
159
+ expect(result[:claims]).to eq(claims)
160
+ end
161
+
162
+ it 'passes correct JWKS URL and issuer' do
163
+ expect(Legion::Crypt::JWT).to receive(:verify_with_jwks).with(
164
+ 'jwt-token',
165
+ jwks_url: 'https://login.microsoftonline.com/tenant-1/discovery/v2.0/keys',
166
+ issuers: ['https://login.microsoftonline.com/tenant-1/v2.0'],
167
+ audience: 'app-id-123'
168
+ )
169
+ client.validate_worker_identity(worker_id: 'worker-abc', token: 'jwt-token')
170
+ end
171
+ end
172
+
173
+ context 'when token is expired' do
174
+ before do
175
+ stub_data_model(build_model_double)
176
+ jwt_mod = Module.new do
177
+ def self.verify_with_jwks(*, **)
178
+ nil
179
+ end
180
+ end
181
+ stub_const('Legion::Crypt::JWT', jwt_mod)
182
+ stub_const('Legion::Crypt::JWT::Error', Class.new(StandardError))
183
+ stub_const('Legion::Crypt::JWT::ExpiredTokenError', Class.new(Legion::Crypt::JWT::Error))
184
+ allow(Legion::Crypt::JWT).to receive(:verify_with_jwks)
185
+ .and_raise(Legion::Crypt::JWT::ExpiredTokenError, 'token has expired')
186
+ allow(client).to receive(:resolve_tenant_id).and_return('tenant-1')
187
+ end
188
+
189
+ it 'returns valid false with token_expired error' do
190
+ result = client.validate_worker_identity(worker_id: 'worker-abc', token: 'expired-jwt')
191
+ expect(result[:valid]).to be false
192
+ expect(result[:error]).to eq('token_expired')
193
+ end
194
+ end
195
+
196
+ context 'when token signature is invalid' do
197
+ before do
198
+ stub_data_model(build_model_double)
199
+ jwt_mod = Module.new do
200
+ def self.verify_with_jwks(*, **)
201
+ nil
202
+ end
203
+ end
204
+ stub_const('Legion::Crypt::JWT', jwt_mod)
205
+ stub_const('Legion::Crypt::JWT::Error', Class.new(StandardError))
206
+ stub_const('Legion::Crypt::JWT::ExpiredTokenError', Class.new(Legion::Crypt::JWT::Error))
207
+ stub_const('Legion::Crypt::JWT::InvalidTokenError', Class.new(Legion::Crypt::JWT::Error))
208
+ allow(Legion::Crypt::JWT).to receive(:verify_with_jwks)
209
+ .and_raise(Legion::Crypt::JWT::InvalidTokenError, 'signature verification failed')
210
+ allow(client).to receive(:resolve_tenant_id).and_return('tenant-1')
211
+ end
212
+
213
+ it 'returns valid false with token_invalid error' do
214
+ result = client.validate_worker_identity(worker_id: 'worker-abc', token: 'bad-jwt')
215
+ expect(result[:valid]).to be false
216
+ expect(result[:error]).to eq('token_invalid')
217
+ end
218
+ end
219
+
220
+ context 'when no tenant_id configured' do
221
+ before do
222
+ stub_data_model(build_model_double)
223
+ jwt_mod = Module.new do
224
+ def self.verify_with_jwks(*, **)
225
+ nil
226
+ end
227
+ end
228
+ stub_const('Legion::Crypt::JWT', jwt_mod)
229
+ allow(client).to receive(:resolve_tenant_id).and_return(nil)
230
+ end
231
+
232
+ it 'returns valid false with no tenant_id error' do
233
+ result = client.validate_worker_identity(worker_id: 'worker-abc', token: 'jwt-token')
234
+ expect(result[:valid]).to be false
235
+ expect(result[:error]).to eq('no tenant_id configured')
236
+ end
237
+ end
238
+ end
239
+
240
+ # ---------------------------------------------------------------------------
241
+ # sync_owner
242
+ # ---------------------------------------------------------------------------
243
+
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)
248
+
249
+ result = client.sync_owner(worker_id: 'worker-abc')
250
+
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)
256
+ end
257
+ end
258
+
259
+ context 'when the worker does not exist' do
260
+ it 'returns synced: false with error' do
261
+ model_double = double('DigitalWorker model')
262
+ allow(model_double).to receive(:first).and_return(nil)
263
+ stub_data_model(model_double)
264
+
265
+ result = client.sync_owner(worker_id: 'no-such-worker')
266
+
267
+ expect(result[:synced]).to be false
268
+ expect(result[:error]).to eq('worker not found')
269
+ end
270
+ end
271
+ end
272
+
273
+ # ---------------------------------------------------------------------------
274
+ # transfer_ownership
275
+ # ---------------------------------------------------------------------------
276
+
277
+ describe '#transfer_ownership' do
278
+ context 'when the worker exists and the new owner differs' do
279
+ it 'returns transferred: true with full audit fields' do
280
+ model_double = build_model_double
281
+ stub_data_model(model_double)
282
+
283
+ result = client.transfer_ownership(
284
+ worker_id: 'worker-abc',
285
+ new_owner_msid: 'bob@example.com',
286
+ transferred_by: 'admin@example.com',
287
+ reason: 'role change'
288
+ )
289
+
290
+ expect(result[:transferred]).to be true
291
+ expect(result[:event]).to eq(:ownership_transferred)
292
+ expect(result[:from_owner]).to eq('alice@example.com')
293
+ expect(result[:to_owner]).to eq('bob@example.com')
294
+ expect(result[:transferred_by]).to eq('admin@example.com')
295
+ expect(result[:reason]).to eq('role change')
296
+ expect(result[:at]).to be_a(Time)
297
+ end
298
+
299
+ it 'emits a Legion::Events event when Legion::Events is available' do
300
+ model_double = build_model_double
301
+ stub_data_model(model_double)
302
+ events_double = double('Legion::Events')
303
+ stub_const('Legion::Events', events_double)
304
+ expect(events_double).to receive(:emit).with('worker.ownership_transferred', hash_including(event: :ownership_transferred))
305
+
306
+ client.transfer_ownership(
307
+ worker_id: 'worker-abc',
308
+ new_owner_msid: 'bob@example.com',
309
+ transferred_by: 'admin@example.com'
310
+ )
311
+ end
312
+
313
+ it 'updates the database record when Legion::Data is available' do
314
+ worker_double = instance_double('DigitalWorker')
315
+ allow(worker_double).to receive(:to_hash).and_return(worker_record)
316
+ expect(worker_double).to receive(:update).with(hash_including(owner_msid: 'bob@example.com'))
317
+
318
+ model_double = double('DigitalWorker model')
319
+ allow(model_double).to receive(:first).and_return(worker_double)
320
+ stub_data_model(model_double)
321
+
322
+ client.transfer_ownership(
323
+ worker_id: 'worker-abc',
324
+ new_owner_msid: 'bob@example.com',
325
+ transferred_by: 'admin@example.com'
326
+ )
327
+ end
328
+ end
329
+
330
+ context 'when new_owner_msid is the same as the current owner' do
331
+ it 'returns transferred: false with same owner error' do
332
+ stub_data_model(build_model_double)
333
+
334
+ result = client.transfer_ownership(
335
+ worker_id: 'worker-abc',
336
+ new_owner_msid: 'alice@example.com',
337
+ transferred_by: 'admin@example.com'
338
+ )
339
+
340
+ expect(result[:transferred]).to be false
341
+ expect(result[:error]).to eq('same owner')
342
+ end
343
+ end
344
+
345
+ context 'when the worker does not exist' do
346
+ it 'returns transferred: false with worker not found error' do
347
+ model_double = double('DigitalWorker model')
348
+ allow(model_double).to receive(:first).and_return(nil)
349
+ stub_data_model(model_double)
350
+
351
+ result = client.transfer_ownership(
352
+ worker_id: 'no-such-worker',
353
+ new_owner_msid: 'bob@example.com',
354
+ transferred_by: 'admin@example.com'
355
+ )
356
+
357
+ expect(result[:transferred]).to be false
358
+ expect(result[:error]).to eq('worker not found')
359
+ end
360
+ end
361
+ end
362
+
363
+ # ---------------------------------------------------------------------------
364
+ # check_orphans
365
+ # ---------------------------------------------------------------------------
366
+
367
+ describe '#check_orphans' do
368
+ context 'when Legion::Data is not available' do
369
+ it 'returns empty orphans with source: :unavailable' do
370
+ result = client.check_orphans
371
+
372
+ expect(result[:orphans]).to eq([])
373
+ expect(result[:checked]).to eq(0)
374
+ expect(result[:source]).to eq(:unavailable)
375
+ end
376
+ end
377
+
378
+ context 'when there are no active workers' do
379
+ it 'returns empty orphans list with checked count of 0' do
380
+ stub_data_model(build_model_double(active_all: []))
381
+
382
+ result = client.check_orphans
383
+
384
+ expect(result[:orphans]).to eq([])
385
+ expect(result[:checked]).to eq(0)
386
+ expect(result[:source]).to eq(:local)
387
+ expect(result[:checked_at]).to be_a(Time)
388
+ end
389
+ end
390
+
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
393
+ active_worker = double('DigitalWorker', worker_id: 'worker-abc', owner_msid: 'alice@example.com',
394
+ entra_app_id: 'entra-app-abc')
395
+ stub_data_model(build_model_double(active_all: [active_worker]))
396
+
397
+ result = client.check_orphans
398
+
399
+ expect(result[:checked]).to eq(1)
400
+ expect(result[:orphans]).to eq([])
401
+ expect(result[:source]).to eq(:local)
402
+ end
403
+ end
404
+ end
405
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/identity/client'
4
+
5
+ RSpec.describe Legion::Extensions::Identity::Runners::Identity do
6
+ let(:client) { Legion::Extensions::Identity::Client.new }
7
+
8
+ describe '#observe_behavior' do
9
+ it 'records a single observation' do
10
+ result = client.observe_behavior(dimension: :communication_cadence, value: 0.7)
11
+ expect(result[:recorded]).to be true
12
+ expect(result[:observation_count]).to eq(1)
13
+ end
14
+ end
15
+
16
+ describe '#observe_all' do
17
+ it 'records multiple observations' do
18
+ result = client.observe_all(observations: {
19
+ communication_cadence: 0.6,
20
+ vocabulary_patterns: 0.7,
21
+ emotional_response: 0.5
22
+ })
23
+ expect(result[:dimensions_observed].size).to eq(3)
24
+ expect(result[:observation_count]).to eq(3)
25
+ end
26
+ end
27
+
28
+ describe '#check_entropy' do
29
+ it 'returns entropy classification' do
30
+ result = client.check_entropy
31
+ expect(result[:entropy]).to be_between(0.0, 1.0)
32
+ expect(result).to have_key(:classification)
33
+ expect(result).to have_key(:trend)
34
+ expect(result).to have_key(:in_range)
35
+ end
36
+
37
+ it 'warns on high entropy observations' do
38
+ # Build baseline
39
+ 20.times { client.observe_behavior(dimension: :communication_cadence, value: 0.5) }
40
+ # Check with very different observation
41
+ result = client.check_entropy(observations: { communication_cadence: 10.0 })
42
+ expect(result[:warning]).to eq(:possible_impersonation_or_drift) if result[:classification] == :high_entropy
43
+ end
44
+ end
45
+
46
+ describe '#identity_status' do
47
+ it 'returns model state' do
48
+ status = client.identity_status
49
+ expect(status).to have_key(:model)
50
+ expect(status).to have_key(:maturity)
51
+ expect(status).to have_key(:observation_count)
52
+ end
53
+ end
54
+
55
+ describe '#identity_maturity' do
56
+ it 'returns maturity level' do
57
+ result = client.identity_maturity
58
+ expect(result[:maturity]).to eq(:nascent)
59
+ end
60
+ end
61
+ end