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
        }
    }
}