diff --git a/powershell/Maester.psd1 b/powershell/Maester.psd1 index d00448cf9..2ec808a49 100644 --- a/powershell/Maester.psd1 +++ b/powershell/Maester.psd1 @@ -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 = @() diff --git a/powershell/public/maester/entra/Test-MtEntitlementManagementDeletedGroups.md b/powershell/public/maester/entra/Test-MtEntitlementManagementDeletedGroups.md new file mode 100644 index 000000000..d67f251d2 --- /dev/null +++ b/powershell/public/maester/entra/Test-MtEntitlementManagementDeletedGroups.md @@ -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) diff --git a/powershell/public/maester/entra/Test-MtEntitlementManagementDeletedGroups.ps1 b/powershell/public/maester/entra/Test-MtEntitlementManagementDeletedGroups.ps1 new file mode 100644 index 000000000..f117feb6a --- /dev/null +++ b/powershell/public/maester/entra/Test-MtEntitlementManagementDeletedGroups.ps1 @@ -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 + } +} diff --git a/powershell/public/maester/entra/Test-MtEntitlementManagementInactivePolicies.md b/powershell/public/maester/entra/Test-MtEntitlementManagementInactivePolicies.md new file mode 100644 index 000000000..b6c453179 --- /dev/null +++ b/powershell/public/maester/entra/Test-MtEntitlementManagementInactivePolicies.md @@ -0,0 +1,49 @@ +## Description + +This test identifies Microsoft Entra ID Governance access packages that contain assignment policies which are disabled, misconfigured, or orphaned. Inactive or misconfigured policies prevent users from successfully requesting access and can break automated provisioning workflows. + +The test validates: +- Policies are in "published" state and active +- Requestor scope type is properly configured (not "NoSubjects" or null) +- Required approval settings are complete with designated approvers +- Policies have not expired +- Required questions have proper text configured + +## Remediation action + +**For Unpublished Policies:** +1. Navigate to [Entra Admin Center → Identity Governance → Access Packages](https://entra.microsoft.com/#view/Microsoft_AAD_ELM/Dashboard.ReactView) +2. Select the affected access package → **Policies** tab +3. Review the policy state: + - If should be active: Publish it + - If no longer needed: Delete it + +**For Missing Requestor Settings:** +1. Edit the problematic policy → **Requestor** settings +2. Configure **Who can request** with appropriate scope (All users, Specific users, etc.) +3. Ensure scope type is valid and not deprecated + +**For Missing/Invalid Approval Settings:** +1. Edit the policy → **Approval** settings +2. If approval required: + - Add at least one approval stage + - Configure primary approvers for each stage + - Ensure approver groups exist +3. If not required: Disable approval requirement + +**For Expired Policies:** +1. Review if expiration was intentional +2. If still needed: Edit policy and update expiration date or remove expiration +3. If no longer needed: Delete the policy + +**For Question Configuration Issues:** +1. Edit the policy → **Requestor information** section +2. Ensure all required questions have proper text configured +3. Validate question type and requirements + +## Related links + +- [Microsoft Entra ID Governance Documentation](https://learn.microsoft.com/entra/id-governance/) +- [Access Package Assignment Policies](https://learn.microsoft.com/entra/id-governance/entitlement-management-access-package-request-policy) +- [Configure Access Package Request Settings](https://learn.microsoft.com/entra/id-governance/entitlement-management-access-package-approval-policy) +- [Microsoft Graph API - Assignment Policies](https://learn.microsoft.com/graph/api/resources/accesspackageassignmentpolicy) diff --git a/powershell/public/maester/entra/Test-MtEntitlementManagementInactivePolicies.ps1 b/powershell/public/maester/entra/Test-MtEntitlementManagementInactivePolicies.ps1 new file mode 100644 index 000000000..33b28f185 --- /dev/null +++ b/powershell/public/maester/entra/Test-MtEntitlementManagementInactivePolicies.ps1 @@ -0,0 +1,227 @@ +<# +.SYNOPSIS + Checks if access packages have inactive or orphaned assignment policies + +.DESCRIPTION + MT.1108 - Access packages should not reference inactive or orphaned assignment policies + + This test identifies Microsoft Entra ID Governance access packages that contain assignment policies + which are disabled, misconfigured, or orphaned. Inactive policies can cause: + - Blocked access requests + - Broken approval workflows + - Inconsistent user lifecycle automation + - Configuration drift + + The test validates that all assignment policies are: + - Accepting requests (requestorSettings.acceptRequests = true) + - Properly configured with valid scope types + - Not using deprecated scope types (e.g., "NoSubjects") + - Have valid approval settings where required + - Not expired + - Have proper question configuration + + Learn more: + https://maester.dev/docs/tests/MT.1108 + +.EXAMPLE + Test-MtEntitlementManagementInactivePolicies + + Returns $true if all access package assignment policies are active and properly configured + +.LINK + https://maester.dev/docs/tests/MT.1108 +#> + +function Test-MtEntitlementManagementInactivePolicies { + [CmdletBinding()] + [OutputType([bool])] + param() + + try { + # Get all access packages + $accessPackages = Invoke-MtGraphRequest -RelativeUri "identityGovernance/entitlementManagement/accessPackages" -ApiVersion beta + + $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 + } + + $inactivePoliciesFound = @() + + # Check each access package for inactive or misconfigured policies + 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/accessPackageAssignmentPolicies?`$filter=accessPackage/id eq '$packageId'" -ApiVersion beta + + $policyArray = @() + if ($policies -is [Array]) { + $policyArray = $policies + } elseif ($null -ne $policies.value) { + $policyArray = $policies.value + } elseif ($null -ne $policies) { + $policyArray = @($policies) + } + + if ($policyArray.Count -eq 0) { + Write-Verbose "No policies found for package: $packageName" + continue + } + + foreach ($policy in $policyArray) { + $policyId = if ($policy.id) { $policy.id } else { $policy.PSObject.Properties['id'].Value } + $policyName = if ($policy.displayName) { $policy.displayName } else { + if ($policy.PSObject.Properties['displayName']) { $policy.PSObject.Properties['displayName'].Value } else { "Unnamed Policy" } + } + + Write-Verbose "Checking policy: $policyName (ID: $policyId)" + + $issues = @() + + # Check 1: Validate if policy accepts requests + if ($policy.requestorSettings.acceptRequests -eq $false) { + $issues += "Policy is not accepting new requests" + } + + # Check 2: Validate requestor scope type + if ($policy.requestorSettings) { + $scopeType = $policy.requestorSettings.scopeType + + if ([string]::IsNullOrEmpty($scopeType)) { + $issues += "Requestor scope type is missing" + } elseif ($scopeType -eq "NoSubjects") { + $issues += "Nobody can request access (NoSubjects)" + } elseif ($scopeType -eq "SpecificDirectorySubjects") { + $allowedRequestors = $policy.requestorSettings.allowedRequestors + if ($null -eq $allowedRequestors -or $allowedRequestors.Count -eq 0) { + $issues += "No users/groups allowed to request" + } + } + } else { + $issues += "Requestor settings are missing" + } + + # Check 3: Validate approval settings if approval is required + if ($policy.requestApprovalSettings) { + $isApprovalRequired = $policy.requestApprovalSettings.isApprovalRequired + + if ($isApprovalRequired -eq $true) { + $approvalStages = $policy.requestApprovalSettings.approvalStages + + if ($null -eq $approvalStages -or $approvalStages.Count -eq 0) { + $issues += "Approval required but no stages configured" + } else { + $hasValidApprovers = $false + foreach ($stage in $approvalStages) { + if ($stage.primaryApprovers -and $stage.primaryApprovers.Count -gt 0) { + $hasValidApprovers = $true + break + } + } + + if (-not $hasValidApprovers) { + $issues += "No valid approvers configured" + } + } + } + } + + # Check 4: Validate expiration settings + if ($policy.PSObject.Properties['expirationDateTime']) { + $expirationDate = $policy.PSObject.Properties['expirationDateTime'].Value + if ($null -ne $expirationDate) { + $expDate = [DateTime]::Parse($expirationDate) + if ($expDate -lt (Get-Date)) { + $issues += "Policy expired on $($expDate.ToString('yyyy-MM-dd'))" + } + } + } + + # If any issues found, add to results + if ($issues.Count -gt 0) { + $inactivePoliciesFound += [PSCustomObject]@{ + PackageName = $packageName + PackageId = $packageId + PolicyName = $policyName + PolicyId = $policyId + ScopeType = $policy.requestorSettings.scopeType + Issues = $issues + } + } + } + } catch { + Write-Verbose "Could not retrieve assignment policies for access package: $packageName. Error: $_" + } + } + + # Evaluate results + $disabledPolicies = $inactivePoliciesFound | Where-Object { $_.ScopeType -ne "Error" } + $result = $disabledPolicies.Count -eq 0 + + if ($result) { + $testResult = "✅ All access package assignment policies are active and properly configured." + Add-MtTestResultDetail -Result $testResult + } else { + $issuesByPackage = $disabledPolicies | Group-Object PackageId + $testResult = "❌ Found $($disabledPolicies.Count) inactive policy/policies across $($issuesByPackage.Count) access package(s):`n`n" + + $testResult += "| Access Package | Policy Name | Issue |`n" + $testResult += "|---|---|---|`n" + + foreach ($packageGroup in $issuesByPackage) { + $packageName = ($packageGroup.Group | Select-Object -First 1).PackageName + $packageId = $packageGroup.Name + $packageLink = "https://portal.azure.com/#view/Microsoft_Azure_ELMAdmin/EntitlementMenuBlade/~/overview/entitlementId/$packageId" + + foreach ($policyIssue in $packageGroup.Group) { + $primaryIssue = "" + if ($policyIssue.Issues -match "NoSubjects") { + $primaryIssue = "No one can request" + } elseif ($policyIssue.Issues -match "not accepting new requests") { + $primaryIssue = "Not accepting requests" + } elseif ($policyIssue.Issues -match "No users/groups") { + $primaryIssue = "No users allowed" + } elseif ($policyIssue.Issues -match "expired") { + $primaryIssue = "Expired" + } elseif ($policyIssue.Issues -match "no stages|No valid approvers") { + $primaryIssue = "No approvers" + } else { + $primaryIssue = $policyIssue.Issues[0] + } + + $testResult += "| [$packageName]($packageLink) | $($policyIssue.PolicyName) | $primaryIssue |`n" + } + } + + $testResult += "`n**Remediation:** Update or remove these policies in the [Entra portal](https://portal.azure.com/#view/Microsoft_Azure_ELMAdmin).`n" + + Add-MtTestResultDetail -Result $testResult + } + + return $result + + } catch { + Write-Error "Error checking access package assignment policies: $($_.Exception.Message)" + return $false + } +} diff --git a/powershell/public/maester/entra/Test-MtEntitlementManagementOrphanedResources.md b/powershell/public/maester/entra/Test-MtEntitlementManagementOrphanedResources.md new file mode 100644 index 000000000..f1a0266eb --- /dev/null +++ b/powershell/public/maester/entra/Test-MtEntitlementManagementOrphanedResources.md @@ -0,0 +1,43 @@ +## Description + +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 within that catalog. Orphaned resources indicate incomplete configuration or drift. + +The test validates: +- All catalog resources are referenced in at least one access package +- No orphaned or unused resources exist in catalogs +- Resources serve their intended governance purpose + +Common scenarios detected: +- Resources added to catalog but package not yet configured +- Access package was deleted but resource remained in catalog +- Resources removed from packages but not from catalog +- Test resources added and never cleaned up + +## Remediation action + +**Option 1: Add to Access Package** (if resource should be governed) +1. Navigate to [Entra Admin Center → Identity Governance → Catalogs](https://entra.microsoft.com/#view/Microsoft_AAD_ELM/Dashboard.ReactView) +2. Open an existing access package or create a new one +3. Add the resource to the package's resource roles +4. Configure appropriate roles and permissions +5. Update package policies as needed + +**Option 2: Remove from Catalog** (if resource no longer needed) +1. Navigate to [Entra Admin Center → Identity Governance → Catalogs](https://entra.microsoft.com/#view/Microsoft_AAD_ELM/Dashboard.ReactView) +2. Select the catalog → **Resources** section +3. Select the unused resource +4. Click **Remove from catalog** +5. Confirm removal + +**Bulk Remediation Process:** +1. Review with stakeholders to identify which resources are still needed +2. Create access packages for resources that should be governed +3. Clean up catalog by removing resources no longer needed +4. Document decisions and update procedures + +## Related links + +- [Microsoft Entra ID Governance Documentation](https://learn.microsoft.com/entra/id-governance/) +- [Access Package Catalogs](https://learn.microsoft.com/entra/id-governance/entitlement-management-catalog-create) +- [Manage Catalog Resources](https://learn.microsoft.com/entra/id-governance/entitlement-management-access-package-resources) +- [Microsoft Graph API - Entitlement Management](https://learn.microsoft.com/graph/api/resources/entitlementmanagement-overview) \ No newline at end of file diff --git a/powershell/public/maester/entra/Test-MtEntitlementManagementOrphanedResources.ps1 b/powershell/public/maester/entra/Test-MtEntitlementManagementOrphanedResources.ps1 new file mode 100644 index 000000000..9f2f3c1c2 --- /dev/null +++ b/powershell/public/maester/entra/Test-MtEntitlementManagementOrphanedResources.ps1 @@ -0,0 +1,237 @@ +<# +.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/tests/MT.1110 +#> + +function Test-MtEntitlementManagementOrphanedResources { + [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 + } +} diff --git a/powershell/public/maester/entra/Test-MtEntitlementManagementValidApprovers.md b/powershell/public/maester/entra/Test-MtEntitlementManagementValidApprovers.md new file mode 100644 index 000000000..8d30fc22a --- /dev/null +++ b/powershell/public/maester/entra/Test-MtEntitlementManagementValidApprovers.md @@ -0,0 +1,52 @@ +## Description + +This test identifies Microsoft Entra ID Governance access package assignment policies with approval workflows that reference invalid approvers. Invalid approvers cause approval workflow failures, access request timeouts, and create significant operational overhead. + +The test validates: +- User approvers exist in the directory and accounts are enabled +- Group approvers exist and have at least one member +- Policies requiring approval have approval stages with primary approvers configured +- Approval workflows are complete and functional + +**Note:** Manager approvers are noted but not validated (resolved at request time). + +## Remediation action + +**For Deleted User Approvers:** +1. Navigate to [Entra Admin Center → Identity Governance → Access Packages](https://entra.microsoft.com/#view/Microsoft_AAD_ELM/Dashboard.ReactView) +2. Select the affected access package → **Policies** tab +3. Edit the affected policy → **Approval** settings +4. Remove deleted users and add valid replacement approvers +5. Consider using groups for resilience +6. Test the approval workflow + +**For Disabled User Approvers:** +1. Determine if user should be re-enabled or replaced +2. If temporary: Re-enable the user account in Entra ID +3. If permanent: Replace with active user or group +4. Update policy **Approval** settings + +**For Deleted or Missing Groups:** +1. Edit the policy → **Approval** settings +2. Remove references to deleted groups +3. Create or select a valid approval group with active members +4. Update the policy and save + +**For Empty Approval Groups:** +1. Navigate to [Entra ID → Groups](https://entra.microsoft.com/#view/Microsoft_AAD_IAM/GroupsManagementMenuBlade) +2. Find the approval group and add appropriate members +3. Ensure multiple members for redundancy +4. Verify the access package policy + +**For Missing Approval Stages or Primary Approvers:** +1. Edit the policy → **Approval** settings +2. Add at least one approval stage +3. Configure primary approvers (users, groups, or manager) +4. Set timeout values and save + +## Related links + +- [Microsoft Entra ID Governance Documentation](https://learn.microsoft.com/entra/id-governance/) +- [Configure Access Package Approval](https://learn.microsoft.com/entra/id-governance/entitlement-management-access-package-approval-policy) +- [Approval Workflow Settings](https://learn.microsoft.com/entra/id-governance/entitlement-management-access-package-request-policy) +- [Microsoft Graph API - Approval Settings](https://learn.microsoft.com/graph/api/resources/approval) diff --git a/powershell/public/maester/entra/Test-MtEntitlementManagementValidApprovers.ps1 b/powershell/public/maester/entra/Test-MtEntitlementManagementValidApprovers.ps1 new file mode 100644 index 000000000..e15087e5e --- /dev/null +++ b/powershell/public/maester/entra/Test-MtEntitlementManagementValidApprovers.ps1 @@ -0,0 +1,323 @@ +<# +.SYNOPSIS + Checks if access package approval workflows have valid approvers + +.DESCRIPTION + MT.1109 - Access package approval workflows must have valid approvers + + This test identifies Microsoft Entra ID Governance access package assignment policies with + approval workflows that reference invalid approvers. Invalid approvers can cause: + - Approval workflow failures + - Access request timeouts + - Broken automation flows + - User frustration and support tickets + + The test validates that all approval workflows have: + - Valid user approvers (account enabled, not deleted) + - Valid group approvers (group exists and has members) + - Manager approvers where requestor has an assigned manager + - No references to deleted or disabled accounts + + Learn more: + https://maester.dev/docs/tests/MT.1109 + +.EXAMPLE + Test-MtEntitlementManagementValidApprovers + + Returns $true if all approval workflows have valid approvers + +.LINK + https://maester.dev/docs/tests/MT.1109 +#> + +function Test-MtEntitlementManagementValidApprovers { + [CmdletBinding()] + [OutputType([bool])] + param() + + try { + # Get all access packages + $accessPackages = Invoke-MtGraphRequest -RelativeUri "identityGovernance/entitlementManagement/accessPackages" -ApiVersion beta + + $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 + } + + $invalidApproversFound = @() + + # Check each access package for invalid approvers + 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 assignment policies + try { + $policies = Invoke-MtGraphRequest -RelativeUri "identityGovernance/entitlementManagement/accessPackageAssignmentPolicies?`$filter=accessPackage/id eq '$packageId'" -ApiVersion beta + + $policyArray = @() + if ($policies -is [Array]) { + $policyArray = $policies + } elseif ($null -ne $policies.value) { + $policyArray = $policies.value + } elseif ($null -ne $policies) { + $policyArray = @($policies) + } + + if ($policyArray.Count -eq 0) { + Write-Verbose "No policies found for package: $packageName" + continue + } + + # Check each policy for approval workflow issues + foreach ($policy in $policyArray) { + $policyName = if ($policy.displayName) { $policy.displayName } else { $policy.PSObject.Properties['displayName'].Value } + + # Skip default system policies + if ($policyName -like "*All members*" -and $policyName -like "*excluding guests*") { + Write-Verbose "Skipping default system policy: $policyName" + continue + } + + Write-Verbose "Checking policy: $policyName" + + $requestApprovalSettings = $policy.requestApprovalSettings + if ($null -eq $requestApprovalSettings) { + Write-Verbose "Policy has no approval settings" + continue + } + + $isApprovalRequired = $requestApprovalSettings.isApprovalRequired + if (-not $isApprovalRequired) { + Write-Verbose "Policy does not require approval" + continue + } + + $approvalStages = $requestApprovalSettings.approvalStages + if ($null -eq $approvalStages -or $approvalStages.Count -eq 0) { + $invalidApproversFound += [PSCustomObject]@{ + PackageId = $packageId + PackageName = $packageName + PolicyName = $policyName + Issue = "No approval stages" + ApproverType = "N/A" + ApproverDetails = "Approval required but no stages defined" + } + continue + } + + # Check each approval stage + foreach ($stage in $approvalStages) { + $primaryApprovers = $stage.primaryApprovers + if ($null -eq $primaryApprovers -or $primaryApprovers.Count -eq 0) { + $invalidApproversFound += [PSCustomObject]@{ + PackageId = $packageId + PackageName = $packageName + PolicyName = $policyName + Issue = "No primary approvers" + ApproverType = "N/A" + ApproverDetails = "Stage has no approvers" + } + continue + } + + # Check each approver + foreach ($approver in $primaryApprovers) { + $approverType = $approver.'@odata.type' + + switch ($approverType) { + '#microsoft.graph.singleUser' { + $userId = if ($approver.userId) { $approver.userId } elseif ($approver.id) { $approver.id } else { + if ($approver.PSObject.Properties['userId']) { $approver.PSObject.Properties['userId'].Value } else { $approver.PSObject.Properties['id'].Value } + } + + if ([string]::IsNullOrEmpty($userId)) { + $invalidApproversFound += [PSCustomObject]@{ + PackageId = $packageId + PackageName = $packageName + PolicyName = $policyName + Issue = "User has no ID" + ApproverType = "User" + ApproverDetails = "Invalid configuration" + } + continue + } + + try { + $user = Invoke-MtGraphRequest -RelativeUri "users/$userId" -ApiVersion beta -ErrorAction SilentlyContinue + + if ($null -eq $user) { + $invalidApproversFound += [PSCustomObject]@{ + PackageId = $packageId + PackageName = $packageName + PolicyName = $policyName + Issue = "User not found" + ApproverType = "User" + ApproverDetails = "ID: $userId" + } + } elseif ($user.accountEnabled -eq $false) { + $userName = if ($user.displayName) { $user.displayName } else { $user.userPrincipalName } + $invalidApproversFound += [PSCustomObject]@{ + PackageId = $packageId + PackageName = $packageName + PolicyName = $policyName + Issue = "User disabled" + ApproverType = "User" + ApproverDetails = "$userName" + } + } + } catch { + if ($_.Exception.Message -like "*404*" -or $_.Exception.Message -like "*not found*") { + $invalidApproversFound += [PSCustomObject]@{ + PackageId = $packageId + PackageName = $packageName + PolicyName = $policyName + Issue = "User deleted" + ApproverType = "User" + ApproverDetails = "ID: $userId" + } + } + } + } + + '#microsoft.graph.groupMembers' { + $groupId = if ($approver.groupId) { $approver.groupId } elseif ($approver.id) { $approver.id } else { + if ($approver.PSObject.Properties['groupId']) { $approver.PSObject.Properties['groupId'].Value } else { $approver.PSObject.Properties['id'].Value } + } + + if ([string]::IsNullOrEmpty($groupId)) { + $invalidApproversFound += [PSCustomObject]@{ + PackageId = $packageId + PackageName = $packageName + PolicyName = $policyName + Issue = "Group has no ID" + ApproverType = "Group" + ApproverDetails = "Invalid configuration" + } + continue + } + + try { + $group = Invoke-MtGraphRequest -RelativeUri "groups/$groupId" -ApiVersion beta -ErrorAction SilentlyContinue + + if ($null -eq $group) { + $invalidApproversFound += [PSCustomObject]@{ + PackageId = $packageId + PackageName = $packageName + PolicyName = $policyName + Issue = "Group not found" + ApproverType = "Group" + ApproverDetails = "ID: $groupId" + } + continue + } + + # Check if group has members + try { + $members = Invoke-MtGraphRequest -RelativeUri "groups/$groupId/members?`$top=1" -ApiVersion beta -ErrorAction SilentlyContinue + + $memberCount = 0 + if ($members -is [Array]) { + $memberCount = $members.Count + } elseif ($null -ne $members.value) { + $memberCount = $members.value.Count + } elseif ($null -ne $members) { + $memberCount = 1 + } + + if ($memberCount -eq 0) { + $groupName = if ($group.displayName) { $group.displayName } else { "Unknown" } + $invalidApproversFound += [PSCustomObject]@{ + PackageId = $packageId + PackageName = $packageName + PolicyName = $policyName + Issue = "Group has no members" + ApproverType = "Group" + ApproverDetails = $groupName + } + } + } catch { + Write-Verbose "Error checking members for group $groupId" + } + } catch { + if ($_.Exception.Message -like "*404*" -or $_.Exception.Message -like "*not found*") { + $invalidApproversFound += [PSCustomObject]@{ + PackageId = $packageId + PackageName = $packageName + PolicyName = $policyName + Issue = "Group deleted" + ApproverType = "Group" + ApproverDetails = "ID: $groupId" + } + } + } + } + + '#microsoft.graph.requestorManager' { + Write-Verbose "Policy uses manager approval" + } + + '#microsoft.graph.internalSponsors' { + Write-Verbose "Policy uses internal sponsors" + } + + '#microsoft.graph.externalSponsors' { + Write-Verbose "Policy uses external sponsors" + } + } + } + } + } + } catch { + Write-Verbose "Error processing package $packageName : $_" + } + } + + # Determine test result + if ($invalidApproversFound.Count -eq 0) { + $testResult = "✅ All approval workflows have valid approvers.`n`nChecked $($packages.Count) access package(s)." + Add-MtTestResultDetail -Result $testResult + return $true + } else { + $groupedByPackage = $invalidApproversFound | Group-Object -Property PackageId + + $testResult = "❌ Found $($invalidApproversFound.Count) invalid approver(s) across $($groupedByPackage.Count) access package(s):`n`n" + + $testResult += "| Access Package | Policy | Issue | Type | Details |`n" + $testResult += "|---|---|---|---|---|`n" + + foreach ($item in $invalidApproversFound) { + $packageLink = "https://portal.azure.com/#view/Microsoft_Azure_ELMAdmin/EntitlementMenuBlade/~/overview/entitlementId/$($item.PackageId)" + $packageName = "[$($item.PackageName)]($packageLink)" + + $testResult += "| $packageName | $($item.PolicyName) | $($item.Issue) | $($item.ApproverType) | $($item.ApproverDetails) |`n" + } + + $testResult += "`n**Remediation:** Update approval workflows to use valid, active approvers in the [Entra portal](https://portal.azure.com/#view/Microsoft_Azure_ELMAdmin).`n" + + Add-MtTestResultDetail -Result $testResult + return $false + } + + } catch { + Write-Error "Error running test: $($_.Exception.Message)" + return $false + } +} diff --git a/powershell/public/maester/entra/Test-MtEntitlementManagementValidResourceRoles.md b/powershell/public/maester/entra/Test-MtEntitlementManagementValidResourceRoles.md new file mode 100644 index 000000000..7a6bd2abc --- /dev/null +++ b/powershell/public/maester/entra/Test-MtEntitlementManagementValidResourceRoles.md @@ -0,0 +1,70 @@ +## Description + +This test identifies Microsoft Entra ID Governance access package catalog resources that reference stale or invalid roles, deleted service principals, or non-existent SharePoint sites. When applications or sites are reconfigured or deleted, catalogs often retain "ghost roles" that cause provisioning failures. + +The test validates: +- Service principals referenced by applications are active and accessible +- Application roles assigned in access packages still exist in their service principals +- SharePoint sites have valid URLs and are accessible via Microsoft Graph API +- Resources are properly configured + +**Note:** Group validation is handled by MT.1107. Built-in roles are excluded as they are system-managed. "Default Access" roles are skipped as system defaults. + +Stale resources detected: +- **Deleted service principals** - Application removed from tenant (404 error) +- **Stale app roles** - Roles removed from service principal but still assigned in packages +- **Invalid SharePoint URLs** - Site URL format incorrect or site deleted/moved +- **Inaccessible sites** - Site exists but cannot be accessed via Graph API + +## Remediation action + +**For Deleted Service Principals/Applications:** + +*Option 1: Remove from Catalog* +1. Navigate to [Entra Admin Center → Identity Governance → Catalogs](https://entra.microsoft.com/#view/Microsoft_AAD_ELM/Dashboard.ReactView) +2. Select the catalog → **Resources** section +3. Select the stale application resource +4. Click **Remove from catalog** +5. Update any access packages that referenced this resource + +*Option 2: Restore Application* +1. Check [Entra Admin Center → Enterprise applications → Deleted applications](https://entra.microsoft.com/#view/Microsoft_AAD_IAM/StartboardApplicationsMenuBlade/~/AppAppsPreview) +2. Restore the application if within recovery window +3. Verify catalog resource is now valid + +**For Stale App Roles:** +1. Identify the app role from test results +2. Find the service principal in Entra portal +3. If role removed intentionally: + - Edit the access package + - Remove the stale resource role assignment + - Add a valid app role if needed +4. If role should exist: + - Contact application owner to restore role in app manifest + - Update app registration to add role back + +**For SharePoint Sites:** + +*Option 1: Remove from Catalog* +1. Navigate to catalog → **Resources** +2. Select the site and click **Remove from catalog** +3. Update access packages + +*Option 2: Fix Site URL* (if moved/renamed) +1. Verify correct URL in [SharePoint Admin Center](https://admin.microsoft.com/sharepoint) +2. Remove old site resource from catalog +3. Add site with correct URL as new resource +4. Update access packages + +*Option 3: Restore Site* +1. Check [SharePoint Admin Center → Deleted sites](https://admin.microsoft.com/sharepoint?page=recycleBin) +2. Restore site if within 93-day recovery window +3. Verify accessibility + +## Related links + +- [Microsoft Entra ID Governance Documentation](https://learn.microsoft.com/entra/id-governance/) +- [Manage Catalog Resources](https://learn.microsoft.com/entra/id-governance/entitlement-management-access-package-resources) +- [Service Principal App Roles](https://learn.microsoft.com/graph/api/resources/approle) +- [SharePoint Site Resource Type](https://learn.microsoft.com/graph/api/resources/site) +- [Microsoft Graph API - Entitlement Management](https://learn.microsoft.com/graph/api/resources/entitlementmanagement-overview) \ No newline at end of file diff --git a/powershell/public/maester/entra/Test-MtEntitlementManagementValidResourceRoles.ps1 b/powershell/public/maester/entra/Test-MtEntitlementManagementValidResourceRoles.ps1 new file mode 100644 index 000000000..92c3ba2f0 --- /dev/null +++ b/powershell/public/maester/entra/Test-MtEntitlementManagementValidResourceRoles.ps1 @@ -0,0 +1,315 @@ +<# +.SYNOPSIS + Validates catalog resources have no stale app roles or deleted service principals + +.DESCRIPTION + MT.1111 - Catalog resources must have valid roles (no stale / removed app roles or SPNs) + + This test identifies Entra ID Governance access package catalog resources that + reference deleted service principals, stale app roles, or inaccessible SharePoint sites. + + When Enterprise Applications are deleted or reconfigured (app roles removed), or when + SharePoint sites are deleted/moved, catalogs often retain references that cause + provisioning failures when users request access. + + The test validates: + - Application resources point to existing service principals + - App roles assigned in access packages still exist in service principals + - SharePoint sites are accessible via Graph API + - "Default Access" roles are excluded (system defaults) + + Issues detected: + - Deleted service principals (404 errors) + - Stale app roles removed from service principal but still in access packages + - Deleted or inaccessible SharePoint sites + - Invalid SharePoint URLs + + Note: Group validation is delegated to MT.1107 for comprehensive coverage. + + Learn more: + https://maester.dev/docs/tests/MT.1111 + +.EXAMPLE + Test-MtEntitlementManagementValidResourceRoles + + Returns $true if all catalog resources have valid roles and service principals + +.LINK + https://maester.dev/docs/tests/MT.1111 +#> + +function Test-MtEntitlementManagementValidResourceRoles { + [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 catalogs found in the tenant." + Add-MtTestResultDetail -Result $testResult + return $true + } + + Write-Verbose "Found $($catalogArray.Count) catalog(s) to check" + + $staleResourcesFound = @() + + # 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) to check" + + # Check each catalog for stale resources + foreach ($catalog in $catalogArray) { + $catalogId = if ($catalog.id) { $catalog.id } else { $catalog.PSObject.Properties['id'].Value } + $catalogName = if ($catalog.displayName) { $catalog.displayName } else { $catalog.PSObject.Properties['displayName'].Value } + + if ([string]::IsNullOrEmpty($catalogId)) { + Write-Verbose "Skipping catalog without ID" + continue + } + + Write-Verbose "Checking catalog: $catalogName (ID: $catalogId)" + + try { + # Get all resources in this catalog + $resources = Invoke-MtGraphRequest -RelativeUri "identityGovernance/entitlementManagement/accessPackageCatalogs/$catalogId/accessPackageResources" -ApiVersion beta + + $resourceArray = @() + if ($resources -is [Array]) { + $resourceArray = $resources + } elseif ($null -ne $resources.value) { + $resourceArray = $resources.value + } elseif ($null -ne $resources) { + $resourceArray = @($resources) + } + + Write-Verbose "Catalog '$catalogName' has $($resourceArray.Count) resource(s)" + + if ($resourceArray.Count -eq 0) { + continue + } + + # Check each resource + foreach ($resource in $resourceArray) { + $resourceOriginId = if ($resource.originId) { $resource.originId } else { $resource.PSObject.Properties['originId'].Value } + $resourceDisplayName = if ($resource.displayName) { $resource.displayName } else { $resource.PSObject.Properties['displayName'].Value } + $resourceType = if ($resource.resourceType) { $resource.resourceType } elseif ($resource.originSystem) { $resource.originSystem } else { "Unknown" } + $resourceId = if ($resource.id) { $resource.id } else { $resource.PSObject.Properties['id'].Value } + + if ([string]::IsNullOrEmpty($resourceOriginId)) { + $staleResourcesFound += [PSCustomObject]@{ + CatalogId = $catalogId + CatalogName = $catalogName + ResourceName = $resourceDisplayName + ResourceType = $resourceType + Issue = "Missing origin ID" + } + continue + } + + Write-Verbose "Validating resource: $resourceDisplayName (Type: $resourceType)" + + $validationFailed = $false + $issueDescription = "" + + switch -Wildcard ($resourceType) { + "*Group*" { + Write-Verbose "Skipping group resource (covered by MT.1107)" + continue + } + + "Built-in" { + Write-Verbose "Skipping built-in role resource" + continue + } + + "*Application*" { + # Check if service principal exists + try { + $sp = Invoke-MtGraphRequest -RelativeUri "servicePrincipals/$resourceOriginId" -ApiVersion beta -ErrorAction Stop + + if (-not $sp -or -not $sp.id) { + $validationFailed = $true + $issueDescription = "Service principal not found" + } else { + Write-Verbose "Service principal exists: $($sp.displayName)" + + # Check for stale app roles using cached packages + $catalogPackages = $allPackageArray | Where-Object { $_.catalogId -eq $catalogId } + + foreach ($package in $catalogPackages) { + try { + $roleScopes = Invoke-MtGraphRequest -RelativeUri "identityGovernance/entitlementManagement/accessPackages/$($package.id)/accessPackageResourceRoleScopes?`$expand=accessPackageResourceRole,accessPackageResourceScope" -ApiVersion beta + + $roleScopeArray = @() + if ($roleScopes -is [Array]) { + $roleScopeArray = $roleScopes + } elseif ($null -ne $roleScopes.value) { + $roleScopeArray = $roleScopes.value + } elseif ($null -ne $roleScopes) { + $roleScopeArray = @($roleScopes) + } + + foreach ($roleScope in $roleScopeArray) { + $resScope = $roleScope.accessPackageResourceScope + $resRole = $roleScope.accessPackageResourceRole + + if ($resScope.originId -eq $resourceOriginId) { + $roleOriginId = $resRole.originId + $roleDisplayName = $resRole.displayName + + if ($roleDisplayName -eq "Default Access") { + continue + } + + $spAppRoleIds = @() + if ($sp.appRoles) { + $spAppRoleIds = $sp.appRoles | ForEach-Object { $_.id } + } + + if ($roleOriginId -and $roleOriginId -ne "00000000-0000-0000-0000-000000000000") { + if ($spAppRoleIds -notcontains $roleOriginId) { + $validationFailed = $true + $issueDescription = "App role '$roleDisplayName' no longer exists" + $resourceType = "App Role" + break + } + } + } + } + } catch { + Write-Verbose "Could not retrieve role scopes for package" + } + + if ($validationFailed) { + break + } + } + } + } catch { + $validationFailed = $true + $issueDescription = "Service principal deleted or inaccessible" + } + } + + "*SharePoint*" { + # Check if SharePoint site exists + try { + if ($resourceOriginId -match "^https://") { + $siteUrl = $resourceOriginId + Write-Verbose "Validating SharePoint site: $siteUrl" + + if ($siteUrl -match '^https://([^/]+)(.*)$') { + $hostname = $Matches[1] + $sitePath = $Matches[2] + $siteIdentifier = "${hostname}:${sitePath}" + + try { + $site = Invoke-MtGraphRequest -RelativeUri "sites/$siteIdentifier" -ApiVersion v1.0 + + if (-not $site) { + $validationFailed = $true + $issueDescription = "SharePoint site not accessible" + $resourceType = "SharePoint Site" + } + } catch { + $validationFailed = $true + $issueDescription = "SharePoint site not accessible" + $resourceType = "SharePoint Site" + } + } else { + Write-Verbose "Could not parse SharePoint URL" + } + } else { + $validationFailed = $true + $issueDescription = "Invalid URL format" + $resourceType = "SharePoint Site" + } + } catch { + Write-Verbose "Error validating SharePoint site" + } + } + + "*Site*" { + if ($resourceOriginId -notmatch "^https://") { + $validationFailed = $true + $issueDescription = "Invalid URL format" + } + } + } + + if ($validationFailed) { + Write-Verbose "Found stale resource: $resourceDisplayName - $issueDescription" + + $staleResourcesFound += [PSCustomObject]@{ + CatalogId = $catalogId + CatalogName = $catalogName + ResourceName = $resourceDisplayName + ResourceType = $resourceType + Issue = $issueDescription + } + } + } + } catch { + Write-Verbose "Error processing catalog '$catalogName': $_" + } + } + + # Determine test result + if ($staleResourcesFound.Count -eq 0) { + $testResult = "✅ All application and SharePoint resources have valid roles and service principals.`n`nChecked $($catalogArray.Count) catalog(s).`n`n*Note: Group validation is covered by MT.1107*" + Add-MtTestResultDetail -Result $testResult + return $true + } else { + $testResult = "❌ Found $($staleResourcesFound.Count) stale resource(s):`n`n" + + $testResult += "| Catalog | Resource Name | Type | Issue |`n" + $testResult += "|---|---|---|---|`n" + + foreach ($item in $staleResourcesFound) { + $friendlyType = switch -Wildcard ($item.ResourceType) { + "*Application*" { "Application" } + "*SharePoint*" { "SharePoint Site" } + "*Site*" { "Site" } + default { $item.ResourceType } + } + + $catalogLink = "https://portal.azure.com/#view/Microsoft_Azure_ELMAdmin/CatalogBlade/catalogId/$($item.CatalogId)" + $catalogCell = "[$($item.CatalogName)]($catalogLink)" + + $testResult += "| $catalogCell | $($item.ResourceName) | $friendlyType | $($item.Issue) |`n" + } + + $testResult += "`n**Remediation:** Remove stale resources from catalogs or restore the underlying service principals/sites.`n" + + Add-MtTestResultDetail -Result $testResult + return $false + } + + } catch { + Write-Error "Error running test: $($_.Exception.Message)" + return $false + } +} diff --git a/tests/Maester/Entra/Test-MtEntitlementManagementDeletedGroups.Tests.ps1 b/tests/Maester/Entra/Test-MtEntitlementManagementDeletedGroups.Tests.ps1 new file mode 100644 index 000000000..5f5e81afa --- /dev/null +++ b/tests/Maester/Entra/Test-MtEntitlementManagementDeletedGroups.Tests.ps1 @@ -0,0 +1,6 @@ +Describe "Maester/Entra" -Tag "Governance", "Entra", "Security", "AccessPackages" { + It "MT.1107: Access packages and catalogs should not reference deleted groups. See https://maester.dev/docs/tests/MT.1107" -Tag "MT.1107" { + $result = Test-MtEntitlementManagementDeletedGroups + $result | Should -Be $true -Because "Access packages and catalogs should not reference deleted groups to prevent access provisioning failures and configuration inconsistencies." + } +} diff --git a/tests/Maester/Entra/Test-MtEntitlementManagementInactivePolicies.Tests.ps1 b/tests/Maester/Entra/Test-MtEntitlementManagementInactivePolicies.Tests.ps1 new file mode 100644 index 000000000..652e3e216 --- /dev/null +++ b/tests/Maester/Entra/Test-MtEntitlementManagementInactivePolicies.Tests.ps1 @@ -0,0 +1,6 @@ +Describe "Maester/Entra" -Tag "Governance", "Entra", "Security", "AccessPackages" { + It "MT.1108: Access packages should not reference inactive or orphaned assignment policies. See https://maester.dev/docs/tests/MT.1108" -Tag "MT.1108" { + $result = Test-MtEntitlementManagementInactivePolicies + $result | Should -Be $true -Because "Access packages should not have inactive or misconfigured assignment policies that block access requests or break approval workflows." + } +} diff --git a/tests/Maester/Entra/Test-MtEntitlementManagementOrphanedResources.Tests.ps1 b/tests/Maester/Entra/Test-MtEntitlementManagementOrphanedResources.Tests.ps1 new file mode 100644 index 000000000..51b4c91a0 --- /dev/null +++ b/tests/Maester/Entra/Test-MtEntitlementManagementOrphanedResources.Tests.ps1 @@ -0,0 +1,6 @@ +Describe "Maester/Entra" -Tag "Governance", "Entra", "Security", "AccessPackages" { + It "MT.1110: No catalog should contain resources without any associated access packages. See https://maester.dev/docs/tests/MT.1110" -Tag "MT.1110" { + $result = Test-MtEntitlementManagementOrphanedResources + $result | Should -Be $true -Because "Catalog resources without associated access packages indicate configuration drift and should be removed to maintain clean governance setup." + } +} diff --git a/tests/Maester/Entra/Test-MtEntitlementManagementValidApprovers.Tests.ps1 b/tests/Maester/Entra/Test-MtEntitlementManagementValidApprovers.Tests.ps1 new file mode 100644 index 000000000..c0f8b912e --- /dev/null +++ b/tests/Maester/Entra/Test-MtEntitlementManagementValidApprovers.Tests.ps1 @@ -0,0 +1,6 @@ +Describe "Maester/Entra" -Tag "Governance", "Entra", "Security", "AccessPackages" { + It "MT.1109: Access package approval workflows must have valid approvers. See https://maester.dev/docs/tests/MT.1109" -Tag "MT.1109" { + $result = Test-MtEntitlementManagementValidApprovers + $result | Should -Be $true -Because "Access package approval workflows must have valid approvers to prevent workflow failures and blocked access requests." + } +} diff --git a/tests/Maester/Entra/Test-MtEntitlementManagementValidResourceRoles.Tests.ps1 b/tests/Maester/Entra/Test-MtEntitlementManagementValidResourceRoles.Tests.ps1 new file mode 100644 index 000000000..6775436b0 --- /dev/null +++ b/tests/Maester/Entra/Test-MtEntitlementManagementValidResourceRoles.Tests.ps1 @@ -0,0 +1,6 @@ +Describe "Maester/Entra" -Tag "Governance", "Entra", "Security", "AccessPackages" { + It "MT.1111: Catalog resources must have valid roles (no stale / removed app roles or SPNs). See https://maester.dev/docs/tests/MT.1111" -Tag "MT.1111" { + $result = Test-MtEntitlementManagementValidResourceRoles + $result | Should -Be $true -Because "Catalog resources must have valid roles to ensure proper access provisioning. Stale or removed app roles and service principals can cause assignment failures." + } +} diff --git a/website/docs/tests/maester/MT.1107.md b/website/docs/tests/maester/MT.1107.md new file mode 100644 index 000000000..78458b9f1 --- /dev/null +++ b/website/docs/tests/maester/MT.1107.md @@ -0,0 +1,33 @@ +--- +title: MT.1107 - Access packages and catalogs should not reference deleted groups +sidebar_label: MT.1107 +description: Checks if Entra ID Governance access packages or catalogs reference deleted groups +slug: /tests/MT.1107 +sidebar_class_name: hidden +--- + +# MT.1107 - Access packages and catalogs should not reference deleted groups + +## Description + +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 + +## How to fix + +1. Navigate to [Entra ID Governance](https://portal.azure.com/#view/Microsoft_Azure_ELMAdmin) +2. Review the test results to identify which access packages/catalogs reference deleted groups +3. For each affected resource: + - Either restore the deleted group from the recycle bin + - Or remove the group reference from the access package/catalog +4. Re-run the test to verify the issue is resolved + +## Learn more + +- [Manage access packages in Entra ID Governance](https://learn.microsoft.com/en-us/entra/id-governance/entitlement-management-access-package-create) +- [Access package catalogs](https://learn.microsoft.com/en-us/entra/id-governance/entitlement-management-catalog-create) + +%TestResult% diff --git a/website/docs/tests/maester/MT.1108.md b/website/docs/tests/maester/MT.1108.md new file mode 100644 index 000000000..2d948c964 --- /dev/null +++ b/website/docs/tests/maester/MT.1108.md @@ -0,0 +1,37 @@ +--- +title: MT.1108 - Access packages should not reference inactive or orphaned assignment policies +sidebar_label: MT.1108 +description: Checks if access packages reference assignment policies that are disabled, misconfigured, or orphaned +slug: /tests/MT.1108 +sidebar_class_name: hidden +--- + +# MT.1108 - Access packages should not reference inactive or orphaned assignment policies + +## Description + +This test identifies access packages in Microsoft Entra ID Governance that contain assignment policies which are disabled, misconfigured, or orphaned. Inactive or misconfigured policies can cause: +- Blocked access requests from users +- Broken approval workflows +- Failed provisioning and deprovisioning +- Configuration drift and orphaned policies + +## How to fix + +1. Navigate to [Entra ID Governance](https://portal.azure.com/#view/Microsoft_Azure_ELMAdmin) +2. Review the test results to identify which access packages have inactive or misconfigured policies +3. For each affected access package: + - Review the policy state and publish if needed + - Configure requestor settings with valid scope types + - Add or update approval stages and approvers + - Update or remove expired policies + - Configure required questions properly +4. Remove policies that are no longer needed +5. Re-run the test to verify the issue is resolved + +## Learn more + +- [Configure access package request settings](https://learn.microsoft.com/en-us/entra/id-governance/entitlement-management-access-package-request-policy) +- [Configure approval settings for access packages](https://learn.microsoft.com/en-us/entra/id-governance/entitlement-management-access-package-approval-policy) + +%TestResult% diff --git a/website/docs/tests/maester/MT.1109.md b/website/docs/tests/maester/MT.1109.md new file mode 100644 index 000000000..2008e4203 --- /dev/null +++ b/website/docs/tests/maester/MT.1109.md @@ -0,0 +1,37 @@ +--- +title: MT.1109 - Access package approval workflows must have valid approvers +sidebar_label: MT.1109 +description: Checks if approval workflows reference valid, active approvers that are not deleted or disabled +slug: /tests/MT.1109 +sidebar_class_name: hidden +--- + +# MT.1109 - Access package approval workflows must have valid approvers + +## Description + +This test identifies access package assignment policies in Microsoft Entra ID Governance that have approval workflows referencing invalid approvers. Invalid approvers can cause: +- Blocked access requests when approvers don't exist +- Workflow timeouts waiting for non-existent approvers +- Failed approval stages causing request failures +- Manual intervention required for urgent access + +## How to fix + +1. Navigate to [Entra ID Governance](https://portal.azure.com/#view/Microsoft_Azure_ELMAdmin) +2. Review the test results to identify which access packages have invalid approvers +3. For each affected access package policy: + - Remove references to deleted or disabled users + - Add valid replacement approvers + - Ensure approval groups exist and have active members + - Add members to empty approval groups + - Consider using groups instead of individual users for resilience +4. Test the approval workflow to ensure it functions correctly +5. Re-run the test to verify the issue is resolved + +## Learn more + +- [Configure approval settings for access packages](https://learn.microsoft.com/en-us/entra/id-governance/entitlement-management-access-package-approval-policy) +- [Approval workflow settings](https://learn.microsoft.com/en-us/entra/id-governance/entitlement-management-access-package-request-policy) + +%TestResult% diff --git a/website/docs/tests/maester/MT.1110.md b/website/docs/tests/maester/MT.1110.md new file mode 100644 index 000000000..25c25181e --- /dev/null +++ b/website/docs/tests/maester/MT.1110.md @@ -0,0 +1,34 @@ +--- +title: MT.1110 - No catalog should contain resources without any associated access packages +sidebar_label: MT.1110 +description: Checks if catalogs contain orphaned resources that are not used in any access package +slug: /tests/MT.1110 +sidebar_class_name: hidden +--- + +# MT.1110 - No catalog should contain resources without any associated access packages + +## Description + +This test identifies access package catalogs in Microsoft Entra ID Governance that contain resources (groups, applications, SharePoint sites) not used in any access package. Orphaned resources can indicate: +- Incomplete access package configuration +- Leftover resources from deleted packages +- Configuration drift over time +- Administrative overhead maintaining unused resources + +## How to fix + +1. Navigate to [Entra ID Governance](https://portal.azure.com/#view/Microsoft_Azure_ELMAdmin) +2. Review the test results to identify which catalogs have orphaned resources +3. For each affected catalog resource: + - Either add the resource to an access package if it should be governed + - Or remove the resource from the catalog if it's no longer needed +4. Document decisions for future reference +5. Re-run the test to verify the issue is resolved + +## Learn more + +- [Manage catalog resources](https://learn.microsoft.com/en-us/entra/id-governance/entitlement-management-access-package-resources) +- [Access package catalogs](https://learn.microsoft.com/en-us/entra/id-governance/entitlement-management-catalog-create) + +%TestResult% diff --git a/website/docs/tests/maester/MT.1111.md b/website/docs/tests/maester/MT.1111.md new file mode 100644 index 000000000..b2ee50465 --- /dev/null +++ b/website/docs/tests/maester/MT.1111.md @@ -0,0 +1,36 @@ +--- +title: MT.1111 - Catalog resources must have valid roles (no stale / removed app roles or SPNs) +sidebar_label: MT.1111 +description: Checks if catalog resources reference valid service principals, app roles, and accessible SharePoint sites +slug: /tests/MT.1111 +sidebar_class_name: hidden +--- + +# MT.1111 - Catalog resources must have valid roles (no stale / removed app roles or SPNs) + +## Description + +This test identifies catalog resources in Microsoft Entra ID Governance that reference stale or invalid roles, deleted service principals, or non-existent SharePoint sites. Stale resources can cause: +- Access provisioning failures when users request access +- Broken approval workflows +- User assignment errors preventing access +- Manual intervention required to fix failed provisioning + +## How to fix + +1. Navigate to [Entra ID Governance](https://portal.azure.com/#view/Microsoft_Azure_ELMAdmin) +2. Review the test results to identify which catalog resources have stale roles or deleted SPNs +3. For each affected resource: + - For deleted applications: Remove from catalog or restore the application + - For stale app roles: Update access packages to remove invalid roles or contact app owner to restore roles + - For SharePoint sites: Remove from catalog, fix the URL, or restore deleted sites +4. Update access packages that referenced the stale resources +5. Re-run the test to verify the issue is resolved + +## Learn more + +- [Manage catalog resources](https://learn.microsoft.com/en-us/entra/id-governance/entitlement-management-access-package-resources) +- [Service principal app roles](https://learn.microsoft.com/graph/api/resources/approle) +- [SharePoint site resource type](https://learn.microsoft.com/graph/api/resources/site) + +%TestResult%