Public/Find-AzureUtilsStaleRoleAssignment.ps1
|
function Find-AzureUtilsStaleRoleAssignment { <# .SYNOPSIS Finds role assignments whose principal no longer exists in Entra ID. .DESCRIPTION Lists every RBAC role assignment in scope via Azure Resource Graph ('authorizationresources'), then resolves each distinct principal id against Microsoft Graph (Entra ID). Assignments whose principal did NOT resolve are orphaned - the user, group or service principal was deleted but the assignment lingers (the Azure portal shows these as 'Identity not found'). Returns one object per orphaned assignment. Why not do this in the query alone: the Resource Graph 'principalType' is frozen when the assignment is created and does NOT change when the principal is deleted, so orphaned assignments are indistinguishable in ARG. Detecting them requires resolving the principal id against the directory, done here with Invoke-AzRestMethod (Az.Accounts) - no extra module needed. Requires a directory-read permission (e.g. Directory.Read.All) on the signed-in identity; if Graph cannot be queried, the cmdlet throws rather than reporting every assignment as orphaned. .PARAMETER SubscriptionId One or more subscription IDs. Default: every enabled subscription. .PARAMETER ManagementGroupId One or more management groups to scan (requires Az.ResourceGraph). .EXAMPLE Find-AzureUtilsStaleRoleAssignment .EXAMPLE Find-AzureUtilsStaleRoleAssignment -ManagementGroupId 'PLAT' | Export-Csv .\stale-rbac.csv -NoTypeInformation .OUTPUTS AzureUtils.StaleRoleAssignment #> [CmdletBinding(DefaultParameterSetName = 'Subscriptions')] [OutputType('AzureUtils.StaleRoleAssignment')] param( [Parameter(ParameterSetName = 'Subscriptions')] [string[]] $SubscriptionId, [Parameter(ParameterSetName = 'ManagementGroup', Mandatory)] [string[]] $ManagementGroupId ) $null = Assert-AzureUtilsContext $resolved = Resolve-AzureUtilsScope -SubscriptionId $SubscriptionId -ManagementGroupId $ManagementGroupId if ($resolved.Type -eq 'Subscription' -and -not $resolved.HasSubscriptions) { Write-Warning 'No accessible subscriptions in scope.' return } $query = New-AzureUtilsRoleAssignmentQuery $nameMap = $resolved.NameMap $scope = $resolved.Scope Write-Verbose "Role-assignment query:`n$query" # Collect all assignments first so principal ids can be resolved in one batch. $assignments = @(Invoke-AzureUtilsGraphQuery -Query $query @scope) if ($assignments.Count -eq 0) { return } $principalIds = @($assignments | ForEach-Object { [string]$_.principalId } | Where-Object { $_ }) Write-Verbose "Resolving $($principalIds.Count) principal id(s) against Entra ID..." $existing = Get-AzureUtilsExistingPrincipal -PrincipalId $principalIds foreach ($a in $assignments) { $principalId = [string]$a.principalId if ([string]::IsNullOrWhiteSpace($principalId) -or $existing.Contains($principalId)) { continue } $subId = [string]$a.subscriptionId [pscustomobject]@{ PSTypeName = 'AzureUtils.StaleRoleAssignment' PrincipalId = $principalId PrincipalType = $a.principalType RoleDefinitionId = $a.roleDefinitionId Scope = $a.scope SubscriptionName = if ($nameMap.ContainsKey($subId)) { $nameMap[$subId] } else { $subId } SubscriptionId = $subId Reason = 'Principal no longer exists in Entra ID (orphaned assignment)' AssignmentId = $a.id } } } |