Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion powershell/Maester.psd1
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,12 @@
'Test-MtXspmCriticalCredsOnDevicesWithNonCriticalAccounts',
'Test-MtXspmPublicRemotelyExploitableHighExposureDevices',
'Test-MtXspmCriticalCredentialsOnNonTpmProtectedDevices',
'Test-MtXspmCriticalCredentialsOnNonCredGuardProtectedDevices'
'Test-MtXspmCriticalCredentialsOnNonCredGuardProtectedDevices',
'Test-MtEntitlementManagementDeletedGroups',
'Test-MtEntitlementManagementInactivePolicies',
'Test-MtEntitlementManagementOrphanedResources',
'Test-MtEntitlementManagementValidApprovers',
'Test-MtEntitlementManagementValidResourceRoles'

# Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export.
CmdletsToExport = @()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
## Description

This test identifies Microsoft Entra ID Governance access packages and catalogs that contain references to deleted Entra ID groups. Deleted group references can cause access provisioning failures, broken approval workflows, and compliance violations.

The test validates:
- Groups assigned as resources in access packages still exist
- Groups configured as approvers in assignment policies are active
- Groups registered in catalogs have not been deleted

For any deleted groups found, the test attempts to retrieve the group name from the recycle bin (`directory/deletedItems`) to help identify which groups need attention.

## Remediation action

**Option 1: Remove Deleted Group References**
1. Navigate to [Entra Admin Center → Identity Governance → Access Packages](https://entra.microsoft.com/#view/Microsoft_AAD_ELM/Dashboard.ReactView)
2. For each affected access package:
- Go to **Resources** and remove the deleted group
- Update assignment policies to remove deleted group approvers
3. For affected catalogs:
- Select the catalog → **Resources**
- Remove the deleted group

**Option 2: Restore Deleted Groups**
1. Navigate to [Entra Admin Center → Identity → Groups → Deleted groups](https://entra.microsoft.com/#blade/Microsoft_AAD_IAM/GroupsManagementMenuBlade/DeletedGroups)
2. Select the deleted group(s) and click **Restore group**
3. Re-run the test to confirm resolution

**Option 3: Replace with Active Groups**
1. Create or identify replacement active groups
2. Add new groups to access packages/catalogs
3. Update assignment policies with new approver groups
4. Remove deleted group references

## Related links

- [Microsoft Entra ID Governance Documentation](https://learn.microsoft.com/entra/id-governance/)
- [Access Packages Overview](https://learn.microsoft.com/entra/id-governance/entitlement-management-access-package-create)
- [Manage Resources in Access Packages](https://learn.microsoft.com/entra/id-governance/entitlement-management-access-package-resources)
- [Access Package Catalogs](https://learn.microsoft.com/entra/id-governance/entitlement-management-catalog-create)
- [Microsoft Graph API - Entitlement Management](https://learn.microsoft.com/graph/api/resources/entitlementmanagement-overview)
Original file line number Diff line number Diff line change
@@ -0,0 +1,310 @@
<#
.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/tests/MT.1107
#>

function Test-MtEntitlementManagementDeletedGroups {
[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
}
}
Loading