diff --git a/src/powershell/public/Get-ZtGraphScope.ps1 b/src/powershell/public/Get-ZtGraphScope.ps1 index 1c54a3792..01b016dec 100644 --- a/src/powershell/public/Get-ZtGraphScope.ps1 +++ b/src/powershell/public/Get-ZtGraphScope.ps1 @@ -32,6 +32,7 @@ 'EntitlementManagement.Read.All' 'IdentityRiskEvent.Read.All' 'IdentityRiskyUser.Read.All' + 'LifecycleWorkflows-Workflow.Read.All' 'Policy.Read.All' 'Policy.Read.ConditionalAccess' 'Policy.Read.PermissionGrant' diff --git a/src/powershell/tests/Test-Assessment.61013.md b/src/powershell/tests/Test-Assessment.61013.md new file mode 100644 index 000000000..408d083bd --- /dev/null +++ b/src/powershell/tests/Test-Assessment.61013.md @@ -0,0 +1,18 @@ +Microsoft Entra Agent ID requires every agent identity and every agent identity blueprint to have at least one sponsor — a human user (or supported group) who carries business accountability for the agent's lifecycle: deciding when the agent is no longer needed, requesting access packages on the agent's behalf, approving extensions when access expires, and authorising suspension during incidents. Sponsorship is the entry point for the rest of identity governance: lifecycle workflows route sponsor-leaving notifications to managers and cosponsors, access-package expiry escalations are sent to the sponsor, and entitlement-management approvers rely on the sponsor relationship to validate that an agent's continued access reflects current business need. An agent identity that exists in the tenant without a sponsor is governance-invisible. Sponsorship alone, however, is not enough: the workshop guidance also requires that agent permissions — group memberships, Microsoft Graph and other API permissions — flow through Microsoft Entra entitlement management rather than through direct grants. An access package is the unit of bundled grant; an assignment policy attached to that package decides who may request or be assigned the package, who must approve, how long the resulting assignment lasts, and how the assignment is reviewed for continued business need. When an organisation enables agent workloads but does not author at least one access package whose policy targets agent identities, every permission an agent receives must instead be granted directly — through `appRoleAssignment`, `oauth2PermissionGrant`, group `members/$ref`, or directory-role assignment — outside the entitlement-management control loop. Direct grants have no built-in expiration, no approver, no sponsor-driven extension notification, and no access-review schedule; once made, they persist until an administrator notices and removes them. A threat actor who later compromises the agent — through credential theft, blueprint compromise, or a malicious access-package request that no governance pipeline existed to intercept — operates against an identity whose permissions were never reviewed against current business need, the precise standing-privilege condition that lifecycle workflows, sponsor approvals, and time-bounded access packages are designed to prevent. This check verifies the two foundational conditions together: every agent identity and blueprint has at least one sponsor currently resolvable in the directory, and at least one access package in the tenant has an assignment policy whose `allowedTargetScope` is `allDirectoryAgentIdentities` — the Microsoft Graph value corresponding to the portal's *For users, service principals, and agent identities in your directory* → *All agents* selection. + +**Remediation action** + +- [Administrative relationships in Microsoft Entra Agent ID](https://learn.microsoft.com/en-us/entra/agent-id/identity-platform/agent-owners-sponsors-managers) +- [Governing agent identities](https://learn.microsoft.com/en-us/entra/id-governance/agent-id-governance-overview) +- [Agent identity sponsor tasks in Lifecycle Workflows (Preview)](https://learn.microsoft.com/en-us/entra/id-governance/agent-sponsor-tasks) +- [Add sponsors to an agent identity](https://learn.microsoft.com/en-us/graph/api/agentidentity-post-sponsors?view=graph-rest-1.0) +- [Manage agents in Microsoft Entra ID](https://learn.microsoft.com/en-us/entra/agent-id/manage-agent) +- [Access packages for agent identities](https://learn.microsoft.com/en-us/entra/agent-id/agent-access-packages) +- [Create an access package in entitlement management](https://learn.microsoft.com/en-us/entra/id-governance/entitlement-management-access-package-create) +- [Create an assignment policy via Microsoft Graph](https://learn.microsoft.com/en-us/graph/api/entitlementmanagement-post-assignmentpolicies?view=graph-rest-beta) +- [Delegation and roles in entitlement management](https://learn.microsoft.com/en-us/entra/id-governance/entitlement-management-delegate) +- [Lifecycle Workflow built-in tasks](https://learn.microsoft.com/en-us/entra/id-governance/lifecycle-workflow-tasks) +- [Create a workflow via Microsoft Graph](https://learn.microsoft.com/en-us/graph/api/identitygovernance-lifecycleworkflowscontainer-post-workflows?view=graph-rest-1.0) + + +%TestResult% diff --git a/src/powershell/tests/Test-Assessment.61013.ps1 b/src/powershell/tests/Test-Assessment.61013.ps1 new file mode 100644 index 000000000..428110e14 --- /dev/null +++ b/src/powershell/tests/Test-Assessment.61013.ps1 @@ -0,0 +1,464 @@ +<# +.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 +}