Module: Legion::Extensions::Agentic::Self::Identity::Runners::Entra
- Defined in:
- lib/legion/extensions/agentic/self/identity/runners/entra.rb
Overview
Entra ID Application identity integration for Digital Workers.
Permission model:
- Entra app CREATION is done by the human owner (requires Application.ReadWrite.All
which Legion does not have and should not have)
- Legion gets Application.Read.All or Directory.Read.All for read operations
- OIDC token validation uses the public JWKS endpoint (no special permission)
- Write operations (transfer ownership, disable apps) update the Legion DB
and emit events; the human completes the Entra side manually
Constant Summary collapse
- GRAPH_API_BASE =
rubocop:disable Legion/Extension/RunnerIncludeHelpers
'https://graph.microsoft.com/v1.0'- ENTRA_JWKS_URL_TEMPLATE =
'https://login.microsoftonline.com/%<tenant_id>s/discovery/v2.0/keys'- ENTRA_ISSUER_TEMPLATE =
'https://login.microsoftonline.com/%<tenant_id>s/v2.0'
Instance Method Summary collapse
-
#check_orphans ⇒ Object
Scan for orphaned workers: Entra apps that are disabled or owners no longer active.
- #credential_refresh_cycle ⇒ Object
- #refresh_access_token(worker_id:, force: false) ⇒ Object
-
#resolve_governance_roles(groups:) ⇒ Object
Map Entra security group OIDs to Legion governance roles.
- #rotate_client_secret(worker_id:, dry_run: false) ⇒ Object
-
#sync_owner(worker_id:) ⇒ Object
Sync the worker’s owner from Entra app ownership.
-
#transfer_ownership(worker_id:, new_owner_msid:, transferred_by:, reason: nil) ⇒ Object
Transfer ownership of a digital worker to a new human.
-
#validate_worker_identity(worker_id:, entra_app_id: nil, token: nil, tenant_id: nil) ⇒ Object
Validate a worker’s identity by checking its Entra app registration exists and its OIDC token is valid.
Instance Method Details
#check_orphans ⇒ Object
Scan for orphaned workers: Entra apps that are disabled or owners no longer active. Requires: Application.Read.All or Directory.Read.All (read-only) Orphan REMEDIATION (disabling apps) requires human action since Legion does not have Application.ReadWrite.All. Falls back to local-only scan when Graph API credentials unavailable.
170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 |
# File 'lib/legion/extensions/agentic/self/identity/runners/entra.rb', line 170 def check_orphans(**) return { orphans: [], checked: 0, source: :unavailable } unless defined?(Legion::Data) && defined?(Legion::Data::Model::DigitalWorker) active_workers = Legion::Data::Model::DigitalWorker.where(lifecycle_state: 'active').all orphans = [] skipped = 0 creds = resolve_graph_credentials conn = nil if creds token = Helpers::GraphToken.fetch(**creds) conn = Helpers::GraphClient.connection(token: token) end active_workers.each do |worker| if system_placeholder?(worker.entra_app_id, worker.worker_id) skipped += 1 next end next unless conn orphan_reason = check_worker_orphan_status(conn, worker) if orphan_reason orphans << worker auto_pause_orphan(worker, reason: orphan_reason) end rescue Faraday::Error => e Legion::Logging.warn "[identity:entra] graph error scanning #{worker.worker_id}: #{e.}" end source = conn ? :graph_api : :local Legion::Logging.debug "[identity:entra] orphan check (#{source}): scanned #{active_workers.size}, skipped #{skipped}" { orphans: orphans.map { |w| { worker_id: w.worker_id, owner_msid: w.owner_msid, reason: :entra_orphan } }, checked: active_workers.size - skipped, skipped: skipped, source: source, checked_at: Time.now.utc } rescue Helpers::GraphToken::GraphTokenError => e Legion::Logging.warn "[identity:entra] orphan check token error: #{e.}" { orphans: [], checked: 0, source: :local, error: e., checked_at: Time.now.utc } end |
#credential_refresh_cycle ⇒ Object
303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 |
# File 'lib/legion/extensions/agentic/self/identity/runners/entra.rb', line 303 def credential_refresh_cycle(**) return { workers_checked: 0, error: 'data_unavailable' } unless defined?(Legion::Data::Model::DigitalWorker) workers = Legion::Data::Model::DigitalWorker.where(lifecycle_state: 'active').all results = { workers_checked: 0, refreshed: 0, warned: 0 } workers.each do |worker| next if system_placeholder?(worker.entra_app_id, worker.worker_id) results[:workers_checked] += 1 token_result = refresh_access_token(worker_id: worker.worker_id) results[:refreshed] += 1 if token_result[:refreshed] rotation_result = rotate_client_secret(worker_id: worker.worker_id) results[:warned] += 1 if rotation_result[:action_required] end results rescue StandardError => e Legion::Logging.warn "[identity] credential refresh cycle error: #{e.}" { workers_checked: 0, error: e. } end |
#refresh_access_token(worker_id:, force: false) ⇒ Object
225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 |
# File 'lib/legion/extensions/agentic/self/identity/runners/entra.rb', line 225 def refresh_access_token(worker_id:, force: false, **) require_relative '../helpers/token_cache' unless force cached = Helpers::TokenCache.fetch(worker_id: worker_id) if cached && !Helpers::TokenCache.approaching_expiry?(worker_id: worker_id) return { refreshed: false, worker_id: worker_id, source: :cache, expires_at: cached[:expires_at] } end end secret = Helpers::VaultSecrets.read_client_secret(worker_id: worker_id) return { refreshed: false, worker_id: worker_id, error: 'vault_unavailable' } unless secret tenant_id = resolve_tenant_id return { refreshed: false, worker_id: worker_id, error: 'no_tenant_id' } unless tenant_id scope = Legion::Settings.dig(:identity, :entra, :token_scope) || 'https://graph.microsoft.com/.default' url = "https://login.microsoftonline.com/#{tenant_id}/oauth2/v2.0/token" require 'faraday' resp = Faraday.post(url, { grant_type: 'client_credentials', client_id: secret[:client_id] || secret[:entra_app_id], client_secret: secret[:client_secret], scope: scope }) unless resp.success? Legion::Logging.warn "[identity] token refresh failed for #{worker_id}: #{resp.status}" return { refreshed: false, worker_id: worker_id, error: 'token_request_failed' } end body = Legion::JSON.load(resp.body) expires_in = body[:expires_in]&.to_i || 3600 Helpers::TokenCache.store(worker_id: worker_id, token: body[:access_token], expires_in: expires_in) { refreshed: true, worker_id: worker_id, expires_at: Time.now + expires_in } rescue StandardError => e Legion::Logging.warn "[identity] token refresh error: #{e.}" { refreshed: false, worker_id: worker_id, error: e. } end |
#resolve_governance_roles(groups:) ⇒ Object
Map Entra security group OIDs to Legion governance roles
217 218 219 220 221 222 223 |
# File 'lib/legion/extensions/agentic/self/identity/runners/entra.rb', line 217 def resolve_governance_roles(groups:, **) group_map = Legion::Settings.dig(:rbac, :entra, :group_map) || {} default_role = Legion::Settings.dig(:rbac, :entra, :default_role) || 'governance-observer' matched = Array(groups).filter_map { |oid| group_map[oid] }.uniq matched = [default_role] if matched.empty? { success: true, groups: groups, roles: matched } end |
#rotate_client_secret(worker_id:, dry_run: false) ⇒ Object
267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 |
# File 'lib/legion/extensions/agentic/self/identity/runners/entra.rb', line 267 def rotate_client_secret(worker_id:, dry_run: false, **) rotation_enabled = Legion::Settings.dig(:identity, :entra, :rotation_enabled) buffer_days = Legion::Settings.dig(:identity, :entra, :rotation_buffer_days) || 30 secret = Helpers::VaultSecrets.read_client_secret(worker_id: worker_id) return { rotated: false, worker_id: worker_id, error: 'vault_unavailable' } unless secret expires_at = secret[:client_secret_expires_at] return { rotated: false, worker_id: worker_id, action_required: false, reason: 'no_expiry_tracked' } unless expires_at days_remaining = (Time.parse(expires_at.to_s) - Time.now) / 86_400 unless days_remaining < buffer_days return { rotated: false, worker_id: worker_id, action_required: false, days_remaining: days_remaining.round(1) } end unless rotation_enabled Legion::Logging.warn "[identity] credential expiring for #{worker_id} in #{days_remaining.round(1)} days" if defined?(Legion::Events) Legion::Events.emit('worker.credential_expiry_warning', { worker_id: worker_id, days_remaining: days_remaining.round(1) }) end return { rotated: false, worker_id: worker_id, action_required: true, days_remaining: days_remaining.round(1) } end return { rotated: false, worker_id: worker_id, dry_run: true, would_rotate: true } if dry_run # Graph API rotation is blocked on Azure Application.ReadWrite.All permission. # Emit a loud warning so callers are never silently misled. Legion::Logging.warn "[identity:entra] Client secret rotation is enabled but Graph API rotation is not yet implemented. Worker: #{worker_id}" { rotated: false, worker_id: worker_id, error: 'graph_api_rotation_not_implemented', action_required: 'Manual rotation needed — Graph API write permission not yet granted' } end |
#sync_owner(worker_id:) ⇒ Object
Sync the worker’s owner from Entra app ownership. Requires: Application.Read.All or Directory.Read.All (read-only) Falls back to local record when Graph API credentials unavailable.
83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 |
# File 'lib/legion/extensions/agentic/self/identity/runners/entra.rb', line 83 def sync_owner(worker_id:, **) worker = find_worker(worker_id) return { synced: false, error: 'worker not found' } unless worker entra_object_id = worker[:entra_object_id] return { synced: false, worker_id: worker_id, error: 'no entra_object_id', source: :local } unless entra_object_id creds = resolve_graph_credentials unless creds Legion::Logging.debug "[identity:entra] sync_owner fallback to local: worker=#{worker_id}" return { synced: true, worker_id: worker_id, source: :local, owner_msid: worker[:owner_msid], synced_at: Time.now.utc } end token = Helpers::GraphToken.fetch(**creds) conn = Helpers::GraphClient.connection(token: token) resp = conn.get("applications/#{entra_object_id}/owners") unless resp.success? Legion::Logging.warn "[identity:entra] graph owner sync failed: #{resp.status}" return { synced: false, worker_id: worker_id, source: :local, owner_msid: worker[:owner_msid] } end owners = resp.body['value'] || [] graph_owner_msid = owners.first&.dig('id') changed = graph_owner_msid && graph_owner_msid != worker[:owner_msid].to_s if changed && defined?(Legion::Data::Model::DigitalWorker) Legion::Data::Model::DigitalWorker.where(worker_id: worker_id).update(owner_msid: graph_owner_msid) if defined?(Legion::Events) Legion::Events.emit('worker.owner_changed', { worker_id: worker_id, old: worker[:owner_msid], new: graph_owner_msid }) end end { synced: true, source: :graph_api, worker_id: worker_id, owner_msid: graph_owner_msid || worker[:owner_msid], changed: !changed.nil?, synced_at: Time.now.utc } rescue Helpers::GraphToken::GraphTokenError, Faraday::Error => e Legion::Logging.warn "[identity:entra] graph sync error: #{e.}" { synced: false, worker_id: worker_id, source: :local, error: e. } end |
#transfer_ownership(worker_id:, new_owner_msid:, transferred_by:, reason: nil) ⇒ Object
Transfer ownership of a digital worker to a new human. Updates the Legion DB record and emits an audit event. The Entra app ownership change must be done by the human owner (requires Application.ReadWrite.All which Legion intentionally does not have).
129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 |
# File 'lib/legion/extensions/agentic/self/identity/runners/entra.rb', line 129 def transfer_ownership(worker_id:, new_owner_msid:, transferred_by:, reason: nil, **) worker = find_worker(worker_id) return { transferred: false, error: 'worker not found' } unless worker old_owner = worker[:owner_msid] return { transferred: false, error: 'same owner' } if old_owner == new_owner_msid # Update local record — this is the Legion side of the transfer if defined?(Legion::Data) && defined?(Legion::Data::Model::DigitalWorker) dw = Legion::Data::Model::DigitalWorker.first(worker_id: worker_id) dw&.update(owner_msid: new_owner_msid, updated_at: Time.now.utc) end # Entra app ownership change requires Application.ReadWrite.All. # Legion does not have this permission by design — the human owner # must update Entra app ownership separately via Azure Portal or CLI. audit = { event: :ownership_transferred, worker_id: worker_id, from_owner: old_owner, to_owner: new_owner_msid, transferred_by: transferred_by, reason: reason, entra_action_required: 'update Entra app ownership via Azure Portal or az CLI', at: Time.now.utc } Legion::Events.emit('worker.ownership_transferred', audit) if defined?(Legion::Events) Legion::Logging.info "[identity:entra] ownership transferred (Legion DB): worker=#{worker_id} " \ "from=#{old_owner} to=#{new_owner_msid} by=#{transferred_by}" Legion::Logging.warn '[identity:entra] Entra app ownership must be updated manually (requires Application.ReadWrite.All)' { transferred: true }.merge(audit) end |
#validate_worker_identity(worker_id:, entra_app_id: nil, token: nil, tenant_id: nil) ⇒ Object
Validate a worker’s identity by checking its Entra app registration exists and its OIDC token is valid. OIDC validation uses the public JWKS endpoint — no Graph API permission needed.
26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 |
# File 'lib/legion/extensions/agentic/self/identity/runners/entra.rb', line 26 def validate_worker_identity(worker_id:, entra_app_id: nil, token: nil, tenant_id: nil, **) worker = find_worker(worker_id) return { valid: false, error: 'worker not found' } unless worker app_id = entra_app_id || worker[:entra_app_id] return { valid: false, error: 'no entra_app_id' } unless app_id # If a token is provided and legion-crypt has JWKS support, validate it if token && defined?(Legion::Crypt::JWT) && Legion::Crypt::JWT.respond_to?(:verify_with_jwks) tid = tenant_id || resolve_tenant_id return { valid: false, error: 'no tenant_id configured' } unless tid jwks_url = format(ENTRA_JWKS_URL_TEMPLATE, tenant_id: tid) issuer = format(ENTRA_ISSUER_TEMPLATE, tenant_id: tid) claims = Legion::Crypt::JWT.verify_with_jwks( token, jwks_url: jwks_url, issuers: [issuer], audience: app_id ) Legion::Logging.debug "[identity:entra] token validated: worker=#{worker_id} sub=#{claims[:sub]}" return { valid: true, worker_id: worker_id, entra_app_id: app_id, owner_msid: worker[:owner_msid], lifecycle: worker[:lifecycle_state], claims: claims, validated_at: Time.now.utc } end # No token provided — return identity info without token validation Legion::Logging.debug "[identity:entra] validate (no token): worker=#{worker_id} entra_app=#{app_id}" { valid: true, worker_id: worker_id, entra_app_id: app_id, owner_msid: worker[:owner_msid], lifecycle: worker[:lifecycle_state], validated_at: Time.now.utc } rescue Legion::Crypt::JWT::ExpiredTokenError => e { valid: false, error: 'token_expired', message: e. } rescue Legion::Crypt::JWT::InvalidTokenError => e { valid: false, error: 'token_invalid', message: e. } rescue Legion::Crypt::JWT::Error => e { valid: false, error: 'token_error', message: e. } end |