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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +4 -6
  3. data/lex-identity.gemspec +12 -15
  4. data/lib/legion/extensions/identity/actors/credential_refresh.rb +45 -0
  5. data/lib/legion/extensions/identity/actors/orphan_check.rb +48 -0
  6. data/lib/legion/extensions/identity/client.rb +23 -0
  7. data/lib/legion/extensions/identity/helpers/dimensions.rb +71 -0
  8. data/lib/legion/extensions/identity/helpers/fingerprint.rb +166 -0
  9. data/lib/legion/extensions/identity/helpers/graph_client.rb +25 -0
  10. data/lib/legion/extensions/identity/helpers/graph_token.rb +32 -0
  11. data/lib/legion/extensions/identity/helpers/token_cache.rb +55 -0
  12. data/lib/legion/extensions/identity/helpers/vault_secrets.rb +76 -0
  13. data/lib/legion/extensions/identity/local_migrations/20260316000030_create_fingerprint.rb +20 -0
  14. data/lib/legion/extensions/identity/runners/entra.rb +398 -0
  15. data/lib/legion/extensions/identity/runners/identity.rb +86 -0
  16. data/lib/legion/extensions/identity/version.rb +1 -1
  17. data/lib/legion/extensions/identity.rb +14 -8
  18. data/spec/legion/extensions/identity/actors/orphan_check_spec.rb +104 -0
  19. data/spec/legion/extensions/identity/client_spec.rb +32 -0
  20. data/spec/legion/extensions/identity/helpers/dimensions_spec.rb +51 -0
  21. data/spec/legion/extensions/identity/helpers/fingerprint_spec.rb +66 -0
  22. data/spec/legion/extensions/identity/helpers/graph_client_spec.rb +19 -0
  23. data/spec/legion/extensions/identity/helpers/graph_token_spec.rb +31 -0
  24. data/spec/legion/extensions/identity/helpers/token_cache_spec.rb +50 -0
  25. data/spec/legion/extensions/identity/runners/entra_spec.rb +655 -0
  26. data/spec/legion/extensions/identity/runners/identity_spec.rb +61 -0
  27. data/spec/local_persistence_spec.rb +329 -0
  28. data/spec/spec_helper.rb +33 -0
  29. metadata +40 -67
  30. data/CHANGELOG.md +0 -27
  31. data/LICENSE +0 -21
  32. data/README.md +0 -230
  33. data/lib/legion/extensions/identity/helpers/browser.rb +0 -44
  34. data/lib/legion/extensions/identity/helpers/callback_server.rb +0 -170
  35. data/lib/legion/extensions/identity/helpers/oauth.rb +0 -62
  36. data/lib/legion/extensions/identity/helpers/pkce.rb +0 -20
  37. data/lib/legion/extensions/identity/helpers/provider.rb +0 -52
  38. data/lib/legion/extensions/identity/runners/base/oauth.rb +0 -140
  39. data/spec/support/provider_contract.rb +0 -49
