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,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
|
|
@@ -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/
|
|
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?
|
|
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
|