tests/Test-Assessment.61013.ps1
|
<#
.SYNOPSIS Checks that every agent identity and blueprint has a sponsor, an entitlement-management access package targets agents, and a Lifecycle Workflow contains an agent-sponsor task. .DESCRIPTION This test evaluates three independent sub-conditions of AI_004 identity governance for agents: Sub-condition A — Sponsorship presence: Every agent identity (microsoft.graph.agentIdentity) and every agent identity blueprint (microsoft.graph.agentIdentityBlueprint) has at least one effective sponsor. A group sponsor counts only if it has at least one transitive member. Agent identity blueprint principals are out of scope. Sub-condition B — Entitlement-management channel: At least one access package assignment policy in the tenant has allowedTargetScope set to 'allDirectoryAgentIdentities'. Sub-condition C — Lifecycle-automation pillar: At least one Lifecycle Workflow contains an enabled task whose taskDefinitionId matches one of the three documented agent-identity sponsor tasks (Send email to manager about sponsorship changes, Send email to co-sponsors about sponsor changes, or Transfer agent identity sponsorships to manager). The test passes only when all three sub-conditions pass. Agent identities (Q1) and blueprints (Q2) are read from the exported database. .NOTES Test ID: 61013 Category: AI Authentication & Access Required permissions: AgentIdentity.Read.All, AgentIdentity-Sponsor.Read.All, GroupMember.Read.All, EntitlementManagement.Read.All, LifecycleWorkflows-Workflow.Read.All on Microsoft Graph #> function Test-Assessment-61013 { [ZtTest( Category = 'AI Authentication & Access', ImplementationCost = 'Low', Service = ('Graph'), CompatibleLicense = ('AAD_PREMIUM&AGENT_365'), Pillar = 'AI', RiskLevel = 'Medium', SfiPillar = 'Protect identities and secrets', TenantType = ('Workforce'), TestId = 61013, Title = 'Identity governance for agents — sponsors assigned, entitlement-management channel exists, and lifecycle automation in place', UserImpact = 'Low' )] [CmdletBinding()] param( $Database ) #region Data Collection Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose # Agent-identity sponsor task definition IDs (from spec) $agentSponsorTaskDefinitionIds = @( 'b8c4e1f9-3a7d-4b2e-9c5f-8d6a9b1c2e3f', # Send email to manager about sponsorship changes 'ad3b85cd-75b1-43e7-b4b9-0e52faba3944', # Send email to co-sponsors about sponsor changes 'b8f4c3d5-9e7a-4b1c-8f2d-6a5e8b9c7f4a' # Transfer agent identity sponsorships to manager ) $activity = 'Checking agent identity governance' # Q1: Agent identities from dedicated AgentIdentity table (pre-filtered cast collection with sponsors expanded) $q1QueryError = $null $agentIdentities = @() Write-ZtProgress -Activity $activity -Status 'Getting agent identities with sponsors (Q1)' try { $sqlAgentIdentities = @" SELECT id, agentAppId AS appId, displayName, accountEnabled, to_json(sponsors) as sponsorsJson FROM AgentIdentity ORDER BY displayName "@ $agentIdentityRows = @(Invoke-DatabaseQuery -Database $Database -Sql $sqlAgentIdentities) $agentIdentities = @($agentIdentityRows | ForEach-Object { $sponsorsParsed = if ($_.sponsorsJson -and $_.sponsorsJson -ne 'null') { @($_.sponsorsJson | ConvertFrom-Json) } else { @() } [PSCustomObject]@{ Kind = 'agentIdentity' id = $_.id appId = $_.appId displayName = $_.displayName accountEnabled = $_.accountEnabled sponsors = $sponsorsParsed } }) } catch { $q1QueryError = $_ Write-PSFMessage "Failed to get agent identities: $_" -Tag Test -Level Warning } # Q2: Agent identity blueprints from dedicated AgentIdentityBlueprint table (pre-filtered cast collection with sponsors expanded) $q2QueryError = $null $agentBlueprints = @() Write-ZtProgress -Activity $activity -Status 'Getting agent identity blueprints with sponsors (Q2)' try { $sqlAgentBlueprints = @" SELECT id, appId, displayName, to_json(sponsors) as sponsorsJson FROM AgentIdentityBlueprint ORDER BY displayName "@ $agentBlueprintRows = @(Invoke-DatabaseQuery -Database $Database -Sql $sqlAgentBlueprints) $agentBlueprints = @($agentBlueprintRows | ForEach-Object { $sponsorsParsed = if ($_.sponsorsJson -and $_.sponsorsJson -ne 'null') { @($_.sponsorsJson | ConvertFrom-Json) } else { @() } [PSCustomObject]@{ Kind = 'agentIdentityBlueprint' id = $_.id appId = $_.appId displayName = $_.displayName sponsors = $sponsorsParsed } }) } catch { $q2QueryError = $_ Write-PSFMessage "Failed to get agent identity blueprints: $_" -Tag Test -Level Warning } # Skip: no agent identities or blueprints → Microsoft Entra Agent ID not in use if ($null -eq $q1QueryError -and $null -eq $q2QueryError -and $agentIdentities.Count -eq 0 -and $agentBlueprints.Count -eq 0) { Add-ZtTestResultDetail -SkippedBecause NotApplicable return } # Q3: Transitive member counts for every group used as a sponsor (ConsistencyLevel: eventual) $q3QueryError = $null $groupHasMembers = @{} $sourcesForQ3 = @() if ($null -eq $q1QueryError) { $sourcesForQ3 += $agentIdentities } if ($null -eq $q2QueryError) { $sourcesForQ3 += $agentBlueprints } $uniqueGroupIds = @($sourcesForQ3 | ForEach-Object { $_.sponsors } | Where-Object { $null -ne $_ -and ( $_.'@odata.type' -eq '#microsoft.graph.group' -or ($null -eq $_.'@odata.type' -and $null -ne $_.PSObject.Properties['mailEnabled']) ) } | Select-Object -ExpandProperty id -Unique) Write-ZtProgress -Activity $activity -Status 'Resolving group sponsor member counts (Q3)' if ($uniqueGroupIds.Count -gt 0) { try { $groupCountResults = Invoke-ZtGraphBatchRequest ` -Path 'groups/{0}/transitiveMembers/$count' ` -ArgumentList $uniqueGroupIds ` -Header @{ 'ConsistencyLevel' = 'eventual' } ` -NoPaging ` -Matched ` -ErrorAction SilentlyContinue foreach ($countResult in $groupCountResults) { $gid = $countResult.Argument if ($countResult.Success) { $groupHasMembers[$gid] = ([int]($countResult.Result | Select-Object -First 1) -gt 0) } else { $groupHasMembers[$gid] = $false Write-PSFMessage "Failed to get member count for group ${gid}: $($countResult.Status)" -Tag Test -Level Warning } } } catch { $q3QueryError = $_ Write-PSFMessage "Failed to get group member counts: $_" -Tag Test -Level Warning } } # Q4:Assignment policies targeting agent identities — v1.0, server-side filtered to allDirectoryAgentIdentities, # with $expand=accessPackage to inline the parent package (id, displayName, catalogId) in a single round-trip. # beta endpoints do not currently expose the allowedTargetScope property required # for allDirectoryAgentIdentities filtering; v1.0 is used because it reliably returns # allowedTargetScope (including allDirectoryAgentIdentities) in tenant testing. $q4QueryError = $null $agentTargetingPolicies = @() Write-ZtProgress -Activity $activity -Status 'Getting entitlement management assignment policies (Q4)' try { $agentTargetingPolicies = @(Invoke-ZtGraphRequest ` -RelativeUri 'identityGovernance/entitlementManagement/assignmentPolicies' ` -ApiVersion v1.0 ` -QueryParameters @{ '$select' = 'id,displayName,allowedTargetScope'; '$expand' = 'accessPackage'; '$filter' = "allowedTargetScope eq 'allDirectoryAgentIdentities'" } ` -ErrorAction Stop) } catch { if ($_.Exception.Response.StatusCode -eq 403 -or $_.Exception.Message -like '*403*' -or $_.Exception.Message -like '*Forbidden*' -or $_.Exception.Message -like '*accessDenied*') { Write-PSFMessage 'Skipping test: Entra ID Governance licensing is required for entitlement management assignment policies.' -Tag Test -Level VeryVerbose Add-ZtTestResultDetail -SkippedBecause NotLicensedEntraIDGovernance return } $q4QueryError = $_ Write-PSFMessage "Failed to get assignment policies: $_" -Tag Test -Level Warning } # Q5: Lifecycle workflows list $q5QueryError = $null $lifecycleWorkflows = @() Write-ZtProgress -Activity $activity -Status 'Getting lifecycle workflows (Q5)' try { $lifecycleWorkflows = @(Invoke-ZtGraphRequest ` -RelativeUri 'identityGovernance/lifecycleWorkflows/workflows' ` -ApiVersion beta ` -QueryParameters @{ '$select' = 'id,displayName,category,isEnabled' } ` -ErrorAction Stop) } catch { if ($_.Exception.Response.StatusCode -eq 403 -or $_.Exception.Message -like '*403*' -or $_.Exception.Message -like '*Forbidden*' -or $_.Exception.Message -like '*accessDenied*') { Write-PSFMessage 'Skipping test: Entra ID Governance licensing is required for lifecycle workflows.' -Tag Test -Level VeryVerbose Add-ZtTestResultDetail -SkippedBecause NotLicensedEntraIDGovernance return } $q5QueryError = $_ Write-PSFMessage "Failed to get lifecycle workflows: $_" -Tag Test -Level Warning } # Q6: Full workflow details with tasks (batched, tasks expanded by default) $q6QueryError = $null $workflowDetails = @() if ($lifecycleWorkflows.Count -gt 0) { Write-ZtProgress -Activity $activity -Status 'Getting lifecycle workflow task details (Q6)' try { $workflowIds = @($lifecycleWorkflows | Select-Object -ExpandProperty id) $workflowDetailResults = Invoke-ZtGraphBatchRequest ` -Path 'identityGovernance/lifecycleWorkflows/workflows/{0}' ` -ArgumentList $workflowIds ` -ApiVersion beta ` -NoPaging ` -Matched ` -ErrorAction SilentlyContinue $workflowDetails = @($workflowDetailResults | Where-Object { $_.Success -and $_.Result } | Select-Object -ExpandProperty Result) } catch { $q6QueryError = $_ Write-PSFMessage "Failed to get lifecycle workflow details: $_" -Tag Test -Level Warning } } #endregion Data Collection #region Assessment Logic # Sub-condition A: sponsorship presence # Every agentIdentity and agentIdentityBlueprint must have ≥1 effective sponsor. # A group sponsor counts only if it has ≥1 transitive member. $subConditionAPass = $true $sponsorshipFailures = @() if ($null -ne $q1QueryError -or $null -ne $q2QueryError -or $null -ne $q3QueryError) { $subConditionAPass = $false } else { foreach ($agentObject in @(@($agentIdentities) + @($agentBlueprints))) { $objectKind = $agentObject.Kind $sponsors = @($agentObject.sponsors | Where-Object { $null -ne $_ }) $hasEffectiveSponsor = $false $emptyGroupIds = @() foreach ($sponsor in $sponsors) { # @odata.type may be absent when Graph omits it from expanded sponsor objects. # Fall back to property heuristic: groups always have mailEnabled; users never do. $odataType = $sponsor.'@odata.type' if (-not $odataType) { $odataType = if ($null -ne $sponsor.PSObject.Properties['mailEnabled']) { '#microsoft.graph.group' } else { '#microsoft.graph.user' } } if ($odataType -eq '#microsoft.graph.user') { $hasEffectiveSponsor = $true break } if ($odataType -eq '#microsoft.graph.group') { if ($groupHasMembers.ContainsKey($sponsor.id) -and $groupHasMembers[$sponsor.id]) { $hasEffectiveSponsor = $true break } else { $emptyGroupIds += $sponsor.id } } # Service principals and other non-user/non-group types are not valid effective sponsors } if (-not $hasEffectiveSponsor) { $subConditionAPass = $false $failureReason = if ($sponsors.Count -eq 0) { 'no sponsors assigned' } else { $groupSuffix = if ($emptyGroupIds.Count -gt 0) { " ($($emptyGroupIds -join ', '))" } else { '' } "only empty-group sponsors$groupSuffix" } $sponsorshipFailures += [PSCustomObject]@{ ObjectKind = $objectKind DisplayName = $agentObject.displayName AppId = $agentObject.appId ObjectId = $agentObject.id FailureReason = $failureReason } } } } # Sub-condition B: entitlement-management channel # ≥1 assignment policy with allowedTargetScope == 'allDirectoryAgentIdentities' $subConditionBPass = $false if ($null -eq $q4QueryError) { $subConditionBPass = ($agentTargetingPolicies.Count -ge 1) } # Sub-condition C: lifecycle-automation pillar # ≥1 lifecycle workflow with ≥1 ENABLED task matching a known agent-sponsor taskDefinitionId $subConditionCPass = $false $matchingWorkflowTasks = @() if ($null -eq $q5QueryError -and $null -eq $q6QueryError) { foreach ($workflow in $workflowDetails) { foreach ($task in @($workflow.tasks | Where-Object { $null -ne $_ })) { if ($task.isEnabled -and ($agentSponsorTaskDefinitionIds -contains $task.taskDefinitionId)) { $subConditionCPass = $true $matchingWorkflowTasks += [PSCustomObject]@{ WorkflowDisplayName = $workflow.displayName WorkflowId = $workflow.id WorkflowCategory = $workflow.category WorkflowIsEnabled = $workflow.isEnabled TaskDisplayName = $task.displayName TaskDefinitionId = $task.taskDefinitionId TaskIsEnabled = $task.isEnabled } } } } } $passed = $subConditionAPass -and $subConditionBPass -and $subConditionCPass if ($passed) { $testResultMarkdown = "✅ Every agent identity and agent identity blueprint in the tenant has at least one sponsor assigned, at least one access package has an assignment policy that grants access to agent identities, and at least one Lifecycle Workflow contains at least one enabled agent-identity sponsor task.`n`n%TestResult%" } else { $failingConditions = @() if (-not $subConditionAPass) { $failingConditions += 'sponsorship' } if (-not $subConditionBPass) { $failingConditions += 'entitlement-management channel' } if (-not $subConditionCPass) { $failingConditions += 'lifecycle-automation pillar' } $testResultMarkdown = "❌ One or more agent identities or blueprints have no sponsor assigned, OR no access package in the tenant has an assignment policy targeting agent identities (``allowedTargetScope == 'allDirectoryAgentIdentities'``), OR no Lifecycle Workflow contains an enabled task whose ``taskDefinitionId`` matches one of the three documented agent-identity sponsor tasks. Failed: $($failingConditions -join '; ').`n`n%TestResult%" } #endregion Assessment Logic #region Report Generation $maxTableRows = 10 $agentsPortalLink = 'https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/AllAgents.MenuView/~/allAgentIds' $accessPackagePortalLink = 'https://entra.microsoft.com/#view/Microsoft_AAD_ERM/DashboardBlade/~/AccessPackages' $lifecycleWorkflowPortalLink = 'https://entra.microsoft.com/#view/Microsoft_AAD_LifecycleManagement/CommonMenuBlade/~/workflows' $formatTemplate = @' ## [{0}]({1}) {2} '@ $mdInfo = '' # Section 1: Sponsorship failures (only emitted when sub-condition A fails) $sectionA = '' if (-not $subConditionAPass) { $contentA = if ($null -ne $q1QueryError -or $null -ne $q2QueryError -or $null -ne $q3QueryError) { '❌ Unable to evaluate sponsorship: query failed or permissions are insufficient.' } else { $tableRowsA = '' $displayedA = 0 foreach ($failure in ($sponsorshipFailures | Sort-Object ObjectKind, DisplayName)) { if ($displayedA -ge $maxTableRows) { break } $portalLink = if ($failure.ObjectKind -eq 'agentIdentity') { "https://entra.microsoft.com/#view/Microsoft_AAD_IAM/ManagedAppMenuBlade/~/Overview/objectId/$($failure.ObjectId)/appId/$($failure.AppId)" } else { "https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/~/overview/appId/$($failure.AppId)" } $tableRowsA += "| $(Get-SafeMarkdown $failure.ObjectKind) | [$(Get-SafeMarkdown $failure.DisplayName)]($portalLink) | $($failure.FailureReason) |`n" $displayedA++ } $truncationNoteA = if ($sponsorshipFailures.Count -gt $maxTableRows) { "`n_**Note**: This table is truncated and showing the first $maxTableRows out of $($sponsorshipFailures.Count) total._" } else { '' } "| Object kind | Display name | Failure reason |`n| :--- | :--- | :--- |`n$tableRowsA$truncationNoteA" } $sectionA = $formatTemplate -f 'Agent identities and blueprints without an effective sponsor', $agentsPortalLink, $contentA } # Section 2: Access packages targeting agent identities (always emitted when B is evaluated) $contentB = if ($null -ne $q4QueryError) { '❌ Unable to evaluate entitlement management policies: query failed or permissions are insufficient.' } elseif ($agentTargetingPolicies.Count -gt 0) { $tableRowsB = '' $displayedB = 0 foreach ($policy in $agentTargetingPolicies) { if ($displayedB -ge $maxTableRows) { break } $pkg = $policy.accessPackage $pkgRawName = if ($pkg) { $pkg.displayName } else { $null } $pkgCell = if ($pkgRawName) { Get-SafeMarkdown $pkgRawName } else { '—' } $tableRowsB += "| $pkgCell | $(Get-SafeMarkdown $policy.displayName) | $(Get-SafeMarkdown $policy.allowedTargetScope) |`n" $displayedB++ } $truncationNoteB = if ($agentTargetingPolicies.Count -gt $maxTableRows) { "`n_**Note**: This table is truncated and showing the first $maxTableRows out of $($agentTargetingPolicies.Count) total._" } else { '' } "| Access package display name | Policy display name | Allowed target scope |`n| :--- | :--- | :--- |`n$tableRowsB$truncationNoteB" } else { 'No access package has an agent-targeting policy.' } $sectionB = $formatTemplate -f 'Access packages and policies that grant access to agent identities', $accessPackagePortalLink, $contentB # Section 3: Lifecycle Workflows with agent-identity sponsor tasks (always emitted when C is evaluated) $contentC = if ($null -ne $q5QueryError -or $null -ne $q6QueryError) { '❌ Unable to evaluate lifecycle workflows: query failed or permissions are insufficient.' } elseif ($matchingWorkflowTasks.Count -gt 0) { $tableRowsC = '' $displayedC = 0 foreach ($entry in $matchingWorkflowTasks) { if ($displayedC -ge $maxTableRows) { break } $wfEnabledText = if ($entry.WorkflowIsEnabled) { '✅ Yes' } else { '❌ No' } $taskEnabledText = if ($entry.TaskIsEnabled) { '✅ Yes' } else { '❌ No' } $wfNameEncoded = [System.Uri]::EscapeDataString($entry.WorkflowDisplayName) $wfPortalLink = "https://entra.microsoft.com/#view/Microsoft_AAD_LifecycleManagement/DetailedWorkflowMenuBlade/~/overview/workflowId/$($entry.WorkflowId)/workflowName/$wfNameEncoded" $tableRowsC += "| [$(Get-SafeMarkdown $entry.WorkflowDisplayName)]($wfPortalLink) | $($entry.WorkflowCategory) | $wfEnabledText | $(Get-SafeMarkdown $entry.TaskDisplayName) | $taskEnabledText |`n" $displayedC++ } $truncationNoteC = if ($matchingWorkflowTasks.Count -gt $maxTableRows) { "`n_**Note**: This table is truncated and showing the first $maxTableRows out of $($matchingWorkflowTasks.Count) total._" } else { '' } "| Workflow display name | Workflow category | Workflow enabled | Matching task display name | Task enabled |`n| :--- | :--- | :--- | :--- | :--- |`n$tableRowsC$truncationNoteC" } else { 'No Lifecycle Workflow contains an enabled agent-identity sponsor task.' } $sectionC = $formatTemplate -f 'Lifecycle Workflows containing agent-identity sponsor tasks', $lifecycleWorkflowPortalLink, $contentC $mdInfo = "$sectionA$sectionB$sectionC" $testResultMarkdown = $testResultMarkdown -replace '%TestResult%', $mdInfo $params = @{ TestId = '61013' Title = 'Identity governance for agents — sponsors assigned, entitlement-management channel exists, and lifecycle automation in place' Status = $passed Result = $testResultMarkdown } Add-ZtTestResultDetail @params #endregion Report Generation } |