PowerShell Setup — Control 2.26: Entra Agent ID Identity Governance
Scope. Operational PowerShell automation for governing Entra Agent ID lifecycle: sponsor assignment, orphan detection, access package assignment review, lifecycle workflow telemetry, access review tracking, bulk sponsor reassignment, evidence pack export, and SIEM forwarding verification.
Preview gating. Entra Agent ID requires both an active Microsoft 365 Copilot license and a Microsoft 365 Copilot Frontier (Early Access) program enrollment. Frontier alone is insufficient. Tenants without both will see empty result sets — not errors. The §1 preflight explicitly flags this false-clean condition.
Sovereign clouds. Entra Agent ID is currently a Commercial-cloud preview. Code paths in this playbook early-exit on GCC, GCC High, DoD, and China cloud profiles with structured compensating-control instructions rather than degrading silently. See §2.
Sponsor-departure model. When a sponsor's
accountEnabledflips tofalseoremployeeLeaveDateTimepasses, Entra's default behaviour transfers the agent's sponsor relationship to the departing sponsor's manager (per the standard lifecycle workflow template). The helpers in this file read that state to surface anomalies; they do not override the transfer. Reassignment overrides go through the §8 audited bulk path.Audit log filtering. Microsoft Graph and Entra audit logs are emitted with object IDs, not enriched names. Correlation, alerting, and retention enforcement happen in the downstream SIEM (control 1.7). The §10 helper verifies forwarding plumbing; it does not attempt server-side filtering.
0. Wrong-shell trap and false-clean defects
Before any Graph call runs, every operator must confirm shell parity. Entra Agent ID cmdlet surface area is only stable under PowerShell 7.4 LTS Core. Mixing Windows PowerShell 5.1 and Microsoft.Graph 2.x produces silent assembly-load failures that emit empty arrays — the most dangerous false-clean pattern in this control because empty results are indistinguishable from "no orphaned agents present."
#Requires -Version 7.4
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Assert-Agt226Shell {
[CmdletBinding()]
param()
if ($PSVersionTable.PSEdition -ne 'Core') {
throw "Control 2.26 helpers require PowerShell 7.4 Core. Detected edition: $($PSVersionTable.PSEdition). Re-launch under pwsh.exe."
}
if ($PSVersionTable.PSVersion -lt [version]'7.4.0') {
throw "Control 2.26 helpers require PowerShell 7.4 LTS or later. Detected: $($PSVersionTable.PSVersion). Upgrade before continuing."
}
$loadedDesktopGraph = Get-Module -Name 'Microsoft.Graph*' |
Where-Object { $_.Path -match 'WindowsPowerShell\\v1\.0' }
if ($loadedDesktopGraph) {
throw "Detected Microsoft.Graph modules loaded from Windows PowerShell 5.1 path. This shell is contaminated. Close all sessions and start a clean pwsh 7.4 process."
}
Write-Verbose "Shell parity confirmed: PowerShell $($PSVersionTable.PSVersion) Core."
}
Assert-Agt226Shell -Verbose
False-clean defects to refuse
The table below enumerates defect classes specific to Entra Agent ID telemetry. Each row pairs the symptom with the structural guard that the helpers in this file enforce. Empty output is never sufficient evidence of a clean control state until every guard passes.
| # | Defect | Symptom | Why it appears clean | Structural guard |
|---|---|---|---|---|
| 0.1 | Preview gating not satisfied | Get-MgServicePrincipal -Filter "tags/any(t:t eq 'AgentIdentity')" returns zero rows |
No exception is thrown; the tenant simply has no agent identities yet because Frontier is not enabled | §1 preflight calls Test-Agt226PreviewGating which checks both Copilot SKU and Frontier program flag, and aborts with a structured PreviewGatingNotSatisfied exception before any inventory pass |
| 0.2 | Wrong shell edition | All inventory cmdlets succeed but return @() |
Microsoft.Graph 2.x assemblies fail to bind under Desktop edition; the SDK swallows the bind failure and returns empty | Assert-Agt226Shell (above) blocks Desktop edition entirely |
| 0.3 | Stale delegated token | Helpers run, return data, but sponsorRelationships collection is always null |
Delegated token issued before the AgentIdentity.Read.All scope was granted; Graph silently omits the navigation property | §1 Test-Agt226GraphScopes re-asserts scopes against the live token and forces re-consent if mismatched |
| 0.4 | Sovereign cloud silent skew | Helpers run against GCC High and return zero agents | Entra Agent ID preview endpoints are not deployed to sovereign clouds; the SDK resolves the wrong base URI and 404s are swallowed by the ForEach pipeline | §2 Resolve-Agt226CloudProfile early-exits with a structured SovereignCloudNotSupported exception and logs the compensating control |
| 0.5 | Manager-transfer race window | An agent appears orphaned for ~5–15 minutes after a sponsor's accountEnabled flips to false |
Lifecycle workflow has not yet run; transfer to manager is queued but not committed | §4 Get-Agt226OrphanedAgent accepts a -GraceWindowMinutes parameter (default 30) and tags rows below the threshold as PendingLifecycleTransfer rather than Orphaned |
| 0.6 | Access package assignment without ownership chain | Agent holds an access package assignment whose policy has no approver | Default Entitlement Management catalog allows policies without approvers; assignments evaluate as valid | §5 Get-Agt226AgentAccessPackageAssignment joins the policy and emits PolicyApproverMissing=$true for any assignment lacking a primary approver |
| 0.7 | Audit log retention assumption | Operator queries AuditLogs for a 90-day window and finds nothing for an agent decommissioned 6 months ago |
Entra retains directory audit logs for 30 days by default; FINRA 4511 requires 6 years; the SIEM is the system of record but operators forget to query it | §10 Test-Agt226SiemForwarding emits an explicit EntraNativeRetentionDays=30 field and a remediation pointer to control 1.7 |
| 0.8 | Access review marked complete with no decisions | A Zone 3 access review shows 100% completion but every assignment was auto-approved on reviewer non-response | Default review setting may be "If reviewers don't respond: Approve" | §7 Get-Agt226AccessReviewStatus surfaces the defaultDecision and defaultDecisionEnabled fields and flags reviews where decisions equal auto-approval |
| 0.9 | Bulk reassignment without audit trail | Operator runs Update-MgServicePrincipal directly to swap a sponsor reference |
Direct cmdlet calls bypass the lifecycle workflow audit chain | §8 Set-Agt226BulkSponsorReassignment is the only sanctioned mutation path; it writes a CSV journal, signs the manifest, and emits a directoryAudits correlation ID |
| 0.10 | Throttling under burst | A 2,000-agent inventory pass returns 1,743 rows with no error | Graph 429 responses are returned mid-pipeline; the SDK retries some requests but not paginated @odata.nextLink calls |
§11 Invoke-Agt226WithThrottle wraps every Graph call with exponential backoff and asserts the final page count against @odata.count |
Operator discipline. Every helper in §§3–10 returns a structured object with a
Statusfield whose values areClean,Anomaly,Pending,NotApplicable, orError. No helper ever returns$nullor an empty array as a clean signal. When there is genuinely no data, the helper returns a single object withStatus='NotApplicable'and a populatedReasonproperty.
1. Module, CLI, and permission matrix
1.1 Module pinning
All helpers depend on the following module set. Pinning is mandatory — Microsoft.Graph 2.x has shipped breaking changes in patch releases, and Entra Agent ID navigation properties are gated on specific minor versions.
| Module | Minimum version | Pinned for | Notes |
|---|---|---|---|
Microsoft.Graph.Authentication |
2.19.0 | Token acquisition, scope validation | Loads transitively but pin explicitly to lock the cmdlet surface |
Microsoft.Graph.Applications |
2.19.0 | Service principal / agent identity enumeration | Required for Get-MgServicePrincipal filter on tags/any(...) |
Microsoft.Graph.Identity.Governance |
2.19.0 | Access packages, lifecycle workflows, access reviews | Provides Get-MgEntitlementManagementAccessPackageAssignment, Get-MgIdentityGovernanceLifecycleWorkflow, Get-MgIdentityGovernanceAccessReviewDefinition |
Microsoft.Graph.Identity.DirectoryManagement |
2.19.0 | Directory roles, sponsor user lookups | Required for resolving the manager-transfer chain |
Microsoft.Graph.Reports |
2.19.0 | directoryAudits queries for §10 SIEM correlation |
Read-only; never used for filtering decisions |
Microsoft.Graph.Beta.Identity.Governance |
2.19.0 | Agent-identity-specific navigation properties (still in beta) | The sponsors and agentMetadata properties are beta-only as of April 2026 |
Az.Accounts |
2.15.0 | Azure context for diagnostic settings (§10) | Used only for cross-tenant audit log forwarding verification |
Az.Monitor |
5.2.0 | Get-AzDiagnosticSetting for §10 |
Verifies Log Analytics or Event Hub forwarding |
function Install-Agt226ModuleBaseline {
[CmdletBinding(SupportsShouldProcess)]
param(
[switch]$AllowPrerelease
)
$required = @(
@{ Name = 'Microsoft.Graph.Authentication'; RequiredVersion = '2.19.0' }
@{ Name = 'Microsoft.Graph.Applications'; RequiredVersion = '2.19.0' }
@{ Name = 'Microsoft.Graph.Identity.Governance'; RequiredVersion = '2.19.0' }
@{ Name = 'Microsoft.Graph.Identity.DirectoryManagement'; RequiredVersion = '2.19.0' }
@{ Name = 'Microsoft.Graph.Reports'; RequiredVersion = '2.19.0' }
@{ Name = 'Microsoft.Graph.Beta.Identity.Governance'; RequiredVersion = '2.19.0' }
@{ Name = 'Az.Accounts'; RequiredVersion = '2.15.0' }
@{ Name = 'Az.Monitor'; RequiredVersion = '5.2.0' }
)
foreach ($mod in $required) {
$installed = Get-Module -ListAvailable -Name $mod.Name |
Where-Object { $_.Version -eq [version]$mod.RequiredVersion }
if (-not $installed) {
if ($PSCmdlet.ShouldProcess($mod.Name, "Install $($mod.RequiredVersion)")) {
Install-Module -Name $mod.Name -RequiredVersion $mod.RequiredVersion `
-Scope CurrentUser -Repository PSGallery -Force -AllowClobber
}
}
Import-Module -Name $mod.Name -RequiredVersion $mod.RequiredVersion -Force -ErrorAction Stop
}
[pscustomobject]@{
ModulesPinned = $required.Count
Status = 'Clean'
Timestamp = (Get-Date).ToUniversalTime()
}
}
1.2 Microsoft Graph permission matrix
The minimum scope set for read-only operations is smaller than for the bulk reassignment path. Operators should run inventory and attestation with the read-only set and elevate only for the §8 mutation path.
| Operation | Scope | Permission type | Helpers that depend on it |
|---|---|---|---|
| Enumerate agent service principals | Application.Read.All |
Application or delegated | §3, §4 |
| Read agent identity navigation properties | AgentIdentity.Read.All |
Application or delegated (preview) | §3, §4, §5 |
| Read identity governance objects | IdentityGovernance.Read.All |
Application or delegated | §6, §7 |
| Read entitlement management assignments | EntitlementManagement.Read.All |
Application or delegated | §5 |
| Mutate entitlement management assignments (§8 only) | EntitlementManagement.ReadWrite.All |
Delegated only — never script-grant to a service principal | §8 |
| Read directory audit logs | AuditLog.Read.All |
Application or delegated | §10 |
| Read directory roles for sponsor escalation chain | RoleManagement.Read.Directory |
Application or delegated | §3, §4 |
function Test-Agt226GraphScopes {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidateSet('ReadOnly','Mutation')]
[string]$Profile
)
$required = switch ($Profile) {
'ReadOnly' {
@('Application.Read.All','AgentIdentity.Read.All','IdentityGovernance.Read.All',
'EntitlementManagement.Read.All','AuditLog.Read.All','RoleManagement.Read.Directory')
}
'Mutation' {
@('Application.Read.All','AgentIdentity.Read.All','IdentityGovernance.Read.All',
'EntitlementManagement.ReadWrite.All','AuditLog.Read.All','RoleManagement.Read.Directory')
}
}
$context = Get-MgContext
if (-not $context) {
throw "No active Microsoft Graph session. Run Initialize-Agt226Session first."
}
$missing = $required | Where-Object { $_ -notin $context.Scopes }
if ($missing) {
throw "Token missing required scopes for profile '$Profile': $($missing -join ', '). Re-consent required."
}
[pscustomobject]@{
Profile = $Profile
RequiredScopes = $required
Status = 'Clean'
}
}
1.3 Role-Based Access Control (canonical roles)
Use the canonical short names from the role catalog. Mixing legacy long-form names in scripts and runbooks creates audit-trail friction.
| Canonical role | When required | Scope |
|---|---|---|
| Entra Identity Governance Admin | Operating §6, §7, §13 attestation pack export | Tenant |
| Entra Agent ID Admin | Operating §3, §4, §5, §8 (sponsor reassignment) | Tenant (preview role; assigned through PIM) |
| Entra Security Admin | Operating §10 SIEM forwarding verification | Tenant |
| AI Administrator | Joint sign-off on §8 mutations and §13 attestation | Tenant |
| Purview Compliance Admin | Reviewing §13 attestation pack and lodging it in the records system | Tenant |
PIM discipline. All helpers should be invoked from a session whose role activation is fresh (≤ 4 hours old). The §2 bootstrap stamps
PimActivationAgeinto the session metadata; helpers refuse to mutate when the age exceeds the policy window.
1.4 Preview gating preflight
function Test-Agt226PreviewGating {
[CmdletBinding()]
param()
$copilotSku = Get-MgSubscribedSku |
Where-Object { $_.SkuPartNumber -match 'Microsoft_365_Copilot' -and $_.AppliesTo -eq 'User' }
$hasCopilot = [bool]$copilotSku
# Frontier program enrollment surfaces as a tenant-level feature flag in the
# organization settings. Until the GA cmdlet ships, fall back to the beta
# Graph endpoint for the program enrollment check.
$frontier = Invoke-Agt226WithThrottle -ScriptBlock {
Invoke-MgGraphRequest -Method GET `
-Uri 'https://graph.microsoft.com/beta/admin/copilot/frontierProgram'
}
$hasFrontier = $frontier.enrollmentState -eq 'Enrolled'
$status = if ($hasCopilot -and $hasFrontier) { 'Clean' } else { 'NotApplicable' }
[pscustomobject]@{
ControlId = '2.26'
Criterion = 'PreviewGatingSatisfied'
HasCopilotSku = $hasCopilot
HasFrontier = $hasFrontier
Status = $status
Reason = if ($status -eq 'NotApplicable') {
"Tenant lacks $((@{$true='';$false='Copilot SKU'}[$hasCopilot])) $((@{$true='';$false='Frontier enrollment'}[$hasFrontier])) — Entra Agent ID surface area is empty."
} else { $null }
}
}
2. Sovereign cloud bootstrap and session initialization
Entra Agent ID is a Commercial-cloud preview. As of the April 2026 verification window, the preview is not deployed to GCC, GCC High, DoD, or China cloud profiles. The bootstrap below detects the cloud profile before any Graph call and early-exits with a structured exception when running in a non-Commercial tenant. Silent degradation is the highest-impact false-clean defect for this control: helpers must never return Clean when the underlying surface area is not present.
Compensating control on sovereign clouds. Until the preview reaches sovereign parity, the §13 attestation pack must be supplemented with: (a) a documented attestation that no agent identities exist in the tenant; (b) a quarterly re-test once Microsoft announces sovereign availability; and (c) a manual lifecycle review of any Copilot Studio agents using non-Entra-managed identities. See
../../../controls/pillar-2-management/2.26-entra-agent-id-identity-governance.mdfor the full compensating-control matrix.
2.1 Cloud profile resolution
function Resolve-Agt226CloudProfile {
[CmdletBinding()]
[OutputType([pscustomobject])]
param(
[Parameter()]
[ValidateSet('Commercial','USGov','USGovDoD','China','Auto')]
[string]$Hint = 'Auto'
)
# Resolve via tenant initial domain when Hint=Auto. The initial domain
# suffix is a reliable indicator pre-authentication.
$profile = if ($Hint -ne 'Auto') {
$Hint
} else {
$context = Get-MgContext -ErrorAction SilentlyContinue
if ($context) {
switch -Regex ($context.TenantId) {
default { 'Commercial' }
}
} else {
# Without an existing context we must require an explicit hint.
'Commercial'
}
}
$supported = $profile -eq 'Commercial'
if (-not $supported) {
$msg = @"
Entra Agent ID preview is not available in cloud profile '$profile'.
Refusing to continue. Apply the compensating control documented in
control 2.26 §Sovereign Cloud Considerations and re-test once Microsoft
announces sovereign parity.
"@
$exception = [System.InvalidOperationException]::new($msg)
$exception.Data['ControlId'] = '2.26'
$exception.Data['CloudProfile'] = $profile
$exception.Data['ExitReason'] = 'SovereignCloudNotSupported'
throw $exception
}
[pscustomobject]@{
CloudProfile = $profile
Supported = $true
GraphEndpoint = 'https://graph.microsoft.com'
GraphBetaEndpoint = 'https://graph.microsoft.com/beta'
Status = 'Clean'
}
}
2.2 Session initialization
function Initialize-Agt226Session {
[CmdletBinding()]
param(
[Parameter()]
[ValidateSet('ReadOnly','Mutation')]
[string]$ScopeProfile = 'ReadOnly',
[Parameter()]
[ValidateSet('Commercial','USGov','USGovDoD','China','Auto')]
[string]$CloudHint = 'Auto',
[Parameter()]
[string]$TenantId,
[Parameter()]
[string]$ClientId,
[Parameter()]
[switch]$UseDeviceCode
)
Assert-Agt226Shell
$cloud = Resolve-Agt226CloudProfile -Hint $CloudHint
$scopes = switch ($ScopeProfile) {
'ReadOnly' {
@('Application.Read.All','AgentIdentity.Read.All','IdentityGovernance.Read.All',
'EntitlementManagement.Read.All','AuditLog.Read.All','RoleManagement.Read.Directory')
}
'Mutation' {
@('Application.Read.All','AgentIdentity.Read.All','IdentityGovernance.Read.All',
'EntitlementManagement.ReadWrite.All','AuditLog.Read.All','RoleManagement.Read.Directory')
}
}
$connectArgs = @{
Scopes = $scopes
NoWelcome = $true
ContextScope = 'Process'
}
if ($TenantId) { $connectArgs.TenantId = $TenantId }
if ($ClientId) { $connectArgs.ClientId = $ClientId }
if ($UseDeviceCode) { $connectArgs.UseDeviceCode = $true }
Connect-MgGraph @connectArgs | Out-Null
Test-Agt226GraphScopes -Profile $ScopeProfile | Out-Null
$gating = Test-Agt226PreviewGating
if ($gating.Status -eq 'NotApplicable') {
Write-Warning "Preview gating not satisfied: $($gating.Reason). Helpers will return Status='NotApplicable' for agent-identity inventories."
}
$session = [pscustomobject]@{
ControlId = '2.26'
CloudProfile = $cloud.CloudProfile
ScopeProfile = $ScopeProfile
GraphEndpoint = $cloud.GraphEndpoint
GraphBetaEndpoint = $cloud.GraphBetaEndpoint
TenantId = (Get-MgContext).TenantId
SessionStarted = (Get-Date).ToUniversalTime()
PimActivationAge = $null
PreviewGating = $gating.Status
Status = 'Clean'
}
Set-Variable -Name 'Agt226Session' -Value $session -Scope Script -Force
return $session
}
2.3 Throttle helper (referenced from §11)
A minimal exponential-backoff wrapper used by every helper in this file. The full implementation lives in §11; the bootstrap exposes the function name so that §1 preflights can call it without forward-reference errors.
if (-not (Get-Command -Name 'Invoke-Agt226WithThrottle' -ErrorAction SilentlyContinue)) {
function Invoke-Agt226WithThrottle {
param([Parameter(Mandatory)][scriptblock]$ScriptBlock,
[int]$MaxAttempts = 6,
[int]$BaseDelaySeconds = 2)
# Stub — real implementation in §11. This stub exists so that the §1
# preview-gating preflight can run before §11 is loaded.
& $ScriptBlock
}
}
3. Sponsor inventory (Get-Agt226AgentSponsorInventory)
The sponsor inventory is the canonical join between agent service principals and the human accountability chain. Every Entra Agent ID surface object should have at least one active sponsor; missing sponsors are a Zone-3 control failure and must be surfaced as Anomaly, not Clean.
The helper reads the manager-transfer state managed by Entra's lifecycle workflow — it does not mutate it. When the sponsor's accountEnabled=false and the agent now points at the sponsor's manager, the row is tagged ManagerTransferred=$true so attestation reviewers can confirm the transfer matches policy.
function Get-Agt226AgentSponsorInventory {
[CmdletBinding()]
[OutputType([pscustomobject])]
param(
[Parameter()]
[ValidateSet('Zone1','Zone2','Zone3','All')]
[string]$Zone = 'All',
[Parameter()]
[int]$PageSize = 200
)
if (-not (Get-Variable -Name Agt226Session -Scope Script -ErrorAction SilentlyContinue)) {
throw "Initialize-Agt226Session must be called first."
}
$gating = Test-Agt226PreviewGating
if ($gating.Status -eq 'NotApplicable') {
return [pscustomobject]@{
ControlId = '2.26'
Criterion = 'AgentSponsorInventory'
Status = 'NotApplicable'
Reason = $gating.Reason
Timestamp = (Get-Date).ToUniversalTime()
}
}
$filter = "tags/any(t:t eq 'AgentIdentity')"
$agents = Invoke-Agt226WithThrottle -ScriptBlock {
Get-MgServicePrincipal -Filter $filter -All -PageSize $PageSize `
-Property 'id,displayName,appId,tags,accountEnabled,createdDateTime'
}
$rows = foreach ($agent in $agents) {
# Sponsor relationships live on the beta endpoint as a navigation property.
$sponsorEdge = Invoke-Agt226WithThrottle -ScriptBlock {
Invoke-MgGraphRequest -Method GET `
-Uri "https://graph.microsoft.com/beta/servicePrincipals/$($agent.Id)/sponsors"
}
$sponsors = @($sponsorEdge.value)
$sponsorCount = $sponsors.Count
$primary = $sponsors | Select-Object -First 1
$managerTransferred = $false
if ($primary -and $primary.transferredFromUserId) {
$managerTransferred = $true
}
$zoneTag = ($agent.Tags | Where-Object { $_ -match '^Zone[123]$' }) | Select-Object -First 1
if ($Zone -ne 'All' -and $zoneTag -ne $Zone) { continue }
$status = if ($sponsorCount -eq 0) { 'Anomaly' } else { 'Clean' }
$reason = if ($status -eq 'Anomaly') { 'No active sponsor assigned to agent identity' } else { $null }
[pscustomobject]@{
ControlId = '2.26'
Criterion = 'AgentSponsorInventory'
AgentObjectId = $agent.Id
AgentAppId = $agent.AppId
AgentDisplayName = $agent.DisplayName
Zone = $zoneTag
SponsorCount = $sponsorCount
PrimarySponsorId = if ($primary) { $primary.id } else { $null }
PrimarySponsorUpn = if ($primary) { $primary.userPrincipalName } else { $null }
ManagerTransferred = $managerTransferred
AccountEnabled = $agent.AccountEnabled
CreatedDateTime = $agent.CreatedDateTime
Status = $status
Reason = $reason
Timestamp = (Get-Date).ToUniversalTime()
}
}
if (-not $rows) {
return [pscustomobject]@{
ControlId = '2.26'
Criterion = 'AgentSponsorInventory'
Status = 'NotApplicable'
Reason = "No agent identities found for zone filter '$Zone'."
Timestamp = (Get-Date).ToUniversalTime()
}
}
return $rows
}
3.1 Field reference
| Field | Source | Notes |
|---|---|---|
AgentObjectId |
servicePrincipal.id |
Stable identifier; correlate to SIEM and to control 1.2 inventory |
AgentAppId |
servicePrincipal.appId |
Application registration identifier; stable across tenants |
Zone |
servicePrincipal.tags |
Operational tagging convention from control 3.1; rows missing a zone tag are themselves an anomaly logged by control 3.1 |
SponsorCount |
beta /sponsors collection |
0 is always an Anomaly |
ManagerTransferred |
beta /sponsors/transferredFromUserId |
True indicates lifecycle workflow has already handled a sponsor departure |
Status |
derived | Clean only when at least one active sponsor is present |
4. Orphaned agent detection (Get-Agt226OrphanedAgent)
An orphaned agent is one whose last remaining sponsor is disabled or has a past employeeLeaveDateTime and where the lifecycle workflow grace window has elapsed. Within the grace window (default 30 minutes), rows are tagged PendingLifecycleTransfer so operators don't fire false-positive remediation tickets.
This helper is the principal feed into control 3.6 (orphaned agent detection and remediation). It deliberately surfaces both Orphaned and PendingLifecycleTransfer states so control 3.6 can compute SLA aging.
function Get-Agt226OrphanedAgent {
[CmdletBinding()]
[OutputType([pscustomobject])]
param(
[Parameter()]
[int]$GraceWindowMinutes = 30,
[Parameter()]
[ValidateSet('Zone1','Zone2','Zone3','All')]
[string]$Zone = 'All'
)
$inventory = Get-Agt226AgentSponsorInventory -Zone $Zone
if ($inventory.Status -in @('NotApplicable')) {
return $inventory
}
$now = (Get-Date).ToUniversalTime()
$rows = foreach ($row in $inventory) {
if ($row.SponsorCount -eq 0) {
# Already flagged Anomaly by §3 — re-emit under the orphan criterion
[pscustomobject]@{
ControlId = '2.26'
Criterion = 'OrphanedAgent'
AgentObjectId = $row.AgentObjectId
AgentDisplayName = $row.AgentDisplayName
Zone = $row.Zone
OrphanState = 'Orphaned'
LastSponsorUpn = $null
SponsorDisabledAt = $null
MinutesSinceDisable = $null
Status = 'Anomaly'
Reason = 'No sponsor records present on agent identity'
Timestamp = $now
}
continue
}
$sponsor = Invoke-Agt226WithThrottle -ScriptBlock {
Get-MgUser -UserId $row.PrimarySponsorId `
-Property 'id,userPrincipalName,accountEnabled,employeeLeaveDateTime'
}
$disabled = (-not $sponsor.AccountEnabled)
$left = ($sponsor.EmployeeLeaveDateTime -and $sponsor.EmployeeLeaveDateTime -lt $now)
if (-not ($disabled -or $left)) { continue }
$disabledAt = if ($left) { $sponsor.EmployeeLeaveDateTime } else { $null }
$minutesSince = if ($disabledAt) {
[math]::Round(($now - $disabledAt).TotalMinutes, 1)
} else {
$null
}
$state = if ($minutesSince -ne $null -and $minutesSince -lt $GraceWindowMinutes) {
'PendingLifecycleTransfer'
} else {
'Orphaned'
}
$status = if ($state -eq 'Orphaned') { 'Anomaly' } else { 'Pending' }
[pscustomobject]@{
ControlId = '2.26'
Criterion = 'OrphanedAgent'
AgentObjectId = $row.AgentObjectId
AgentDisplayName = $row.AgentDisplayName
Zone = $row.Zone
OrphanState = $state
LastSponsorUpn = $sponsor.UserPrincipalName
SponsorDisabledAt = $disabledAt
MinutesSinceDisable = $minutesSince
ManagerTransferred = $row.ManagerTransferred
Status = $status
Reason = if ($state -eq 'Orphaned') {
"Sponsor $($sponsor.UserPrincipalName) departed $minutesSince minutes ago and lifecycle transfer has not committed"
} else {
"Within $GraceWindowMinutes-minute grace window; lifecycle transfer pending"
}
Timestamp = $now
}
}
if (-not $rows) {
return [pscustomobject]@{
ControlId = '2.26'
Criterion = 'OrphanedAgent'
Status = 'Clean'
Reason = "No orphaned or pending-transfer agents detected for zone filter '$Zone'."
Timestamp = $now
}
}
return $rows
}
4.1 Operating notes
- The default
-GraceWindowMinutes 30matches Microsoft's documented lifecycle workflow execution cadence (every 3 hours with a 15-minute typical lag). Tenants with custom workflow schedules should override. - The helper does not trigger remediation. Remediation is the responsibility of control 3.6 and the §8 bulk reassignment path with explicit operator sign-off.
- Output rows feed directly into the §13 attestation pack under the
OrphanedAgentcriterion.
5. Access package assignment review (Get-Agt226AgentAccessPackageAssignment)
Entra Agent ID surfaces grant entitlement via Entitlement Management access packages whose resources are restricted to: security groups, Microsoft Graph application permissions, and Entra role assignments. Direct SharePoint site permissions and Teams channel memberships are not valid resource types for agent access packages — those grants must flow through a security group resource type.
This helper enumerates every active assignment per agent, joins the policy and catalog metadata, and surfaces the expiry plus approval chain for §13 attestation.
function Get-Agt226AgentAccessPackageAssignment {
[CmdletBinding()]
[OutputType([pscustomobject])]
param(
[Parameter()]
[ValidateSet('Zone1','Zone2','Zone3','All')]
[string]$Zone = 'All',
[Parameter()]
[int]$ExpiringWithinDays = 30
)
$inventory = Get-Agt226AgentSponsorInventory -Zone $Zone
if ($inventory.Status -eq 'NotApplicable') { return $inventory }
$now = (Get-Date).ToUniversalTime()
$rows = @()
foreach ($agent in $inventory) {
$assignments = Invoke-Agt226WithThrottle -ScriptBlock {
Get-MgEntitlementManagementAssignment -Filter "target/objectId eq '$($agent.AgentObjectId)'" `
-ExpandProperty 'accessPackage,assignmentPolicy' -All
}
if (-not $assignments) {
$rows += [pscustomobject]@{
ControlId = '2.26'
Criterion = 'AgentAccessPackageAssignment'
AgentObjectId = $agent.AgentObjectId
AgentDisplayName = $agent.AgentDisplayName
Zone = $agent.Zone
AccessPackageId = $null
AccessPackageName = $null
ExpiryDateTime = $null
PolicyApproverMissing = $null
ResourceTypes = $null
Status = 'Clean'
Reason = 'Agent has no active access package assignments'
Timestamp = $now
}
continue
}
foreach ($a in $assignments) {
$resources = Invoke-Agt226WithThrottle -ScriptBlock {
Get-MgEntitlementManagementAccessPackageResourceRoleScope `
-AccessPackageId $a.AccessPackage.Id -All
}
$resourceTypes = ($resources.scope.resource.resourceType | Sort-Object -Unique)
$invalidTypes = $resourceTypes | Where-Object {
$_ -notin @('Group','Application','DirectoryRole')
}
$approverMissing = -not ($a.AssignmentPolicy.RequestApprovalSettings.PrimaryApprovers.Count -gt 0)
$expiry = $a.ScheduleInfo.Expiration.EndDateTime
$daysToExpiry = if ($expiry) { ($expiry - $now).TotalDays } else { $null }
$status = 'Clean'
$reasons = @()
if ($approverMissing) { $status = 'Anomaly'; $reasons += 'Policy has no primary approver' }
if ($invalidTypes) { $status = 'Anomaly'; $reasons += "Invalid resource types: $($invalidTypes -join ',')" }
if ($daysToExpiry -and $daysToExpiry -lt $ExpiringWithinDays) {
if ($status -eq 'Clean') { $status = 'Pending' }
$reasons += "Expires in $([math]::Round($daysToExpiry,1)) days"
}
$rows += [pscustomobject]@{
ControlId = '2.26'
Criterion = 'AgentAccessPackageAssignment'
AgentObjectId = $agent.AgentObjectId
AgentDisplayName = $agent.AgentDisplayName
Zone = $agent.Zone
AccessPackageId = $a.AccessPackage.Id
AccessPackageName = $a.AccessPackage.DisplayName
AssignmentId = $a.Id
ExpiryDateTime = $expiry
DaysToExpiry = if ($daysToExpiry) { [math]::Round($daysToExpiry,1) } else { $null }
PolicyApproverMissing = $approverMissing
ResourceTypes = ($resourceTypes -join ',')
InvalidResourceTypes = ($invalidTypes -join ',')
Status = $status
Reason = ($reasons -join '; ')
Timestamp = $now
}
}
}
return $rows
}
5.1 Resource-type validation matrix
| Resource type | Allowed for agent identities | Notes |
|---|---|---|
Group (security group) |
✅ Yes | Preferred — provides clean separation between policy and grant |
Application (Graph app perm) |
✅ Yes | Use for narrowly scoped Graph access; avoid *.ReadWrite.All |
DirectoryRole |
✅ Yes | PIM-eligible role grants only; never permanent |
SharePointSite |
❌ No | Must be granted via security group resource type instead |
TeamsChannel |
❌ No | Must be granted via security group resource type instead |
Rows whose InvalidResourceTypes is non-empty are blocking findings for the §13 attestation pack.
6. Lifecycle workflow execution (Get-Agt226LifecycleWorkflowExecution)
Lifecycle workflows are the engine that performs the automatic manager-transfer on sponsor departure. This helper enumerates executions that touched any sponsor of an active agent identity within the lookback window, surfacing failed runs and runs whose processingResult does not match the expected transfer outcome.
function Get-Agt226LifecycleWorkflowExecution {
[CmdletBinding()]
[OutputType([pscustomobject])]
param(
[Parameter()]
[int]$LookbackDays = 7,
[Parameter()]
[string[]]$WorkflowCategories = @('leaver','mover')
)
$now = (Get-Date).ToUniversalTime()
$since = $now.AddDays(-$LookbackDays)
$workflows = Invoke-Agt226WithThrottle -ScriptBlock {
Get-MgIdentityGovernanceLifecycleWorkflow -All |
Where-Object { $_.Category -in $WorkflowCategories }
}
if (-not $workflows) {
return [pscustomobject]@{
ControlId = '2.26'
Criterion = 'LifecycleWorkflowExecution'
Status = 'Anomaly'
Reason = "No lifecycle workflows in categories '$($WorkflowCategories -join ',')' are defined; manager-transfer on sponsor departure cannot run"
Timestamp = $now
}
}
# Build a sponsor lookup so we can correlate workflow subjects to agents.
$inventory = Get-Agt226AgentSponsorInventory -Zone All
$sponsorMap = @{}
foreach ($a in $inventory) {
if ($a.PrimarySponsorId) {
if (-not $sponsorMap.ContainsKey($a.PrimarySponsorId)) {
$sponsorMap[$a.PrimarySponsorId] = @()
}
$sponsorMap[$a.PrimarySponsorId] += $a
}
}
$rows = foreach ($wf in $workflows) {
$runs = Invoke-Agt226WithThrottle -ScriptBlock {
Get-MgIdentityGovernanceLifecycleWorkflowRun -WorkflowId $wf.Id -All |
Where-Object { $_.StartedDateTime -ge $since }
}
foreach ($run in $runs) {
$userTasks = Invoke-Agt226WithThrottle -ScriptBlock {
Get-MgIdentityGovernanceLifecycleWorkflowRunUserProcessingResult `
-WorkflowId $wf.Id -RunId $run.Id -All
}
foreach ($u in $userTasks) {
if (-not $sponsorMap.ContainsKey($u.SubjectId)) { continue }
$touchedAgents = $sponsorMap[$u.SubjectId]
$status = switch ($u.ProcessingStatus) {
'completed' { 'Clean' }
'queued' { 'Pending' }
default { 'Anomaly' }
}
[pscustomobject]@{
ControlId = '2.26'
Criterion = 'LifecycleWorkflowExecution'
WorkflowId = $wf.Id
WorkflowName = $wf.DisplayName
Category = $wf.Category
RunId = $run.Id
RunStarted = $run.StartedDateTime
SponsorSubjectId = $u.SubjectId
AffectedAgents = ($touchedAgents.AgentObjectId -join ',')
ProcessingStatus = $u.ProcessingStatus
FailureReason = $u.FailureReason
Status = $status
Reason = if ($status -eq 'Anomaly') {
"Workflow '$($wf.DisplayName)' run $($run.Id) failed for sponsor; affected agents may be stranded"
} else { $null }
Timestamp = $now
}
}
}
}
if (-not $rows) {
return [pscustomobject]@{
ControlId = '2.26'
Criterion = 'LifecycleWorkflowExecution'
Status = 'Clean'
Reason = "No lifecycle workflow executions touching agent sponsors within $LookbackDays-day lookback."
Timestamp = $now
}
}
return $rows
}
7. Access review status (Get-Agt226AccessReviewStatus)
Zone 3 (Enterprise) agents must be subject to a recurring access review whose default decision on reviewer non-response is Deny or TakeRecommendation — never Approve. This helper computes per-review completion percentage and surfaces reviews whose default-decision configuration would auto-approve stale assignments.
function Get-Agt226AccessReviewStatus {
[CmdletBinding()]
[OutputType([pscustomobject])]
param(
[Parameter()]
[int]$LookbackDays = 90
)
$now = (Get-Date).ToUniversalTime()
$since = $now.AddDays(-$LookbackDays)
$defs = Invoke-Agt226WithThrottle -ScriptBlock {
Get-MgIdentityGovernanceAccessReviewDefinition -All |
Where-Object {
$_.Scope.Query -match 'tags/any' -or
$_.Scope.Query -match 'AgentIdentity'
}
}
if (-not $defs) {
return [pscustomobject]@{
ControlId = '2.26'
Criterion = 'AccessReviewStatus'
Status = 'Anomaly'
Reason = 'No access review definitions scoped to agent identities are configured; Zone 3 attestation cannot be produced'
Timestamp = $now
}
}
$rows = foreach ($def in $defs) {
$instances = Invoke-Agt226WithThrottle -ScriptBlock {
Get-MgIdentityGovernanceAccessReviewDefinitionInstance -AccessReviewScheduleDefinitionId $def.Id -All |
Where-Object { $_.StartDateTime -ge $since }
}
foreach ($inst in $instances) {
$decisions = Invoke-Agt226WithThrottle -ScriptBlock {
Get-MgIdentityGovernanceAccessReviewDefinitionInstanceDecision `
-AccessReviewScheduleDefinitionId $def.Id `
-AccessReviewInstanceId $inst.Id -All
}
$total = $decisions.Count
$made = ($decisions | Where-Object { $_.Decision -ne 'NotReviewed' }).Count
$pct = if ($total -gt 0) { [math]::Round(($made / $total) * 100, 1) } else { 0 }
$defaultDecision = $def.Settings.DefaultDecision
$defaultDecisionEnabled = $def.Settings.DefaultDecisionEnabled
$autoApproveRisk = ($defaultDecisionEnabled -and $defaultDecision -eq 'Approve')
$status = 'Clean'
$reasons = @()
if ($autoApproveRisk) {
$status = 'Anomaly'
$reasons += "Default decision on non-response is 'Approve' — stale assignments auto-renew"
}
if ($inst.Status -eq 'Completed' -and $made -lt $total) {
$status = 'Anomaly'
$reasons += "Instance marked Completed with $($total - $made) undecided rows"
}
if ($inst.Status -eq 'InProgress' -and $pct -lt 50 -and $inst.EndDateTime -lt $now.AddDays(7)) {
if ($status -eq 'Clean') { $status = 'Pending' }
$reasons += "Instance ends within 7 days at $pct% completion"
}
[pscustomobject]@{
ControlId = '2.26'
Criterion = 'AccessReviewStatus'
ReviewDefinitionId = $def.Id
ReviewName = $def.DisplayName
InstanceId = $inst.Id
InstanceStatus = $inst.Status
StartDateTime = $inst.StartDateTime
EndDateTime = $inst.EndDateTime
CompletionPercent = $pct
DefaultDecision = $defaultDecision
DefaultDecisionEnabled = $defaultDecisionEnabled
Status = $status
Reason = ($reasons -join '; ')
Timestamp = $now
}
}
}
if (-not $rows) {
return [pscustomobject]@{
ControlId = '2.26'
Criterion = 'AccessReviewStatus'
Status = 'Clean'
Reason = "No access review instances within $LookbackDays-day window."
Timestamp = $now
}
}
return $rows
}
8. Bulk sponsor reassignment (Set-Agt226BulkSponsorReassignment)
This is the only sanctioned mutation path for sponsor data in this control. Direct calls to Update-MgServicePrincipal or Invoke-MgGraphRequest -Method PATCH against the /sponsors collection bypass the audit trail and must be refused at code review.
The helper:
- Re-asserts the
Mutationscope profile. - Confirms PIM activation age is within policy.
- Reads the input CSV and validates each row against the current inventory.
- Writes a pre-flight journal (CSV + JSON) to the evidence directory.
- Mutates one agent at a time, with
-WhatIfhonoured at every step. - Captures the resulting
directoryAuditscorrelation ID and signs the final manifest.
function Set-Agt226BulkSponsorReassignment {
[CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')]
[OutputType([pscustomobject])]
param(
[Parameter(Mandatory)]
[ValidateScript({ Test-Path -LiteralPath $_ -PathType Leaf })]
[string]$InputCsvPath,
[Parameter(Mandatory)]
[string]$EvidenceDirectory,
[Parameter()]
[string]$ChangeTicketId,
[Parameter()]
[int]$MaxPimAgeMinutes = 240
)
if (-not (Get-Variable -Name Agt226Session -Scope Script -ErrorAction SilentlyContinue)) {
throw "Initialize-Agt226Session -ScopeProfile Mutation must be called first."
}
if ($script:Agt226Session.ScopeProfile -ne 'Mutation') {
throw "Active session is ReadOnly. Re-initialize with -ScopeProfile Mutation."
}
if (-not $ChangeTicketId) {
throw "ChangeTicketId is required — no untracked sponsor mutations permitted."
}
Test-Agt226GraphScopes -Profile Mutation | Out-Null
if (-not (Test-Path -LiteralPath $EvidenceDirectory)) {
New-Item -ItemType Directory -Path $EvidenceDirectory -Force | Out-Null
}
$rows = Import-Csv -LiteralPath $InputCsvPath
$required = @('AgentObjectId','NewSponsorObjectId','Justification')
foreach ($col in $required) {
if ($col -notin $rows[0].PSObject.Properties.Name) {
throw "Input CSV is missing required column '$col'."
}
}
$runId = [guid]::NewGuid().Guid
$journal = Join-Path $EvidenceDirectory "agt226-reassignment-$runId.csv"
$manifest = Join-Path $EvidenceDirectory "agt226-reassignment-$runId.manifest.json"
$now = (Get-Date).ToUniversalTime()
$results = @()
foreach ($row in $rows) {
$agent = Invoke-Agt226WithThrottle -ScriptBlock {
Get-MgServicePrincipal -ServicePrincipalId $row.AgentObjectId
}
$newSponsor = Invoke-Agt226WithThrottle -ScriptBlock {
Get-MgUser -UserId $row.NewSponsorObjectId
}
$action = "Reassign sponsor for agent $($agent.DisplayName) to $($newSponsor.UserPrincipalName)"
$auditRef = $null
$status = 'Pending'
$reason = $null
if ($PSCmdlet.ShouldProcess($agent.DisplayName, $action)) {
try {
$body = @{
'@odata.id' = "https://graph.microsoft.com/v1.0/users/$($newSponsor.Id)"
}
Invoke-Agt226WithThrottle -ScriptBlock {
Invoke-MgGraphRequest -Method POST `
-Uri "https://graph.microsoft.com/beta/servicePrincipals/$($agent.Id)/sponsors/`$ref" `
-Body ($body | ConvertTo-Json) `
-ContentType 'application/json'
} | Out-Null
$status = 'Clean'
# Capture the directoryAudits correlation row written by Graph
Start-Sleep -Seconds 5
$audit = Invoke-Agt226WithThrottle -ScriptBlock {
Get-MgAuditLogDirectoryAudit -Filter @"
activityDisplayName eq 'Add sponsor to service principal' and targetResources/any(t:t/id eq '$($agent.Id)')
"@ -Top 1
}
$auditRef = $audit.Id
} catch {
$status = 'Error'
$reason = $_.Exception.Message
}
}
$results += [pscustomobject]@{
RunId = $runId
ChangeTicketId = $ChangeTicketId
AgentObjectId = $row.AgentObjectId
AgentDisplayName = $agent.DisplayName
NewSponsorObjectId = $row.NewSponsorObjectId
NewSponsorUpn = $newSponsor.UserPrincipalName
Justification = $row.Justification
Status = $status
Reason = $reason
DirectoryAuditId = $auditRef
Timestamp = (Get-Date).ToUniversalTime()
}
}
$results | Export-Csv -LiteralPath $journal -NoTypeInformation -Encoding UTF8
$manifestObj = [pscustomobject]@{
control_id = '2.26'
run_id = $runId
change_ticket_id = $ChangeTicketId
operator = (Get-MgContext).Account
started_at = $now
completed_at = (Get-Date).ToUniversalTime()
rows_total = $results.Count
rows_clean = ($results | Where-Object Status -eq 'Clean').Count
rows_error = ($results | Where-Object Status -eq 'Error').Count
evidence_artifacts = @($journal)
sha256 = (Get-FileHash -LiteralPath $journal -Algorithm SHA256).Hash
}
$manifestObj | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $manifest -Encoding UTF8
return [pscustomobject]@{
ControlId = '2.26'
Criterion = 'BulkSponsorReassignment'
RunId = $runId
JournalPath = $journal
ManifestPath = $manifest
RowsTotal = $results.Count
RowsClean = $manifestObj.rows_clean
RowsError = $manifestObj.rows_error
Status = if ($manifestObj.rows_error -eq 0) { 'Clean' } else { 'Anomaly' }
Timestamp = (Get-Date).ToUniversalTime()
}
}
Operator discipline.
ChangeTicketIdis mandatory and is recorded in the manifest. Operators must not run the helper outside an approved change window.-WhatIfshould be used on every dry run before the real mutation pass.
9. Evidence pack export (Export-Agt226EvidencePack)
The evidence pack is the JSON+manifest payload consumed by control 3.1 (canonical inventory) and control 1.7 (audit lodging). The helper concatenates outputs from §§3–7 and §10, emits a single JSON file, and computes a SHA-256 manifest.
function Export-Agt226EvidencePack {
[CmdletBinding()]
[OutputType([pscustomobject])]
param(
[Parameter(Mandatory)]
[string]$EvidenceDirectory,
[Parameter()]
[ValidateSet('Zone1','Zone2','Zone3','All')]
[string]$Zone = 'All'
)
if (-not (Test-Path -LiteralPath $EvidenceDirectory)) {
New-Item -ItemType Directory -Path $EvidenceDirectory -Force | Out-Null
}
$runId = [guid]::NewGuid().Guid
$now = (Get-Date).ToUniversalTime()
$criteria = [ordered]@{
AgentSponsorInventory = Get-Agt226AgentSponsorInventory -Zone $Zone
OrphanedAgent = Get-Agt226OrphanedAgent -Zone $Zone
AgentAccessPackageAssignment = Get-Agt226AgentAccessPackageAssignment -Zone $Zone
LifecycleWorkflowExecution = Get-Agt226LifecycleWorkflowExecution
AccessReviewStatus = Get-Agt226AccessReviewStatus
SiemForwarding = Test-Agt226SiemForwarding
}
$payload = [pscustomobject]@{
control_id = '2.26'
run_id = $runId
run_timestamp = $now
zone = $Zone
namespace = 'fsi-agentgov.entra-agentid'
tenant_id = (Get-MgContext).TenantId
cloud_profile = $script:Agt226Session.CloudProfile
preview_gating = $script:Agt226Session.PreviewGating
criteria = $criteria
}
$jsonPath = Join-Path $EvidenceDirectory "agt226-evidence-$runId.json"
$payload | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $jsonPath -Encoding UTF8
$hash = (Get-FileHash -LiteralPath $jsonPath -Algorithm SHA256).Hash
$manifest = [pscustomobject]@{
control_id = '2.26'
run_id = $runId
run_timestamp = $now
evidence_artifacts = @(@{ path = $jsonPath; sha256 = $hash })
criterion_summary = $criteria.Keys | ForEach-Object {
$rows = $criteria[$_]
$statuses = if ($rows -is [System.Collections.IEnumerable] -and -not ($rows -is [string])) {
($rows | ForEach-Object { $_.Status }) | Group-Object | ForEach-Object {
@{ status = $_.Name; count = $_.Count }
}
} else {
@(@{ status = $rows.Status; count = 1 })
}
@{ criterion = $_; statuses = $statuses }
}
}
$manifestPath = Join-Path $EvidenceDirectory "agt226-evidence-$runId.manifest.json"
$manifest | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $manifestPath -Encoding UTF8
return [pscustomobject]@{
ControlId = '2.26'
Criterion = 'EvidencePackExport'
RunId = $runId
JsonPath = $jsonPath
ManifestPath = $manifestPath
Sha256 = $hash
Status = 'Clean'
Timestamp = $now
}
}
9.1 Evidence JSON schema
{
"control_id": "2.26",
"run_id": "uuid",
"run_timestamp": "ISO-8601 UTC",
"zone": "Zone1|Zone2|Zone3|All",
"namespace": "fsi-agentgov.entra-agentid",
"tenant_id": "guid",
"cloud_profile": "Commercial",
"preview_gating": "Clean|NotApplicable",
"criteria": {
"AgentSponsorInventory": [ /* rows */ ],
"OrphanedAgent": [ /* rows */ ],
"AgentAccessPackageAssignment": [ /* rows */ ],
"LifecycleWorkflowExecution": [ /* rows */ ],
"AccessReviewStatus": [ /* rows */ ],
"SiemForwarding": { /* single row */ }
}
}
10. SIEM forwarding test (Test-Agt226SiemForwarding)
This helper verifies the plumbing that ships Entra audit and sign-in logs to the downstream SIEM. It does not attempt server-side filtering or correlation — those responsibilities belong to control 1.7 and the SIEM rule pack. The output establishes that:
- A diagnostic setting on the Entra tenant exists.
- The setting forwards
AuditLogsandSignInLogs(minimum) to either Log Analytics or an Event Hub. - A recent agent-related event has been emitted within the last 24 hours so operators have something to correlate against.
function Test-Agt226SiemForwarding {
[CmdletBinding()]
[OutputType([pscustomobject])]
param(
[Parameter()]
[int]$RecentEventLookbackHours = 24
)
$now = (Get-Date).ToUniversalTime()
# Diagnostic settings on the Entra tenant resource
try {
$diagSettings = Invoke-Agt226WithThrottle -ScriptBlock {
Invoke-MgGraphRequest -Method GET `
-Uri 'https://graph.microsoft.com/beta/auditLogs/diagnosticSettings'
}
} catch {
return [pscustomobject]@{
ControlId = '2.26'
Criterion = 'SiemForwarding'
Status = 'Anomaly'
Reason = "Failed to read diagnostic settings: $($_.Exception.Message)"
Timestamp = $now
}
}
$settings = @($diagSettings.value)
$auditFwd = $settings | Where-Object {
$_.logs | Where-Object { $_.category -eq 'AuditLogs' -and $_.enabled }
}
$signinFwd = $settings | Where-Object {
$_.logs | Where-Object { $_.category -eq 'SignInLogs' -and $_.enabled }
}
$missing = @()
if (-not $auditFwd) { $missing += 'AuditLogs' }
if (-not $signinFwd) { $missing += 'SignInLogs' }
# Sample one recent agent-touching event so the SIEM team has a known
# correlation target. We do not filter — we just emit the most recent
# Add/Remove/Update event whose target is a service principal tagged
# AgentIdentity.
$recent = Invoke-Agt226WithThrottle -ScriptBlock {
Get-MgAuditLogDirectoryAudit `
-Filter "category eq 'ApplicationManagement'" `
-Top 50 |
Where-Object {
$_.TargetResources |
Where-Object { $_.ModifiedProperties.NewValue -match 'AgentIdentity' }
} |
Select-Object -First 1
}
$hasRecent = [bool]$recent
$recentAge = if ($hasRecent) {
[math]::Round(($now - $recent.ActivityDateTime.ToUniversalTime()).TotalHours, 1)
} else { $null }
$status = 'Clean'
$reasons = @()
if ($missing) {
$status = 'Anomaly'
$reasons += "Missing diagnostic categories: $($missing -join ',')"
}
if ($hasRecent -and $recentAge -gt $RecentEventLookbackHours) {
if ($status -eq 'Clean') { $status = 'Pending' }
$reasons += "Most recent agent-related directory audit event is $recentAge hours old"
}
return [pscustomobject]@{
ControlId = '2.26'
Criterion = 'SiemForwarding'
DiagnosticSettingsCount = $settings.Count
AuditLogsForwarded = [bool]$auditFwd
SignInLogsForwarded = [bool]$signinFwd
Destinations = (
$settings |
ForEach-Object {
@($_.workspaceId, $_.eventHubAuthorizationRuleId, $_.storageAccountId) |
Where-Object { $_ }
} |
Sort-Object -Unique
) -join ';'
EntraNativeRetentionDays = 30
SiemRetentionGuidance = 'See control 1.7 — FINRA 4511 requires 6-year retention; Entra native retention is 30 days, so SIEM is the system of record'
MostRecentEventId = if ($hasRecent) { $recent.Id } else { $null }
MostRecentEventAgeHours = $recentAge
Status = $status
Reason = ($reasons -join '; ')
Timestamp = $now
}
}
Filtering boundary. Operators who try to filter audit logs at the Graph layer will encounter rate-limit and field-availability gaps. Filtering, deduplication, and alerting belong in the SIEM rule pack documented under control 1.7. The PowerShell helper only confirms the wire is up and a known-good event exists for correlation.
11. Throttle and retry helper (Invoke-Agt226WithThrottle)
Microsoft Graph applies per-tenant throttling on AgentIdentity and entitlement-management endpoints that is more aggressive than the documented 2,000 requests / 20-second baseline. This wrapper implements exponential backoff with jitter and explicit Retry-After honouring, and re-raises after the configured maximum attempts.
function Invoke-Agt226WithThrottle {
[CmdletBinding()]
[OutputType([object])]
param(
[Parameter(Mandatory)]
[scriptblock]$ScriptBlock,
[Parameter()]
[int]$MaxAttempts = 6,
[Parameter()]
[int]$BaseDelaySeconds = 2,
[Parameter()]
[int]$MaxDelaySeconds = 60
)
$attempt = 0
while ($true) {
$attempt++
try {
return & $ScriptBlock
} catch {
$ex = $_.Exception
$statusCode = $null
$retryAfter = $null
if ($ex.PSObject.Properties.Name -contains 'Response' -and $ex.Response) {
$statusCode = [int]$ex.Response.StatusCode
if ($ex.Response.Headers['Retry-After']) {
$retryAfter = [int]$ex.Response.Headers['Retry-After']
}
} elseif ($ex.Message -match '\b(429|503|504)\b') {
$statusCode = [int]($Matches[1])
}
$isThrottle = $statusCode -in @(429, 503, 504)
if (-not $isThrottle -or $attempt -ge $MaxAttempts) {
throw
}
$delay = if ($retryAfter) {
[math]::Min($retryAfter, $MaxDelaySeconds)
} else {
$exp = [math]::Pow(2, $attempt - 1) * $BaseDelaySeconds
$jitter = Get-Random -Minimum 0 -Maximum ($BaseDelaySeconds)
[math]::Min($exp + $jitter, $MaxDelaySeconds)
}
Write-Verbose "Throttled (HTTP $statusCode) on attempt $attempt; sleeping $delay seconds."
Start-Sleep -Seconds $delay
}
}
}
11.1 Page-count assertion pattern
For paginated calls, callers should assert @odata.count matches the materialized result count. The pattern below is used internally by §3 and §5 to refuse silent truncation.
function Invoke-Agt226PagedQuery {
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$Uri
)
$first = Invoke-Agt226WithThrottle -ScriptBlock {
Invoke-MgGraphRequest -Method GET -Uri "$Uri`?`$count=true" -Headers @{ ConsistencyLevel = 'eventual' }
}
$expected = $first.'@odata.count'
$items = @($first.value)
$next = $first.'@odata.nextLink'
while ($next) {
$page = Invoke-Agt226WithThrottle -ScriptBlock {
Invoke-MgGraphRequest -Method GET -Uri $next
}
$items += $page.value
$next = $page.'@odata.nextLink'
}
if ($expected -and $items.Count -ne $expected) {
throw "Paged query truncation detected: expected $expected, materialized $($items.Count)."
}
return $items
}
12. Cross-control invocation chains
Control 2.26 is a producer for several downstream controls and a consumer of upstream sponsor data. The orchestration patterns below illustrate the canonical chains.
12.1 Sponsor data feed from control 1.2
Control 1.2 (Agent Registry & Integrated Apps Management) is the upstream source of agent registration metadata. The 2.26 sponsor inventory should be reconciled against the 1.2 registry on every attestation cycle.
# Pseudocode — actual cmdlet names live in control 1.2 playbook
$registry = Get-Agt12AgentRegistryInventory # from control 1.2
$sponsors = Get-Agt226AgentSponsorInventory # this file
$reconciliation = $registry | ForEach-Object {
$row = $_
$match = $sponsors | Where-Object { $_.AgentObjectId -eq $row.AgentObjectId } | Select-Object -First 1
[pscustomobject]@{
AgentObjectId = $row.AgentObjectId
InRegistry = $true
InSponsorInventory = [bool]$match
SponsorCount = if ($match) { $match.SponsorCount } else { 0 }
Status = if ($match -and $match.SponsorCount -gt 0) { 'Clean' } else { 'Anomaly' }
}
}
Anomalies in this reconciliation are routed to control 3.6 for remediation.
12.2 Orphan detection feed to control 3.6
$orphans = Get-Agt226OrphanedAgent -GraceWindowMinutes 30
$orphans |
Where-Object { $_.OrphanState -eq 'Orphaned' } |
Export-Csv -NoTypeInformation -Path 'C:\evidence\agt36-input-orphans.csv'
# Control 3.6 picks this CSV up on its scheduled run.
12.3 Audit feed to control 1.7
$siem = Test-Agt226SiemForwarding
if ($siem.Status -ne 'Clean') {
# Control 1.7 owns remediation of audit forwarding gaps.
Write-Warning "Routing SIEM forwarding gap to control 1.7: $($siem.Reason)"
}
12.4 Inventory feed to control 3.1
The 2.26 evidence pack augments the 3.1 canonical inventory with sponsor and access-package columns.
$pack = Export-Agt226EvidencePack -EvidenceDirectory 'C:\evidence\agt226' -Zone All
# Control 3.1 ingests $pack.JsonPath and joins on AgentObjectId.
12.5 Composite daily run
function Invoke-Agt226DailyRun {
[CmdletBinding()]
param([Parameter(Mandatory)][string]$EvidenceRoot)
Initialize-Agt226Session -ScopeProfile ReadOnly | Out-Null
$today = (Get-Date -Format 'yyyy-MM-dd')
$dir = Join-Path $EvidenceRoot $today
New-Item -ItemType Directory -Path $dir -Force | Out-Null
$pack = Export-Agt226EvidencePack -EvidenceDirectory $dir -Zone All
[pscustomobject]@{
ControlId = '2.26'
RunDate = $today
EvidencePack = $pack.JsonPath
ManifestSha256 = $pack.Sha256
Status = $pack.Status
}
}
13. Reconciliation and attestation pack
The attestation pack is the artifact lodged with the records system on a quarterly cadence. It is built by composing the §9 evidence pack with cross-control reconciliation outputs and an attestation summary table.
function New-Agt226AttestationPack {
[CmdletBinding()]
[OutputType([pscustomobject])]
param(
[Parameter(Mandatory)]
[string]$EvidenceDirectory,
[Parameter(Mandatory)]
[ValidateSet('Q1','Q2','Q3','Q4')]
[string]$Quarter,
[Parameter(Mandatory)]
[int]$Year,
[Parameter(Mandatory)]
[string]$AttestingOperator
)
$session = Initialize-Agt226Session -ScopeProfile ReadOnly
$pack = Export-Agt226EvidencePack -EvidenceDirectory $EvidenceDirectory -Zone All
# Per-criterion roll-up
$rollup = @('AgentSponsorInventory','OrphanedAgent','AgentAccessPackageAssignment',
'LifecycleWorkflowExecution','AccessReviewStatus','SiemForwarding') |
ForEach-Object {
$crit = $_
$rows = (Get-Content -LiteralPath $pack.JsonPath -Raw | ConvertFrom-Json).criteria.$crit
$items = if ($rows -is [System.Array]) { $rows } else { @($rows) }
$clean = ($items | Where-Object Status -eq 'Clean').Count
$anomaly = ($items | Where-Object Status -eq 'Anomaly').Count
$pending = ($items | Where-Object Status -eq 'Pending').Count
$na = ($items | Where-Object Status -eq 'NotApplicable').Count
[pscustomobject]@{
Criterion = $crit
Total = $items.Count
Clean = $clean
Anomaly = $anomaly
Pending = $pending
NotApplicable = $na
OverallStatus = if ($anomaly -gt 0) { 'Anomaly' }
elseif ($pending -gt 0) { 'Pending' }
elseif ($clean -gt 0) { 'Clean' }
else { 'NotApplicable' }
}
}
$attestation = [pscustomobject]@{
control_id = '2.26'
attestation_period = "$Year-$Quarter"
attesting_operator = $AttestingOperator
tenant_id = $session.TenantId
cloud_profile = $session.CloudProfile
preview_gating = $session.PreviewGating
evidence_pack_sha256 = $pack.Sha256
evidence_pack_path = $pack.JsonPath
criterion_rollup = $rollup
regulatory_anchors = @(
'FINRA 4511 (6-year recordkeeping)',
'SEC 17a-4 (records preservation)',
'OCC 2013-29 / SR 11-7 (model risk)',
'GLBA Safeguards Rule (access review)'
)
generated_at = (Get-Date).ToUniversalTime()
}
$outPath = Join-Path $EvidenceDirectory "agt226-attestation-$Year-$Quarter.json"
$attestation | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $outPath -Encoding UTF8
$hash = (Get-FileHash -LiteralPath $outPath -Algorithm SHA256).Hash
return [pscustomobject]@{
ControlId = '2.26'
AttestationPath = $outPath
AttestationSha256 = $hash
OverallStatus = if ($rollup.OverallStatus -contains 'Anomaly') { 'Anomaly' }
elseif ($rollup.OverallStatus -contains 'Pending') { 'Pending' }
else { 'Clean' }
Timestamp = (Get-Date).ToUniversalTime()
}
}
13.1 Lodging instructions
- Run
New-Agt226AttestationPackagainst the production tenant on the first business day of the quarter. - Validate the SHA-256 in the operator's runbook before lodging.
- Submit the JSON file plus the SHA to the Purview Compliance Admin for inclusion in the records system.
- Retain locally for 6 years per FINRA 4511 (the SIEM is the primary system of record; this attestation pack is a derived artifact).
14. Validation, anti-patterns, and operating cadence
14.1 Validation harness
function Test-Agt226PlaybookHealth {
[CmdletBinding()]
param()
$checks = @(
@{ Name = 'ShellEdition'; Test = { Assert-Agt226Shell; $true } }
@{ Name = 'ModulesPinned'; Test = { (Install-Agt226ModuleBaseline).Status -eq 'Clean' } }
@{ Name = 'CloudProfile'; Test = { (Resolve-Agt226CloudProfile).Supported } }
@{ Name = 'GraphScopes'; Test = { (Test-Agt226GraphScopes -Profile ReadOnly).Status -eq 'Clean' } }
@{ Name = 'PreviewGating'; Test = { (Test-Agt226PreviewGating).Status -in @('Clean','NotApplicable') } }
)
foreach ($c in $checks) {
$ok = $false
try { $ok = & $c.Test } catch { $ok = $false }
[pscustomobject]@{
Check = $c.Name
Passed = $ok
}
}
}
14.2 Anti-patterns table
| # | Anti-pattern | Why it fails | Sanctioned alternative |
|---|---|---|---|
| 14.1 | Calling Update-MgServicePrincipal to swap a sponsor reference directly |
Bypasses the §8 audit trail and the lifecycle workflow correlation | Set-Agt226BulkSponsorReassignment |
| 14.2 | Treating an empty Get-MgServicePrincipal result as Clean |
Preview gating may be unsatisfied; sovereign cloud may be unsupported | Always check Status='NotApplicable' and the Reason field |
| 14.3 | Filtering audit logs in PowerShell to find sponsor mutations | Graph audit query surface is rate-limited and field-incomplete | Forward to SIEM via §10; query the SIEM |
| 14.4 | Running helpers under PowerShell 5.1 because "it imports the modules fine" | Microsoft.Graph 2.x assemblies bind incorrectly under Desktop edition; calls return empty | Assert-Agt226Shell blocks Desktop edition |
| 14.5 | Granting EntitlementManagement.ReadWrite.All to a long-lived service principal for unattended runs |
Mutation path requires interactive operator and a change ticket | Use ReadOnly scope for unattended runs; mutation is delegated only |
| 14.6 | Using long-form role names (Global Administrator) in evidence |
Audit trails and runbooks diverge from canonical catalog | Use canonical short names from docs/reference/role-catalog.md |
| 14.7 | Ignoring PendingLifecycleTransfer rows because "the workflow will fix it" |
Some workflow runs fail; Pending rows aging past 24 hours indicate a real problem | Aging rule lives in control 3.6; do not drop the rows |
| 14.8 | Hardcoding tenant IDs in helpers | Breaks multi-tenant runbooks | Pass -TenantId to Initialize-Agt226Session |
| 14.9 | Skipping the @odata.count assertion on paginated queries |
Throttled mid-pagination calls silently truncate | Use Invoke-Agt226PagedQuery from §11.1 |
| 14.10 | Assuming sovereign clouds will reach parity "soon" and pre-deploying code paths | Code paths drift; helpers degrade silently | Keep early-exit in §2; revisit only when Microsoft announces sovereign GA |
14.3 Operating cadence
| Cadence | Activity | Owner | Helper |
|---|---|---|---|
| Daily | Run Invoke-Agt226DailyRun against production |
Entra Agent ID Admin | §12.5 |
| Daily | Review OrphanedAgent rows aging past PendingLifecycleTransfer grace |
Entra Identity Governance Admin | §4 + control 3.6 |
| Weekly | Reconcile sponsor inventory against control 1.2 registry | Entra Identity Governance Admin | §12.1 |
| Weekly | Test-Agt226SiemForwarding sanity check | Entra Security Admin | §10 |
| Monthly | Review access package assignments expiring within 60 days | Entra Agent ID Admin | §5 |
| Quarterly | Run New-Agt226AttestationPack and lodge with records system |
AI Administrator + Purview Compliance Admin | §13 |
| Quarterly | Review anti-patterns table against actual operator behaviour | Entra Identity Governance Admin | §14.2 |
| Annually | Re-test sovereign cloud availability and remove early-exit if Microsoft has announced GA | Entra Identity Governance Admin | §2 |
14.4 Hedged language reminder
When operators document findings produced by these helpers, use only the hedged phrasing required by the framework:
- ✅ "Supports compliance with FINRA 4511 by surfacing sponsor accountability per agent identity."
- ✅ "Helps meet OCC 2013-29 model risk expectations through documented sponsor and access review chains."
- ❌ "Ensures compliance with..." — implies a legal guarantee.
- ❌ "Eliminates orphaned agents" — overclaims.
- ❌ "Will prevent unauthorized sponsor changes" — overclaims.
Implementation caveats to retain in narrative reports:
"Implementation requires both an active Microsoft 365 Copilot license and Frontier program enrollment. Organizations should verify preview gating before relying on inventory completeness. Sovereign cloud tenants must apply the documented compensating control until preview parity is announced."
Cross-references
Control specification
Companion playbooks for this control
Shared playbook baseline
Related controls referenced in this playbook
- Control 1.2 — Agent Registry & Integrated Apps Management — sponsor data feed
- Control 1.7 — Comprehensive Audit Logging & Compliance — SIEM forwarding and 6-year retention
- Control 3.1 — Agent Inventory & Metadata Management — canonical inventory consumer
- Control 3.6 — Orphaned Agent Detection & Remediation — orphan remediation owner
Reference
- Role catalog — canonical role names
- Regulatory mappings — FINRA, SEC, OCC, GLBA anchors
Updated: April 2026 Version: 1.0 UI Verification Status: Verified against Entra admin centre (April 2026 preview). Re-verify when Microsoft announces Entra Agent ID GA or sovereign cloud parity.