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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3681b4b6aab75c906d15647f1f359d19760d42994803b8c89d733480e77eacff
4
+ data.tar.gz: 4b49caa8730568c80e4e84a8d43f9053d83e6bef70a22730115a63492d7d5a06
5
+ SHA512:
6
+ metadata.gz: 527ef0472ad306da82d94ebea24a2b48c0375e164248b322aff1a721897d84f7518eac789852f17af88df14117ddfc9523dd65b42047a84d27af4fb907797d03
7
+ data.tar.gz: d7e69edd6068f138112c1a685423fc34fd44b8c5d83482c4c4ec8a1179b628955f2043a2e0d43a2b0dc0035b71129f8f56d5d6119dfc69ca64317087a7905849
data/Gemfile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gemspec
6
+
7
+ gem 'rspec', '~> 3.13'
8
+ gem 'rubocop', '~> 1.75', require: false
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/legion/extensions/identity/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'lex-identity'
7
+ spec.version = Legion::Extensions::Identity::VERSION
8
+ spec.authors = ['Esity']
9
+ spec.email = ['matthewdiverson@gmail.com']
10
+
11
+ spec.summary = 'LEX Identity'
12
+ spec.description = 'Human partner identity modeling and behavioral entropy for brain-modeled agentic AI'
13
+ spec.homepage = 'https://github.com/LegionIO/lex-identity'
14
+ spec.license = 'MIT'
15
+ spec.required_ruby_version = '>= 3.4'
16
+
17
+ spec.metadata['homepage_uri'] = spec.homepage
18
+ spec.metadata['source_code_uri'] = 'https://github.com/LegionIO/lex-identity'
19
+ spec.metadata['documentation_uri'] = 'https://github.com/LegionIO/lex-identity'
20
+ spec.metadata['changelog_uri'] = 'https://github.com/LegionIO/lex-identity'
21
+ spec.metadata['bug_tracker_uri'] = 'https://github.com/LegionIO/lex-identity/issues'
22
+ spec.metadata['rubygems_mfa_required'] = 'true'
23
+
24
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
25
+ Dir.glob('{lib,spec}/**/*') + %w[lex-identity.gemspec Gemfile]
26
+ end
27
+ spec.require_paths = ['lib']
28
+ spec.add_development_dependency 'sequel', '>= 5.70'
29
+ spec.add_development_dependency 'sqlite3', '>= 2.0'
30
+ end
@@ -0,0 +1,48 @@
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
+ # Periodic orphan detection: scans active workers for disabled Entra apps
10
+ # or inactive owners. Runs every 4 hours by default.
11
+ # Requires legion-data for worker records.
12
+ class OrphanCheck < Legion::Extensions::Actors::Every
13
+ ORPHAN_CHECK_INTERVAL = 14_400 # 4 hours in seconds
14
+
15
+ def runner_class
16
+ Legion::Extensions::Identity::Runners::Entra
17
+ end
18
+
19
+ def runner_function
20
+ 'check_orphans'
21
+ end
22
+
23
+ def time
24
+ ORPHAN_CHECK_INTERVAL
25
+ end
26
+
27
+ def enabled?
28
+ defined?(Legion::Data) && Legion::Settings[:data][:connected] != false
29
+ rescue StandardError
30
+ false
31
+ end
32
+
33
+ def use_runner?
34
+ false
35
+ end
36
+
37
+ def check_subtask?
38
+ false
39
+ end
40
+
41
+ def generate_task?
42
+ false
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/identity/helpers/dimensions'
4
+ require 'legion/extensions/identity/helpers/fingerprint'
5
+ require 'legion/extensions/identity/runners/identity'
6
+
7
+ module Legion
8
+ module Extensions
9
+ module Identity
10
+ class Client
11
+ include Runners::Identity
12
+
13
+ def initialize(**)
14
+ @identity_fingerprint = Helpers::Fingerprint.new
15
+ end
16
+
17
+ private
18
+
19
+ attr_reader :identity_fingerprint
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Identity
6
+ module Helpers
7
+ module Dimensions
8
+ # The 6 behavioral dimensions that constitute identity
9
+ IDENTITY_DIMENSIONS = %i[
10
+ communication_cadence
11
+ vocabulary_patterns
12
+ emotional_response
13
+ decision_patterns
14
+ contextual_consistency
15
+ temporal_patterns
16
+ ].freeze
17
+
18
+ # Entropy thresholds (from tick-loop-spec Phase 4)
19
+ HIGH_ENTROPY_THRESHOLD = 0.70
20
+ LOW_ENTROPY_THRESHOLD = 0.20
21
+ OPTIMAL_ENTROPY_RANGE = (0.20..0.70)
22
+
23
+ # EMA alpha for dimension updates
24
+ OBSERVATION_ALPHA = 0.1
25
+
26
+ module_function
27
+
28
+ def new_identity_model
29
+ IDENTITY_DIMENSIONS.to_h do |dim|
30
+ [dim, { mean: 0.5, variance: 0.1, observations: 0, last_observed: nil }]
31
+ end
32
+ end
33
+
34
+ def compute_entropy(observations, model)
35
+ return 0.5 if observations.empty?
36
+
37
+ divergences = IDENTITY_DIMENSIONS.filter_map do |dim|
38
+ obs = observations[dim]
39
+ next unless obs
40
+
41
+ baseline = model[dim]
42
+ next 0.0 unless baseline && baseline[:observations].positive?
43
+
44
+ # Weighted divergence from established baseline
45
+ (obs - baseline[:mean]).abs / [baseline[:variance].to_f, 0.1].max
46
+ end
47
+
48
+ return 0.5 if divergences.empty?
49
+
50
+ raw = divergences.sum / divergences.size
51
+ clamp(raw / 3.0) # normalize: divergence of 3.0 stddevs = entropy 1.0
52
+ end
53
+
54
+ def classify_entropy(entropy)
55
+ if entropy > HIGH_ENTROPY_THRESHOLD
56
+ :high_entropy
57
+ elsif entropy < LOW_ENTROPY_THRESHOLD
58
+ :low_entropy
59
+ else
60
+ :normal
61
+ end
62
+ end
63
+
64
+ def clamp(value, min = 0.0, max = 1.0)
65
+ value.clamp(min, max)
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'time'
5
+
6
+ module Legion
7
+ module Extensions
8
+ module Identity
9
+ module Helpers
10
+ class Fingerprint
11
+ attr_reader :model, :observation_count, :entropy_history
12
+
13
+ def initialize
14
+ @model = Dimensions.new_identity_model
15
+ @observation_count = 0
16
+ @entropy_history = []
17
+ load_from_local
18
+ end
19
+
20
+ def observe(dimension, value)
21
+ return unless Dimensions::IDENTITY_DIMENSIONS.include?(dimension)
22
+
23
+ dim = @model[dimension]
24
+ dim[:observations] += 1
25
+ @observation_count += 1
26
+
27
+ alpha = Dimensions::OBSERVATION_ALPHA
28
+ old_mean = dim[:mean]
29
+ dim[:mean] = (alpha * value) + ((1.0 - alpha) * old_mean)
30
+ deviation = (value - dim[:mean]).abs
31
+ dim[:variance] = (alpha * deviation) + ((1.0 - alpha) * dim[:variance])
32
+ dim[:last_observed] = Time.now.utc
33
+ end
34
+
35
+ def observe_all(observations)
36
+ observations.each { |dim, value| observe(dim, value) }
37
+ end
38
+
39
+ def current_entropy(observations = {})
40
+ entropy = Dimensions.compute_entropy(observations, @model)
41
+ @entropy_history << { entropy: entropy, at: Time.now.utc }
42
+ @entropy_history.shift while @entropy_history.size > 200
43
+ entropy
44
+ end
45
+
46
+ def entropy_trend(window: 10)
47
+ recent = @entropy_history.last(window)
48
+ return :stable if recent.size < 2
49
+
50
+ values = recent.map { |e| e[:entropy] }
51
+ first_half = values[0...(values.size / 2)]
52
+ second_half = values[(values.size / 2)..]
53
+
54
+ diff = (second_half.sum / second_half.size) - (first_half.sum / first_half.size)
55
+ if diff > 0.1
56
+ :rising
57
+ elsif diff < -0.1
58
+ :falling
59
+ else
60
+ :stable
61
+ end
62
+ end
63
+
64
+ def maturity
65
+ if @observation_count < 10
66
+ :nascent
67
+ elsif @observation_count < 100
68
+ :developing
69
+ elsif @observation_count < 1000
70
+ :established
71
+ else
72
+ :mature
73
+ end
74
+ end
75
+
76
+ def to_h
77
+ {
78
+ model: @model,
79
+ observation_count: @observation_count,
80
+ maturity: maturity,
81
+ entropy_history_size: @entropy_history.size
82
+ }
83
+ end
84
+
85
+ def save_to_local
86
+ return unless local_available?
87
+
88
+ db = Legion::Data::Local.connection
89
+
90
+ @model.each do |dimension, data|
91
+ existing = db[:identity_fingerprint].where(dimension: dimension.to_s).first
92
+ row = {
93
+ dimension: dimension.to_s,
94
+ mean: data[:mean],
95
+ variance: data[:variance],
96
+ observations: data[:observations],
97
+ last_observed: data[:last_observed]
98
+ }
99
+ if existing
100
+ db[:identity_fingerprint].where(dimension: dimension.to_s).update(row)
101
+ else
102
+ db[:identity_fingerprint].insert(row)
103
+ end
104
+ end
105
+
106
+ history_json = ::JSON.generate(@entropy_history.map { |e| { entropy: e[:entropy], at: e[:at].iso8601 } })
107
+ meta = db[:identity_meta].first
108
+ if meta
109
+ db[:identity_meta].where(id: meta[:id]).update(
110
+ observation_count: @observation_count,
111
+ entropy_history: history_json
112
+ )
113
+ else
114
+ db[:identity_meta].insert(
115
+ observation_count: @observation_count,
116
+ entropy_history: history_json
117
+ )
118
+ end
119
+
120
+ true
121
+ rescue StandardError => e
122
+ Legion::Logging.warn "lex-identity: save_to_local failed: #{e.message}" if defined?(Legion::Logging)
123
+ false
124
+ end
125
+
126
+ def load_from_local
127
+ return unless local_available?
128
+
129
+ db = Legion::Data::Local.connection
130
+
131
+ db[:identity_fingerprint].each do |row|
132
+ dim = row[:dimension].to_sym
133
+ next unless @model.key?(dim)
134
+
135
+ @model[dim][:mean] = row[:mean].to_f
136
+ @model[dim][:variance] = row[:variance].to_f
137
+ @model[dim][:observations] = row[:observations].to_i
138
+ @model[dim][:last_observed] = row[:last_observed]
139
+ end
140
+
141
+ meta = db[:identity_meta].first
142
+ if meta
143
+ @observation_count = meta[:observation_count].to_i
144
+ raw = meta[:entropy_history]
145
+ if raw && !raw.empty?
146
+ parsed = ::JSON.parse(raw)
147
+ @entropy_history = parsed.map { |e| { entropy: e['entropy'].to_f, at: Time.parse(e['at']) } }
148
+ end
149
+ end
150
+
151
+ true
152
+ rescue StandardError => e
153
+ Legion::Logging.warn "lex-identity: load_from_local failed: #{e.message}" if defined?(Legion::Logging)
154
+ false
155
+ end
156
+
157
+ private
158
+
159
+ def local_available?
160
+ defined?(Legion::Data::Local) && Legion::Data::Local.connected?
161
+ end
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Identity
6
+ module Helpers
7
+ # Vault secret path conventions for Digital Worker Entra ID credentials.
8
+ #
9
+ # Secrets are stored in Vault KV v2 under a well-known path:
10
+ # secret/data/legion/workers/{worker_id}/entra
11
+ #
12
+ # Legion uses legion-crypt for Vault access. If Vault is not connected,
13
+ # methods return nil/false gracefully.
14
+ module VaultSecrets
15
+ VAULT_PATH_PREFIX = 'secret/data/legion/workers'
16
+
17
+ def self.secret_path(worker_id)
18
+ "#{VAULT_PATH_PREFIX}/#{worker_id}/entra"
19
+ end
20
+
21
+ # Store Entra app client_secret in Vault.
22
+ # Returns true on success, false if Vault is unavailable.
23
+ def self.store_client_secret(worker_id:, client_secret:, entra_app_id: nil)
24
+ return false unless vault_available?
25
+
26
+ path = secret_path(worker_id)
27
+ data = { client_secret: client_secret }
28
+ data[:entra_app_id] = entra_app_id if entra_app_id
29
+
30
+ Legion::Crypt.write(path, data)
31
+ Legion::Logging.info "[identity:vault] stored Entra credentials for worker=#{worker_id}"
32
+ true
33
+ rescue StandardError => e
34
+ Legion::Logging.error "[identity:vault] failed to store credentials for worker=#{worker_id}: #{e.message}"
35
+ false
36
+ end
37
+
38
+ # Read Entra app client_secret from Vault.
39
+ # Returns the secret hash on success, nil if unavailable or not found.
40
+ def self.read_client_secret(worker_id:)
41
+ return nil unless vault_available?
42
+
43
+ path = secret_path(worker_id)
44
+ result = Legion::Crypt.read(path)
45
+ result&.dig(:data, :data) || result&.dig(:data)
46
+ rescue StandardError => e
47
+ Legion::Logging.error "[identity:vault] failed to read credentials for worker=#{worker_id}: #{e.message}"
48
+ nil
49
+ end
50
+
51
+ # Delete Entra app credentials from Vault (used during worker termination).
52
+ # Returns true on success, false if Vault is unavailable.
53
+ def self.delete_client_secret(worker_id:)
54
+ return false unless vault_available?
55
+
56
+ path = secret_path(worker_id)
57
+ Legion::Crypt.delete(path)
58
+ Legion::Logging.info "[identity:vault] deleted Entra credentials for worker=#{worker_id}"
59
+ true
60
+ rescue StandardError => e
61
+ Legion::Logging.error "[identity:vault] failed to delete credentials for worker=#{worker_id}: #{e.message}"
62
+ false
63
+ end
64
+
65
+ def self.vault_available?
66
+ defined?(Legion::Crypt) &&
67
+ defined?(Legion::Settings) &&
68
+ Legion::Settings[:crypt][:vault][:connected] == true
69
+ rescue StandardError
70
+ false
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ Sequel.migration do
4
+ change do
5
+ create_table(:identity_fingerprint) do
6
+ primary_key :id
7
+ String :dimension, null: false, unique: true
8
+ Float :mean, default: 0.0
9
+ Float :variance, default: 0.0
10
+ Integer :observations, default: 0
11
+ DateTime :last_observed
12
+ end
13
+
14
+ create_table(:identity_meta) do
15
+ primary_key :id
16
+ Integer :observation_count, default: 0
17
+ String :entropy_history, text: true
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,223 @@
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
+ def sync_owner(worker_id:, **)
81
+ worker = find_worker(worker_id)
82
+ return { synced: false, error: 'worker not found' } unless worker
83
+
84
+ # TODO: With Application.Read.All, call:
85
+ # GET #{GRAPH_API_BASE}/applications/#{worker[:entra_object_id]}/owners
86
+ # Parse owner MSID from response, update local record
87
+
88
+ Legion::Logging.debug "[identity:entra] sync_owner: worker=#{worker_id} current_owner=#{worker[:owner_msid]}"
89
+
90
+ {
91
+ synced: true,
92
+ worker_id: worker_id,
93
+ owner_msid: worker[:owner_msid],
94
+ source: :local, # will be :graph_api when read permission granted
95
+ synced_at: Time.now.utc
96
+ }
97
+ end
98
+
99
+ # Transfer ownership of a digital worker to a new human.
100
+ # Updates the Legion DB record and emits an audit event.
101
+ # The Entra app ownership change must be done by the human owner
102
+ # (requires Application.ReadWrite.All which Legion intentionally does not have).
103
+ def transfer_ownership(worker_id:, new_owner_msid:, transferred_by:, reason: nil, **)
104
+ worker = find_worker(worker_id)
105
+ return { transferred: false, error: 'worker not found' } unless worker
106
+
107
+ old_owner = worker[:owner_msid]
108
+ return { transferred: false, error: 'same owner' } if old_owner == new_owner_msid
109
+
110
+ # Update local record — this is the Legion side of the transfer
111
+ if defined?(Legion::Data) && defined?(Legion::Data::Model::DigitalWorker)
112
+ dw = Legion::Data::Model::DigitalWorker.first(worker_id: worker_id)
113
+ dw&.update(owner_msid: new_owner_msid, updated_at: Time.now.utc)
114
+ end
115
+
116
+ # Entra app ownership change requires Application.ReadWrite.All.
117
+ # Legion does not have this permission by design — the human owner
118
+ # must update Entra app ownership separately via Azure Portal or CLI.
119
+
120
+ audit = {
121
+ event: :ownership_transferred,
122
+ worker_id: worker_id,
123
+ from_owner: old_owner,
124
+ to_owner: new_owner_msid,
125
+ transferred_by: transferred_by,
126
+ reason: reason,
127
+ entra_action_required: 'update Entra app ownership via Azure Portal or az CLI',
128
+ at: Time.now.utc
129
+ }
130
+
131
+ Legion::Events.emit('worker.ownership_transferred', audit) if defined?(Legion::Events)
132
+ Legion::Logging.info "[identity:entra] ownership transferred (Legion DB): worker=#{worker_id} " \
133
+ "from=#{old_owner} to=#{new_owner_msid} by=#{transferred_by}"
134
+ Legion::Logging.warn '[identity:entra] Entra app ownership must be updated manually (requires Application.ReadWrite.All)'
135
+
136
+ { transferred: true }.merge(audit)
137
+ end
138
+
139
+ # Scan for orphaned workers: Entra apps that are disabled or owners no longer active.
140
+ # Requires: Application.Read.All or Directory.Read.All (read-only)
141
+ # Orphan REMEDIATION (disabling apps) requires human action since Legion
142
+ # does not have Application.ReadWrite.All.
143
+ def check_orphans(**)
144
+ return { orphans: [], checked: 0, source: :unavailable } unless defined?(Legion::Data) && defined?(Legion::Data::Model::DigitalWorker)
145
+
146
+ active_workers = Legion::Data::Model::DigitalWorker.where(lifecycle_state: 'active').all
147
+ orphans = []
148
+ skipped = 0
149
+
150
+ active_workers.each do |worker|
151
+ # Skip auto-registered extension workers without real Entra apps
152
+ if system_placeholder?(worker.entra_app_id, worker.worker_id)
153
+ skipped += 1
154
+ next
155
+ end
156
+
157
+ # TODO: With Application.Read.All, check:
158
+ # GET #{GRAPH_API_BASE}/applications/#{entra_object_id} — is app disabled?
159
+ # GET #{GRAPH_API_BASE}/users/#{owner_msid} — is owner active?
160
+ # If either is disabled/deleted:
161
+ # orphans << worker
162
+ # auto_pause_orphan(worker, reason: :entra_app_disabled)
163
+ end
164
+
165
+ Legion::Logging.debug "[identity:entra] orphan check: scanned #{active_workers.size} active workers, skipped #{skipped} system workers"
166
+
167
+ {
168
+ orphans: orphans.map { |w| { worker_id: w.worker_id, owner_msid: w.owner_msid, reason: :pending_entra_validation } },
169
+ checked: active_workers.size - skipped,
170
+ skipped: skipped,
171
+ source: :local,
172
+ checked_at: Time.now.utc
173
+ }
174
+ end
175
+
176
+ private
177
+
178
+ def find_worker(worker_id)
179
+ if defined?(Legion::Data) && defined?(Legion::Data::Model::DigitalWorker)
180
+ worker = Legion::Data::Model::DigitalWorker.first(worker_id: worker_id)
181
+ return worker.to_hash if worker
182
+ end
183
+ nil
184
+ end
185
+
186
+ def system_placeholder?(entra_app_id, worker_id)
187
+ return true if entra_app_id.nil? || entra_app_id == 'system'
188
+ return true if entra_app_id == worker_id
189
+ return true if entra_app_id.start_with?('lex-')
190
+
191
+ false
192
+ end
193
+
194
+ def resolve_tenant_id
195
+ if defined?(Legion::Settings) &&
196
+ Legion::Settings[:identity]&.dig(:entra, :tenant_id)
197
+ return Legion::Settings[:identity][:entra][:tenant_id]
198
+ end
199
+
200
+ nil
201
+ end
202
+
203
+ def auto_pause_orphan(worker, reason:)
204
+ worker.update(lifecycle_state: 'paused', updated_at: Time.now.utc)
205
+
206
+ if defined?(Legion::Events)
207
+ Legion::Events.emit('worker.orphan_detected', {
208
+ worker_id: worker.worker_id,
209
+ owner_msid: worker.owner_msid,
210
+ reason: reason,
211
+ action: :auto_paused,
212
+ remediation: 'disable or reassign Entra app via Azure Portal',
213
+ at: Time.now.utc
214
+ })
215
+ end
216
+
217
+ Legion::Logging.warn "[identity:entra] orphan detected: worker=#{worker.worker_id} reason=#{reason} — auto-paused"
218
+ end
219
+ end
220
+ end
221
+ end
222
+ end
223
+ end