public/maester/entra/Test-MtEntitlementManagementDeletedGroups.ps1

<#
.SYNOPSIS
    Checks if Entra ID Governance access packages or catalogs reference deleted groups

.DESCRIPTION
    MT.1107 - Access packages and catalogs should not reference deleted groups

    This test identifies access packages and catalogs in Microsoft Entra ID Governance
    that reference Entra ID groups which have been deleted. Deleted group references can cause:
    - Unexpected access provisioning failures
    - Configuration inconsistencies
    - Approval workflow issues
    - Compliance and audit concerns

    The test performs comprehensive checks across:
    - Access package resource assignments (groups assigned as resources)
    - Access package assignment policies (groups configured as approvers)
    - Access package catalog resources (groups registered in catalogs)

    For deleted groups still in the recycle bin, the test retrieves the actual group name
    to provide clear identification of which groups need attention.

    Learn more:
    https://maester.dev/docs/tests/MT.1107

.EXAMPLE
    Test-MtEntitlementManagementDeletedGroups

    Returns $true if all access packages and catalogs reference only active groups

.LINK
    https://maester.dev/docs/commands/Test-MtEntitlementManagementDeletedGroups
#>


function Test-MtEntitlementManagementDeletedGroups {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Groups is the resource type being tested')]
    [CmdletBinding()]
    [OutputType([bool])]
    param()

    try {
        # Get all access packages
        $accessPackages = Invoke-MtGraphRequest -RelativeUri "identityGovernance/entitlementManagement/accessPackages" -ApiVersion beta

        # Check if access packages exist
        $packages = @()
        if ($accessPackages -is [Array]) {
            $packages = $accessPackages
        } elseif ($null -ne $accessPackages.value) {
            $packages = $accessPackages.value
        } elseif ($null -ne $accessPackages) {
            $packages = @($accessPackages)
        }

        if ($packages.Count -eq 0) {
            $testResult = "✅ No access packages found in the tenant."
            Add-MtTestResultDetail -Result $testResult
            return $true
        }

        $deletedGroupsFound = @()

        # Check each access package for deleted groups
        foreach ($package in $packages) {
            $packageId = if ($package.id) { $package.id } else { $package.PSObject.Properties['id'].Value }

            if ([string]::IsNullOrEmpty($packageId)) {
                Write-Verbose "Skipping package without ID: $($package.displayName)"
                continue
            }

            $packageName = if ($package.displayName) { $package.displayName } else { $package.PSObject.Properties['displayName'].Value }
            Write-Verbose "Checking access package: $packageName (ID: $packageId)"

            # Get access package assignment policies
            try {
                $policies = Invoke-MtGraphRequest -RelativeUri "identityGovernance/entitlementManagement/accessPackages/$packageId/assignmentPolicies" -ApiVersion beta

                $policyArray = @()
                if ($policies -is [Array]) {
                    $policyArray = $policies
                } elseif ($null -ne $policies.value) {
                    $policyArray = $policies.value
                } elseif ($null -ne $policies) {
                    $policyArray = @($policies)
                }

                foreach ($policy in $policyArray) {
                    if ($policy.requestApprovalSettings) {
                        foreach ($stage in $policy.requestApprovalSettings.approvalStages) {
                            if ($stage.primaryApprovers) {
                                foreach ($approver in $stage.primaryApprovers) {
                                    if ($approver.'@odata.type' -eq '#microsoft.graph.groupMembers') {
                                        $groupId = $approver.groupId

                                        # Try to get the group
                                        try {
                                            $group = Invoke-MtGraphRequest -RelativeUri "groups/$groupId" -ApiVersion beta -ErrorAction Stop
                                            if ($null -eq $group -or $null -eq $group.id) {
                                                $deletedGroupsFound += [PSCustomObject]@{
                                                    Type = "Access Package"
                                                    Name = $packageName
                                                    Id = $packageId
                                                    DeletedGroupId = $groupId
                                                    Context = "Approval Stage Primary Approver"
                                                }
                                            }
                                        } catch {
                                            $deletedGroupsFound += [PSCustomObject]@{
                                                Type = "Access Package"
                                                Name = $packageName
                                                Id = $packageId
                                                DeletedGroupId = $groupId
                                                Context = "Approval Stage Primary Approver"
                                            }
                                        }
                                    }
                                }
                            }
                        }
                    }
                }
            } catch {
                Write-Verbose "Could not retrieve assignment policies for access package: $packageName"
            }

            # Get access package resources (groups assigned through the package)
            try {
                $resources = Invoke-MtGraphRequest -RelativeUri "identityGovernance/entitlementManagement/accessPackages/$packageId/accessPackageResourceRoleScopes?`$expand=accessPackageResourceScope" -ApiVersion beta

                $resourceArray = @()
                if ($resources -is [Array]) {
                    $resourceArray = $resources
                } elseif ($null -ne $resources.value) {
                    $resourceArray = $resources.value
                } elseif ($null -ne $resources) {
                    $resourceArray = @($resources)
                }

                foreach ($resource in $resourceArray) {
                    $resourceScope = $resource.accessPackageResourceScope
                    $resourceType = if ($resourceScope.originSystem) { $resourceScope.originSystem } else { $resourceScope.PSObject.Properties['originSystem'].Value }
                    $groupId = if ($resourceScope.originId) { $resourceScope.originId } else { $resourceScope.PSObject.Properties['originId'].Value }
                    $resourceDisplayName = if ($resourceScope.displayName) { $resourceScope.displayName } else { $resourceScope.PSObject.Properties['displayName'].Value }

                    if ($resourceType -eq 'AadGroup' -and $groupId) {
                        $groupStillExists = $false
                        $actualGroupName = $resourceDisplayName

                        try {
                            $group = Invoke-MtGraphRequest -RelativeUri "groups/$groupId" -ApiVersion beta -ErrorAction Stop
                            if ($group -and $group.id) {
                                $groupStillExists = $true
                            }
                        } catch {
                            # Try to get from deleted items
                            try {
                                $deletedGroup = Invoke-MtGraphRequest -RelativeUri "directory/deletedItems/$groupId" -ApiVersion beta -ErrorAction Stop
                                if ($deletedGroup -and $deletedGroup.displayName) {
                                    $actualGroupName = $deletedGroup.displayName
                                }
                            } catch {
                                Write-Verbose "Could not retrieve deleted group name for $groupId"
                            }
                        }

                        if (-not $groupStillExists) {
                            $deletedGroupsFound += [PSCustomObject]@{
                                Type = "Access Package"
                                Name = $packageName
                                Id = $packageId
                                DeletedGroupId = $groupId
                                Context = "Resource Assignment"
                                ResourceDisplayName = $actualGroupName
                            }
                        }
                    }
                }
            } catch {
                Write-Verbose "Could not retrieve resources for access package: $packageName"
            }
        }

        # Check access package catalogs for deleted groups
        try {
            $catalogs = Invoke-MtGraphRequest -RelativeUri "identityGovernance/entitlementManagement/accessPackageCatalogs" -ApiVersion beta

            $catalogArray = @()
            if ($catalogs -is [Array]) {
                $catalogArray = $catalogs
            } elseif ($null -ne $catalogs.value) {
                $catalogArray = $catalogs.value
            } elseif ($null -ne $catalogs) {
                $catalogArray = @($catalogs)
            }

            foreach ($catalog in $catalogArray) {
                Write-Verbose "Checking catalog: $($catalog.displayName)"

                try {
                    $resources = Invoke-MtGraphRequest -RelativeUri "identityGovernance/entitlementManagement/accessPackageCatalogs/$($catalog.id)/accessPackageResources" -ApiVersion beta

                    $resourceArray = @()
                    if ($resources -is [Array]) {
                        $resourceArray = $resources
                    } elseif ($null -ne $resources.value) {
                        $resourceArray = $resources.value
                    } elseif ($null -ne $resources) {
                        $resourceArray = @($resources)
                    }

                    foreach ($resource in $resourceArray) {
                        if ($resource.resourceType -eq 'AadGroup' -or $resource.originSystem -eq 'AadGroup') {
                            $groupId = $resource.originId
                            $actualGroupName = $resource.displayName

                            $groupStillExists = $false
                            try {
                                $group = Invoke-MtGraphRequest -RelativeUri "groups/$groupId" -ApiVersion beta -ErrorAction Stop
                                if ($group -and $group.id) {
                                    $groupStillExists = $true
                                }
                            } catch {
                                try {
                                    $deletedGroup = Invoke-MtGraphRequest -RelativeUri "directory/deletedItems/$groupId" -ApiVersion beta -ErrorAction Stop
                                    if ($deletedGroup -and $deletedGroup.displayName) {
                                        $actualGroupName = $deletedGroup.displayName
                                    }
                                } catch {
                                    Write-Verbose "Could not retrieve deleted group name for $groupId in catalog"
                                }
                            }

                            if (-not $groupStillExists) {
                                $deletedGroupsFound += [PSCustomObject]@{
                                    Type = "Catalog"
                                    Name = $catalog.displayName
                                    Id = $catalog.id
                                    DeletedGroupId = $groupId
                                    Context = "Catalog Resource"
                                    ResourceDisplayName = $actualGroupName
                                }
                            }
                        }
                    }
                } catch {
                    Write-Verbose "Could not retrieve resources for catalog: $($catalog.displayName)"
                }
            }
        } catch {
            Write-Verbose "Could not retrieve access package catalogs: $_"
        }

        # Evaluate results
        $result = $deletedGroupsFound.Count -eq 0

        if ($result) {
            $testResult = "✅ All access packages and catalogs reference only active groups."
            Add-MtTestResultDetail -Result $testResult
        } else {
            $accessPackageIssues = $deletedGroupsFound | Where-Object { $_.Type -eq "Access Package" }
            $catalogIssues = $deletedGroupsFound | Where-Object { $_.Type -eq "Catalog" }

            $realIssuesCount = $accessPackageIssues.Count + $catalogIssues.Count

            $testResult = "❌ Found $realIssuesCount reference(s) to deleted groups:`n`n"

            $issuesByGroup = $deletedGroupsFound | Group-Object DeletedGroupId

            foreach ($grouping in $issuesByGroup) {
                $deletedGroupId = $grouping.Name
                $groupDisplayName = ($grouping.Group | Select-Object -First 1).ResourceDisplayName
                if ([string]::IsNullOrEmpty($groupDisplayName)) {
                    $groupDisplayName = "Unknown Group"
                }

                $testResult += "### Deleted Group: **$groupDisplayName**`n"
                $testResult += "Group ID: ``$deletedGroupId```n`n"

                $catalogsForGroup = $grouping.Group | Where-Object { $_.Type -eq "Catalog" }
                $packagesForGroup = $grouping.Group | Where-Object { $_.Type -eq "Access Package" }

                if ($catalogsForGroup.Count -gt 0) {
                    $testResult += "**Referenced in Catalog(s):**`n"
                    foreach ($item in $catalogsForGroup) {
                        $testResult += "- [$($item.Name)](https://portal.azure.com/#view/Microsoft_Azure_ELMAdmin/CatalogBlade/catalogId/$($item.Id))`n"
                    }
                    $testResult += "`n"
                }

                if ($packagesForGroup.Count -gt 0) {
                    $testResult += "**Referenced in Access Package(s):**`n"
                    foreach ($item in $packagesForGroup) {
                        $testResult += "- [$($item.Name)](https://portal.azure.com/#view/Microsoft_Azure_ELMAdmin/EntitlementMenuBlade/~/overview/entitlementId/$($item.Id))`n"
                    }
                    $testResult += "`n"
                }
            }

            $testResult += "---`n**Remediation:** Review and update access packages and catalogs to remove references to deleted groups, or restore the groups if needed.`n"

            Add-MtTestResultDetail -Result $testResult
        }

        return $result

    } catch {
        Write-Error "Error checking access packages and catalogs: $($_.Exception.Message)"
        return $false
    }
}