@@ -0,0 +1,398 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Identity
6
+ module Runners
7
+ # Entra ID Application identity integration for Digital Workers.
8
+ #
9
+ # Permission model:
10
+ # - Entra app CREATION is done by the human owner (requires Application.ReadWrite.All
11
+ # which Legion does not have and should not have)
12
+ # - Legion gets Application.Read.All or Directory.Read.All for read operations
13
+ # - OIDC token validation uses the public JWKS endpoint (no special permission)
14
+ # - Write operations (transfer ownership, disable apps) update the Legion DB
15
+ # and emit events; the human completes the Entra side manually
16
+ module Entra
17
+ GRAPH_API_BASE = 'https://graph.microsoft.com/v1.0'
18
+ ENTRA_JWKS_URL_TEMPLATE = 'https://login.microsoftonline.com/%<tenant_id>s/discovery/v2.0/keys'
19
+ ENTRA_ISSUER_TEMPLATE = 'https://login.microsoftonline.com/%<tenant_id>s/v2.0'
20
+
21
+ # Validate a worker's identity by checking its Entra app registration exists
22
+ # and its OIDC token is valid.
23
+ # OIDC validation uses the public JWKS endpoint — no Graph API permission needed.
24
+ def validate_worker_identity(worker_id:, entra_app_id: nil, token: nil, tenant_id: nil, **)
25
+ worker = find_worker(worker_id)
26
+ return { valid: false, error: 'worker not found' } unless worker
27
+
28
+ app_id = entra_app_id || worker[:entra_app_id]
29
+ return { valid: false, error: 'no entra_app_id' } unless app_id
30
+
31
+ # If a token is provided and legion-crypt has JWKS support, validate it
32
+ if token && defined?(Legion::Crypt::JWT) && Legion::Crypt::JWT.respond_to?(:verify_with_jwks)
33
+ tid = tenant_id || resolve_tenant_id
34
+ return { valid: false, error: 'no tenant_id configured' } unless tid
35
+
36
+ jwks_url = format(ENTRA_JWKS_URL_TEMPLATE, tenant_id: tid)
37
+ issuer = format(ENTRA_ISSUER_TEMPLATE, tenant_id: tid)
38
+
39
+ claims = Legion::Crypt::JWT.verify_with_jwks(
40
+ token,
41
+ jwks_url: jwks_url,
42
+ issuers: [issuer],
43
+ audience: app_id
44
+ )
45
+
46
+ Legion::Logging.debug "[identity:entra] token validated: worker=#{worker_id} sub=#{claims[:sub]}"
47
+
48
+ return {
49
+ valid: true,
50
+ worker_id: worker_id,
51
+ entra_app_id: app_id,
52
+ owner_msid: worker[:owner_msid],
53
+ lifecycle: worker[:lifecycle_state],
54
+ claims: claims,
55
+ validated_at: Time.now.utc
56
+ }
57
+ end
58
+
59
+ # No token provided — return identity info without token validation
60
+ Legion::Logging.debug "[identity:entra] validate (no token): worker=#{worker_id} entra_app=#{app_id}"
61
+
62
+ {
63
+ valid: true,
64
+ worker_id: worker_id,
65
+ entra_app_id: app_id,
66
+ owner_msid: worker[:owner_msid],
67
+ lifecycle: worker[:lifecycle_state],
68
+ validated_at: Time.now.utc
69
+ }
70
+ rescue Legion::Crypt::JWT::ExpiredTokenError => e
71
+ { valid: false, error: 'token_expired', message: e.message }
72
+ rescue Legion::Crypt::JWT::InvalidTokenError => e
73
+ { valid: false, error: 'token_invalid', message: e.message }
74
+ rescue Legion::Crypt::JWT::Error => e
75
+ { valid: false, error: 'token_error', message: e.message }
76
+ end
77
+
78
+ # Sync the worker's owner from Entra app ownership.
79
+ # Requires: Application.Read.All or Directory.Read.All (read-only)
80
+ # Falls back to local record when Graph API credentials unavailable.
81
+ def sync_owner(worker_id:, **)
82
+ worker = find_worker(worker_id)
83
+ return { synced: false, error: 'worker not found' } unless worker
84
+
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
+
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
94
+
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 }
121
+ end
122
+
123
+ # Transfer ownership of a digital worker to a new human.
124
+ # Updates the Legion DB record and emits an audit event.
125
+ # The Entra app ownership change must be done by the human owner
126
+ # (requires Application.ReadWrite.All which Legion intentionally does not have).
127
+ def transfer_ownership(worker_id:, new_owner_msid:, transferred_by:, reason: nil, **)
128
+ worker = find_worker(worker_id)
129
+ return { transferred: false, error: 'worker not found' } unless worker
130
+
131
+ old_owner = worker[:owner_msid]
132
+ return { transferred: false, error: 'same owner' } if old_owner == new_owner_msid
133
+
134
+ # Update local record — this is the Legion side of the transfer
135
+ if defined?(Legion::Data) && defined?(Legion::Data::Model::DigitalWorker)
136
+ dw = Legion::Data::Model::DigitalWorker.first(worker_id: worker_id)
137
+ dw&.update(owner_msid: new_owner_msid, updated_at: Time.now.utc)
138
+ end
139
+
140
+ # Entra app ownership change requires Application.ReadWrite.All.
141
+ # Legion does not have this permission by design — the human owner
142
+ # must update Entra app ownership separately via Azure Portal or CLI.
143
+
144
+ audit = {
145
+ event: :ownership_transferred,
146
+ worker_id: worker_id,
147
+ from_owner: old_owner,
148
+ to_owner: new_owner_msid,
149
+ transferred_by: transferred_by,
150
+ reason: reason,
151
+ entra_action_required: 'update Entra app ownership via Azure Portal or az CLI',
152
+ at: Time.now.utc
153
+ }
154
+
155
+ Legion::Events.emit('worker.ownership_transferred', audit) if defined?(Legion::Events)
156
+ Legion::Logging.info "[identity:entra] ownership transferred (Legion DB): worker=#{worker_id} " \
157
+ "from=#{old_owner} to=#{new_owner_msid} by=#{transferred_by}"
158
+ Legion::Logging.warn '[identity:entra] Entra app ownership must be updated manually (requires Application.ReadWrite.All)'
159
+
160
+ { transferred: true }.merge(audit)
161
+ end
162
+
163
+ # Scan for orphaned workers: Entra apps that are disabled or owners no longer active.
164
+ # Requires: Application.Read.All or Directory.Read.All (read-only)
165
+ # Orphan REMEDIATION (disabling apps) requires human action since Legion
166
+ # does not have Application.ReadWrite.All.
167
+ # Falls back to local-only scan when Graph API credentials unavailable.
168
+ def check_orphans(**)
169
+ return { orphans: [], checked: 0, source: :unavailable } unless defined?(Legion::Data) && defined?(Legion::Data::Model::DigitalWorker)
170
+
171
+ active_workers = Legion::Data::Model::DigitalWorker.where(lifecycle_state: 'active').all
172
+ orphans = []
173
+ skipped = 0
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
+
182
+ active_workers.each do |worker|
183
+ if system_placeholder?(worker.entra_app_id, worker.worker_id)
184
+ skipped += 1
185
+ next
186
+ end
187
+
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}"
197
+ end
198
+
199
+ source = conn ? :graph_api : :local
200
+ Legion::Logging.debug "[identity:entra] orphan check (#{source}): scanned #{active_workers.size}, skipped #{skipped}"
201
+
202
+ {
203
+ orphans: orphans.map { |w| { worker_id: w.worker_id, owner_msid: w.owner_msid, reason: :entra_orphan } },
204
+ checked: active_workers.size - skipped,
205
+ skipped: skipped,
206
+ source: source,
207
+ checked_at: Time.now.utc
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 }
320
+ end
321
+
322
+ private
323
+
324
+ def find_worker(worker_id)
325
+ if defined?(Legion::Data) && defined?(Legion::Data::Model::DigitalWorker)
326
+ worker = Legion::Data::Model::DigitalWorker.first(worker_id: worker_id)
327
+ return worker.to_hash if worker
328
+ end
329
+ nil
330
+ end
331
+
332
+ def system_placeholder?(entra_app_id, worker_id)
333
+ return true if entra_app_id.nil? || entra_app_id == 'system'
334
+ return true if entra_app_id == worker_id
335
+ return true if entra_app_id.start_with?('lex-')
336
+
337
+ false
338
+ end
339
+
340
+ def resolve_tenant_id
341
+ if defined?(Legion::Settings) &&
342
+ Legion::Settings[:identity]&.dig(:entra, :tenant_id)
343
+ return Legion::Settings[:identity][:entra][:tenant_id]
344
+ end
345
+
346
+ nil
347
+ end
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
+
378
+ def auto_pause_orphan(worker, reason:)
379
+ worker.update(lifecycle_state: 'paused', updated_at: Time.now.utc)
380
+
381
+ if defined?(Legion::Events)
382
+ Legion::Events.emit('worker.orphan_detected', {
383
+ worker_id: worker.worker_id,
384
+ owner_msid: worker.owner_msid,
385
+ reason: reason,
386
+ action: :auto_paused,
387
+ remediation: 'disable or reassign Entra app via Azure Portal',
388
+ at: Time.now.utc
389
+ })
390
+ end
391
+
392
+ Legion::Logging.warn "[identity:entra] orphan detected: worker=#{worker.worker_id} reason=#{reason} — auto-paused"
393
+ end
394
+ end
395
+ end
396
+ end
397
+ end
398
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Identity
6
+ module Runners
7
+ module Identity
8
+ include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
9
+ Legion::Extensions::Helpers.const_defined?(:Lex)
10
+
11
+ def observe_behavior(dimension:, value:, **)
12
+ fingerprint = identity_fingerprint
13
+ fingerprint.observe(dimension, value)
14
+
15
+ Legion::Logging.debug "[identity] observe: dim=#{dimension} val=#{value.round(2)} " \
16
+ "obs=#{fingerprint.observation_count} maturity=#{fingerprint.maturity}"
17
+ {
18
+ dimension: dimension,
19
+ recorded: true,
20
+ observation_count: fingerprint.observation_count,
21
+ maturity: fingerprint.maturity
22
+ }
23
+ end
24
+
25
+ def observe_all(observations:, **)
26
+ fingerprint = identity_fingerprint
27
+ fingerprint.observe_all(observations)
28
+
29
+ Legion::Logging.debug "[identity] observe_all: dims=#{observations.keys.join(',')} " \
30
+ "obs=#{fingerprint.observation_count} maturity=#{fingerprint.maturity}"
31
+ {
32
+ dimensions_observed: observations.keys,
33
+ observation_count: fingerprint.observation_count,
34
+ maturity: fingerprint.maturity
35
+ }
36
+ end
37
+
38
+ def check_entropy(observations: {}, **)
39
+ fingerprint = identity_fingerprint
40
+ entropy = fingerprint.current_entropy(observations)
41
+ classification = Helpers::Dimensions.classify_entropy(entropy)
42
+ trend = fingerprint.entropy_trend
43
+
44
+ result = {
45
+ entropy: entropy,
46
+ classification: classification,
47
+ trend: trend,
48
+ in_range: Helpers::Dimensions::OPTIMAL_ENTROPY_RANGE.cover?(entropy)
49
+ }
50
+
51
+ case classification
52
+ when :high_entropy
53
+ result[:warning] = :possible_impersonation_or_drift
54
+ result[:action] = :enter_caution_mode
55
+ Legion::Logging.warn "[identity] high entropy detected: #{entropy.round(3)} trend=#{trend} - possible impersonation"
56
+ when :low_entropy
57
+ result[:warning] = :possible_automation
58
+ result[:action] = :trigger_verification
59
+ Legion::Logging.warn "[identity] low entropy detected: #{entropy.round(3)} trend=#{trend} - possible automation"
60
+ else
61
+ Legion::Logging.debug "[identity] entropy check: #{entropy.round(3)} classification=#{classification} trend=#{trend}"
62
+ end
63
+
64
+ result
65
+ end
66
+
67
+ def identity_status(**)
68
+ fingerprint = identity_fingerprint
69
+ Legion::Logging.debug "[identity] status: maturity=#{fingerprint.maturity} observations=#{fingerprint.observation_count}"
70
+ fingerprint.to_h
71
+ end
72
+
73
+ def identity_maturity(**)
74
+ { maturity: identity_fingerprint.maturity }
75
+ end
76
+
77
+ private
78
+
79
+ def identity_fingerprint
80
+ @identity_fingerprint ||= Helpers::Fingerprint.new
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -3,7 +3,7 @@
3
3
  module Legion
