public/maester/entra/Test-MtEntitlementManagementOrphanedResources.ps1
|
<# .SYNOPSIS Checks if catalogs contain unused resources without associated access packages .DESCRIPTION MT.1110 - No catalog should contain resources without any associated access packages This test identifies Microsoft Entra ID Governance access package catalogs that contain resources (groups, applications, SharePoint sites) that are not used in any access package. Unused catalog resources can indicate: - Resources added but never configured in packages - Leftover resources from deleted or modified access packages - Configuration drift or incomplete setup - Potential security and governance gaps - Wasted administrative effort maintaining unused resources The test validates that: - All catalog resources are used in at least one access package - Resources are properly linked to package role scopes - No orphaned resources exist in catalogs - Catalog resources serve their intended purpose Learn more: https://maester.dev/docs/tests/MT.1110 .EXAMPLE Test-MtEntitlementManagementOrphanedResources Returns $true if all catalog resources are used in access packages .LINK https://maester.dev/docs/commands/Test-MtEntitlementManagementOrphanedResources #> function Test-MtEntitlementManagementOrphanedResources { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Resources is the resource type being tested')] [CmdletBinding()] [OutputType([bool])] param() try { # Get all access package catalogs $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) } if ($catalogArray.Count -eq 0) { $testResult = "✅ No access package catalogs found in the tenant." Add-MtTestResultDetail -Result $testResult return $true } $unusedResourcesFound = @() # Get all access packages once (cache for performance) $allPackages = Invoke-MtGraphRequest -RelativeUri "identityGovernance/entitlementManagement/accessPackages" -ApiVersion beta $allPackageArray = @() if ($allPackages -is [Array]) { $allPackageArray = $allPackages } elseif ($null -ne $allPackages.value) { $allPackageArray = $allPackages.value } elseif ($null -ne $allPackages) { $allPackageArray = @($allPackages) } Write-Verbose "Found $($allPackageArray.Count) access package(s) total" # Check each catalog for unused resources foreach ($catalog in $catalogArray) { $catalogId = if ($catalog.id) { $catalog.id } else { $catalog.PSObject.Properties['id'].Value } if ([string]::IsNullOrEmpty($catalogId)) { Write-Verbose "Skipping catalog without ID" continue } $catalogName = if ($catalog.displayName) { $catalog.displayName } else { $catalog.PSObject.Properties['displayName'].Value } Write-Verbose "Checking catalog: $catalogName (ID: $catalogId)" # Get all resources in this catalog try { $catalogResources = Invoke-MtGraphRequest -RelativeUri "identityGovernance/entitlementManagement/accessPackageCatalogs/$catalogId/accessPackageResources" -ApiVersion beta $resourceArray = @() if ($catalogResources -is [Array]) { $resourceArray = $catalogResources } elseif ($null -ne $catalogResources.value) { $resourceArray = $catalogResources.value } elseif ($null -ne $catalogResources) { $resourceArray = @($catalogResources) } if ($resourceArray.Count -eq 0) { Write-Verbose "Catalog '$catalogName' has no resources" continue } Write-Verbose "Catalog '$catalogName' has $($resourceArray.Count) resource(s)" # Filter cached packages to only those in this catalog $packageArray = @($allPackageArray | Where-Object { $pkgCatalogId = if ($_.catalogId) { $_.catalogId } else { $_.PSObject.Properties['catalogId'].Value } $pkgCatalogId -eq $catalogId }) Write-Verbose "Catalog '$catalogName' has $($packageArray.Count) access package(s)" # Skip catalogs with no access packages if ($packageArray.Count -eq 0) { Write-Verbose "Skipping catalog '$catalogName' - no access packages configured" continue } # Build a set of resource IDs that are used in access packages $usedResourceIds = @{} foreach ($package in $packageArray) { $packageId = if ($package.id) { $package.id } else { $package.PSObject.Properties['id'].Value } if ([string]::IsNullOrEmpty($packageId)) { continue } # Get resource role scopes for this package try { $resourceRoleScopes = Invoke-MtGraphRequest -RelativeUri "identityGovernance/entitlementManagement/accessPackages/$packageId/accessPackageResourceRoleScopes?`$expand=accessPackageResourceRole,accessPackageResourceScope" -ApiVersion beta $roleScopeArray = @() if ($resourceRoleScopes -is [Array]) { $roleScopeArray = $resourceRoleScopes } elseif ($null -ne $resourceRoleScopes.value) { $roleScopeArray = $resourceRoleScopes.value } elseif ($null -ne $resourceRoleScopes) { $roleScopeArray = @($resourceRoleScopes) } foreach ($roleScope in $roleScopeArray) { $resourceId = $null if ($roleScope.accessPackageResourceScope) { $scope = $roleScope.accessPackageResourceScope $resourceId = if ($scope.originId) { $scope.originId } else { $scope.PSObject.Properties['originId'].Value } } if (-not [string]::IsNullOrEmpty($resourceId)) { $usedResourceIds[$resourceId] = $true } } } catch { Write-Verbose "Error getting resource role scopes for package $packageId : $_" } } Write-Verbose "Found $($usedResourceIds.Count) unique resource(s) used in access packages" # Check each catalog resource to see if it's used foreach ($resource in $resourceArray) { $resourceOriginId = if ($resource.originId) { $resource.originId } else { $resource.PSObject.Properties['originId'].Value } if ([string]::IsNullOrEmpty($resourceOriginId)) { Write-Verbose "Skipping resource without originId" continue } # Check if this resource is used in any access package if (-not $usedResourceIds.ContainsKey($resourceOriginId)) { $resourceDisplayName = if ($resource.displayName) { $resource.displayName } else { if ($resource.PSObject.Properties['displayName']) { $resource.PSObject.Properties['displayName'].Value } else { "Unknown Resource" } } $resourceType = if ($resource.resourceType) { $resource.resourceType } elseif ($resource.originSystem) { $resource.originSystem } else { "Unknown" } Write-Verbose "Found unused resource: $resourceDisplayName (ID: $resourceOriginId, Type: $resourceType)" $unusedResourcesFound += [PSCustomObject]@{ CatalogId = $catalogId CatalogName = $catalogName ResourceId = $resourceOriginId ResourceName = $resourceDisplayName ResourceType = $resourceType } } } } catch { Write-Verbose "Error processing catalog '$catalogName': $_" } } # Determine test result if ($unusedResourcesFound.Count -eq 0) { $testResult = "✅ All catalog resources are used in access packages.`n`nChecked $($catalogArray.Count) catalog(s)." Add-MtTestResultDetail -Result $testResult return $true } else { $groupedByCatalog = $unusedResourcesFound | Group-Object -Property CatalogId $testResult = "❌ Found $($unusedResourcesFound.Count) unused resource(s) across $($groupedByCatalog.Count) catalog(s):`n`n" $testResult += "| Catalog | Resource Name | Type |`n" $testResult += "|---|---|---|`n" foreach ($item in $unusedResourcesFound) { $catalogLink = "https://portal.azure.com/#view/Microsoft_Azure_ELMAdmin/CatalogBlade/catalogId/$($item.CatalogId)" $catalogCell = "[$($item.CatalogName)]($catalogLink)" $friendlyType = switch -Wildcard ($item.ResourceType) { "*Group*" { "Group" } "*Application*" { "Application" } "*SharePoint*" { "SharePoint Site" } "*Site*" { "SharePoint Site" } default { $item.ResourceType } } $testResult += "| $catalogCell | $($item.ResourceName) | $friendlyType |`n" } $testResult += "`n**Remediation:** Review unused resources and either add them to an access package or remove them from the catalog.`n" Add-MtTestResultDetail -Result $testResult return $false } } catch { Write-Error "Error running test: $($_.Exception.Message)" return $false } } |