4
4
  module Extensions
5
5
  module Identity
6
- VERSION = '0.3.0'
6
+ VERSION = '0.4.0'
7
7
  end
8
8
  end
9
9
  end
@@ -1,18 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'legion/logging'
4
3
  require 'legion/extensions/identity/version'
5
- require 'legion/extensions/identity/helpers/provider'
4
+ require 'legion/extensions/identity/helpers/dimensions'
5
+ require 'legion/extensions/identity/helpers/fingerprint'
6
+ require 'legion/extensions/identity/helpers/vault_secrets'
7
+ require 'legion/extensions/identity/runners/identity'
8
+ require 'legion/extensions/identity/runners/entra'
9
+ require 'legion/extensions/identity/actors/orphan_check'
6
10
 
7
11
  module Legion
8
12
  module Extensions
9
13
  module Identity
10
- extend Legion::Extensions::Core if Legion::Extensions.const_defined?(:Core, false)
11
-
12
- def self.identity_provider? = false
13
- def self.remote_invocable? = false
14
- def self.transport_required? = false
15
- def self.crypt_required? = false
14
+ extend Legion::Extensions::Core if Legion::Extensions.const_defined? :Core
16
15
  end
17
16
  end
18
17
  end
18
+
19
+ if defined?(Legion::Data::Local)
20
+ Legion::Data::Local.register_migrations(
21
+ name: :identity,
22
+ path: File.join(__dir__, 'identity', 'local_migrations')
23
+ )
24
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Stub the framework actor base class since legionio gem is not available in test
4
+ module Legion
5
+ module Extensions
6
+ module Actors
7
+ class Every # rubocop:disable Lint/EmptyClass
8
+ end
9
+ end
10
+ end
11
+ end
12
+
13
+ # Intercept the require in the actor file so it doesn't fail
14
+ $LOADED_FEATURES << 'legion/extensions/actors/every'
15
+
16
+ require 'legion/extensions/identity/actors/orphan_check'
17
+
18
+ RSpec.describe Legion::Extensions::Identity::Actor::OrphanCheck do
19
+ subject(:actor) { described_class.new }
20
+
21
+ describe 'ORPHAN_CHECK_INTERVAL' do
22
+ it 'is 14400 seconds (4 hours)' do
23
+ expect(described_class::ORPHAN_CHECK_INTERVAL).to eq(14_400)
24
+ end
25
+ end
26
+
27
+ describe '#runner_class' do
28
+ it 'returns the Entra module' do
29
+ expect(actor.runner_class).to eq(Legion::Extensions::Identity::Runners::Entra)
30
+ end
31
+ end
32
+
33
+ describe '#runner_function' do
34
+ it 'returns check_orphans' do
35
+ expect(actor.runner_function).to eq('check_orphans')
36
+ end
37
+ end
38
+
39
+ describe '#time' do
40
+ it 'returns 14400 seconds using the constant' do
41
+ expect(actor.time).to eq(Legion::Extensions::Identity::Actor::OrphanCheck::ORPHAN_CHECK_INTERVAL)
42
+ end
43
+
44
+ it 'returns 14400' do
45
+ expect(actor.time).to eq(14_400)
46
+ end
47
+ end
48
+
49
+ describe '#use_runner?' do
50
+ it 'returns false' do
51
+ expect(actor.use_runner?).to be false
52
+ end
53
+ end
54
+
55
+ describe '#check_subtask?' do
56
+ it 'returns false' do
57
+ expect(actor.check_subtask?).to be false
58
+ end
59
+ end
60
+
61
+ describe '#generate_task?' do
62
+ it 'returns false' do
63
+ expect(actor.generate_task?).to be false
64
+ end
65
+ end
66
+
67
+ describe '#enabled?' do
68
+ context 'when Legion::Data is not defined' do
69
+ it 'returns false' do
70
+ hide_const('Legion::Data') if defined?(Legion::Data)
71
+ expect(actor.enabled?).to be_falsey
72
+ end
73
+ end
74
+
75
+ context 'when Legion::Data is defined and data is connected' do
76
+ it 'returns truthy' do
77
+ stub_const('Legion::Data', Module.new)
78
+ stub_const('Legion::Settings', Class.new)
79
+ settings_double = { connected: true }
80
+ allow(Legion::Settings).to receive(:[]).with(:data).and_return(settings_double)
81
+ expect(actor.enabled?).to be_truthy
82
+ end
83
+ end
84
+
85
+ context 'when Legion::Data is defined but connected is false' do
86
+ it 'returns false' do
87
+ stub_const('Legion::Data', Module.new)
88
+ stub_const('Legion::Settings', Class.new)
89
+ settings_double = { connected: false }
90
+ allow(Legion::Settings).to receive(:[]).with(:data).and_return(settings_double)
91
+ expect(actor.enabled?).to be false
92
+ end
93
+ end
94
+
95
+ context 'when Legion::Settings raises an error' do
96
+ it 'returns false' do
97
+ stub_const('Legion::Data', Module.new)
98
+ stub_const('Legion::Settings', Class.new)
99
+ allow(Legion::Settings).to receive(:[]).with(:data).and_raise(StandardError)
100
+ expect(actor.enabled?).to be false
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/identity/client'
4
+
5
+ RSpec.describe Legion::Extensions::Identity::Client do
6
+ let(:client) { described_class.new }
7
+
8
+ it 'responds to identity runner methods' do
9
+ expect(client).to respond_to(:observe_behavior)
10
+ expect(client).to respond_to(:observe_all)
11
+ expect(client).to respond_to(:check_entropy)
12
+ expect(client).to respond_to(:identity_status)
13
+ expect(client).to respond_to(:identity_maturity)
14
+ end
15
+
16
+ it 'round-trips identity lifecycle' do
17
+ # Build identity
18
+ 50.times do
19
+ client.observe_all(observations: {
20
+ communication_cadence: 0.5 + (rand * 0.1),
21
+ vocabulary_patterns: 0.6 + (rand * 0.1),
22
+ emotional_response: 0.4 + (rand * 0.1)
23
+ })
24
+ end
25
+
26
+ expect(client.identity_maturity[:maturity]).to eq(:established)
27
+
28
+ # Check entropy with consistent behavior
29
+ result = client.check_entropy(observations: { communication_cadence: 0.55 })
30
+ expect(result[:entropy]).to be_between(0.0, 1.0)
31
+ end
32
+ end