Public/Get-AACOrphanedIdentity.ps1

function Get-AACOrphanedIdentity {
    <#
    .EXTERNALHELP Azure.Admin.Console-help.xml
    .SYNOPSIS
        Finds the identities nobody uses any more - role assignments to
        deleted principals, user-assigned identities attached to nothing,
        managed identities of deleted resources, service principals that
        haven't signed in for months, app registrations with only expired
        credentials or no owners - and the access each still holds.
    .DESCRIPTION
        Reads, read-only:
          Resource Graph user-assigned identities and the resources (and
                             AKS clusters' kubelets) that use them; every
                             resource's system-assigned identity; the role
                             assignments and definitions, tenant-wide
          Resource Manager the federated credentials of each user-assigned
                             identity nothing uses (a workload identity
                             federation counts as use)
          Microsoft Graph the role assignments' principals (which still
                             exist), the tenant's application service
                             principals and managed identities, the app
                             registrations (credentials, owners) and the
                             service principals' last sign-in
 
        Each identity (AAC.OrphanedIdentity) has its kind, why it looks
        unused, its last sign-in, its role assignments (how many, which),
        credentials and owners, and what to do. Severity follows the access
        left behind: High with a privileged role (Owner, Contributor, User
        Access Administrator, RBAC Administrator), Medium with other roles
        or only expired credentials, Low with none.
 
        "Unused" is an inference: a quarterly job, a disaster-recovery
        pipeline or a break-glass app can be quiet for months. Confirm with
        the owner, disable before deleting. Reader in Azure; for Entra ID
        Directory.Read.All and Application.Read.All, and AuditLog.Read.All
        with Entra ID P1 for sign-in activity. -SkipEntra checks the
        user-assigned identities only.
    .PARAMETER SubscriptionId
        Only these subscriptions (role assignments at management group and
        root scope always count).
    .PARAMETER ManagementGroupId
        Only the subscriptions under these management groups (at any depth).
    .PARAMETER Kind
        Only these kinds: DeletedPrincipal, UserAssignedIdentity,
        ManagedIdentity, Application.
    .PARAMETER Severity
        Only these severities (High, Medium, Low).
    .PARAMETER InactiveDays
        A service principal not signed in for this many days is inactive.
        Default 90.
    .PARAMETER SkipEntra
        Don't read Entra ID (Microsoft Graph): user-assigned identities
        only.
    .PARAMETER CsvPath
        Write the identities to this CSV file.
    .PARAMETER HtmlPath
        Write an interactive HTML report.
    .PARAMETER PdfPath
        Write a PDF report.
    .PARAMETER Title
        The reports' title.
    .PARAMETER PassThru
        Show the view and also return the identities.
    .PARAMETER NoDisplay
        Return the identities without showing the view.
    .PARAMETER NoPaging
        Show the whole view at once.
    .EXAMPLE
        Get-AACOrphanedIdentity
        Every identity that looks unused, the ones with the most access first.
    .EXAMPLE
        Get-AACOrphanedIdentity -Kind DeletedPrincipal -NoDisplay | Select-Object PrincipalId, Roles
        The role assignments left behind by deleted principals.
    .EXAMPLE
        Get-AACOrphanedIdentity -Kind Application -InactiveDays 180 -CsvPath .\out\StaleApps.csv
        Applications not signed in for six months, for review.
    .OUTPUTS
        AAC.OrphanedIdentity
    #>

    [CmdletBinding()]
    [OutputType('AAC.OrphanedIdentity')]
    param(
        [ValidatePattern('^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$')]
        [string[]] $SubscriptionId,

        [string[]] $ManagementGroupId,

        [ValidateSet('DeletedPrincipal', 'UserAssignedIdentity', 'ManagedIdentity', 'Application')]
        [string[]] $Kind,

        [ValidateSet('High', 'Medium', 'Low')]
        [string[]] $Severity,

        [ValidateRange(30, 730)]
        [int] $InactiveDays = 90,

        [switch] $SkipEntra,

        [string] $CsvPath,

        [string] $HtmlPath,

        [string] $PdfPath,

        [string] $Title = 'Orphaned identities',

        [switch] $PassThru,

        [switch] $NoDisplay,

        [switch] $NoPaging
    )

    trap { if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { return }; $PSCmdlet.ThrowTerminatingError((Show-AACError -ErrorRecord $_ -Cmdlet $PSCmdlet)) }

    $pipedOnward = $MyInvocation.PipelinePosition -lt $MyInvocation.PipelineLength
    $interactive = -not $NoDisplay -and -not $pipedOnward
    $resolve = { param([string] $Path) if ($Path) { $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) } }
    $request = @{ SubscriptionId = @($SubscriptionId | Where-Object { $_ }); ManagementGroupId = @($ManagementGroupId | Where-Object { $_ }); InactiveDays = $InactiveDays; Entra = -not $SkipEntra; TenantId = [string]$(if ($script:AACSession) { $script:AACSession.TenantId }) }

    $null = Get-AACAccessToken
    if ($interactive) { Write-AACRule -Title 'Azure Admin Console :: Orphaned identities' -Color 'mediumpurple' }
    $outcome = Invoke-AACProgress -ScriptBlock {
        Update-AACProgress -Id 'scope' -Indeterminate -Description 'Finding the subscriptions'
        $scope = Resolve-AACScope -SubscriptionId $request.SubscriptionId -ManagementGroupId $request.ManagementGroupId
        Update-AACProgress -Id 'scope' -Complete -Description "Scope: $($scope.Label)"
        $q = Get-AACOrphanedIdentityQuery
        Update-AACProgress -Id 'read' -Total $q.Graph.Count -Description 'Reading identities and role assignments'
        $read = Invoke-AACGraphBatch -Query $q.Graph -SubscriptionId $scope.GraphScope -AllowFailure @('attached', 'systemIdentities', 'roleDefinitions') -OnProgress {
            param($QueryName, $Done, $Total)
            Update-AACProgress -Id 'read' -Increment 1 -Description "Read the $QueryName ($Done of $Total)"
        }
        Update-AACProgress -Id 'read' -Complete -Description ('Read {0:N0} user-assigned identit(ies) and {1:N0} role assignment(s)' -f @($read.Rows['userIdentities']).Count, @($read.Rows['roleAssignments']).Count)
        $notices = [System.Collections.Generic.List[string]]::new()

        # Federated credentials of the user-assigned identities nothing is attached to.
        $federated = @{}
        $used = @{}
        foreach ($row in @($read.Rows['attached'] | Where-Object { $_ })) {
            if ($row['identities'] -is [System.Collections.IDictionary]) { foreach ($k in $row['identities'].Keys) { $used[([string]$k).ToLowerInvariant()] = $true } }
            if ($row['kubelet']) { $used[[string]$row['kubelet']] = $true }
        }
        $loose = @($read.Rows['userIdentities'] | Where-Object { $_ -and -not $used.Contains([string]$_['id']) -and $scope.Names.Contains(([string]$_['subscriptionId']).ToLowerInvariant()) })
        if ($loose.Count) {
            if ($loose.Count -gt 300) { $notices.Add("$($loose.Count) unattached identities: the federated credentials of the first 300 were read."); $loose = @($loose | Select-Object -First 300) }
            $uris = [ordered]@{}
            foreach ($row in $loose) { $uris[[string]$row['id']] = "$($row['id'])/federatedIdentityCredentials?api-version=2023-01-31" }
            Update-AACProgress -Id 'federated' -Total $uris.Count -Description "Reading the federated credentials of $($uris.Count) unattached identit(ies)"
            $answers = Invoke-AACArmParallel -Uri @($uris.Values) -OnProgress { param($FedDone, $FedTotal) Update-AACProgress -Id 'federated' -Increment 1 }
            foreach ($id in $uris.Keys) { $answer = $answers[$uris[$id]]; if ($answer -and -not $answer.Error) { $federated[$id] = @($answer.Items | Where-Object { $_ }).Count } }
            Update-AACProgress -Id 'federated' -Complete -Description ('Read the federated credentials of {0:N0} identit(ies)' -f $federated.Count)
        }

        $directory = $null
        $entra = $null
        if ($request.Entra) {
            $principals = @($read.Rows['roleAssignments'] | Where-Object { $_ } | ForEach-Object { ([string]$_['principalId']).ToLowerInvariant() } | Where-Object { $_ } | Select-Object -Unique)
            Update-AACProgress -Id 'directory' -Indeterminate -Description "Looking up $($principals.Count) principal(s) in Entra ID"
            $directory = if ($principals.Count) { Get-AACDirectoryObject -Id $principals } else { @{ Objects = @{}; Error = '' } }
            Update-AACProgress -Id 'directory' -Complete -Description ('Found {0:N0} of {1:N0} principal(s)' -f $directory.Objects.Count, $principals.Count)
            Update-AACProgress -Id 'entra' -Total $q.Entra.Count -Description 'Reading service principals, app registrations and sign-in activity'
            $entra = Read-AACGraphQuery -Query $q.Entra -OnDone { param($Key, $Failure, $GraphDone, $GraphTotal) Update-AACProgress -Id 'entra' -Increment 1 }
            Update-AACProgress -Id 'entra' -Complete -Description "Read $($q.Entra.Count - $entra.Errors.Count) of $($q.Entra.Count) Entra ID queries"
        }
        $result = ConvertTo-AACOrphanedIdentity -Read $read -Federated $federated -Directory $directory -Entra $entra -TenantId $request.TenantId -InactiveDays $request.InactiveDays -SubscriptionName $scope.Names
        @{ Result = $result; Scope = $scope; Notices = @($result.Notices) + $notices.ToArray() }
    }

    $identities = @($outcome.Result.Identities)
    if ($Kind) { $kinds = @{ DeletedPrincipal = 'Deleted principal'; UserAssignedIdentity = 'User-assigned identity'; ManagedIdentity = 'Managed identity'; Application = 'Application' }; $wanted = @($Kind | ForEach-Object { $kinds[$_] }); $identities = @($identities | Where-Object { $wanted -contains $_.Kind }) }
    if ($Severity) { $identities = @($identities | Where-Object { $Severity -contains $_.Severity }) }

    $rank = Get-AACSeverityRank
    $high = @($identities | Where-Object Severity -EQ 'High').Count
    $withAccess = @($identities | Where-Object RoleAssignments -GT 0).Count
    $kindTones = @{ 'Deleted principal' = 'bad'; 'User-assigned identity' = 'info'; 'Managed identity' = 'warn'; Application = 'violet' }
    $report = @{
        Subtitle = 'Orphaned identities: unused service principals, managed identities and leftover role assignments'
        Facts    = [ordered]@{ Scope = $outcome.Scope.Label; Inactive = "no sign-in for $InactiveDays days"; 'Entra ID' = $(if ($SkipEntra) { 'not read (-SkipEntra)' } else { 'read' }) }
        Status   = $(if ($high) { 'Failed' } elseif ($identities.Count) { 'Warning' } else { 'Success' })
        Headline = $(if ($identities.Count) { "$($identities.Count) identit(ies) look unused: $withAccess still hold role assignments, $high a privileged role" } else { 'No orphaned identities found.' })
        Tiles    = @(
            @{ Value = '{0:N0}' -f $identities.Count; Label = 'orphaned identities'; Tone = $(if ($identities.Count) { 'warn' } else { 'good' }) }
            @{ Value = '{0:N0}' -f $high; Label = 'with a privileged role'; Tone = $(if ($high) { 'bad' } else { 'good' }); Table = 'identities'; Filters = @{ Severity = 'High' } }
            @{ Value = '{0:N0}' -f @($identities | Where-Object Kind -EQ 'Deleted principal').Count; Label = 'deleted principals'; Tone = 'bad'; Table = 'identities'; Filters = @{ Kind = 'Deleted principal' } }
            @{ Value = '{0:N0}' -f @($identities | Where-Object Kind -EQ 'User-assigned identity').Count; Label = 'unattached identities'; Tone = 'info'; Table = 'identities'; Filters = @{ Kind = 'User-assigned identity' } }
            @{ Value = '{0:N0}' -f @($identities | Where-Object Kind -EQ 'Application').Count; Label = 'applications'; Tone = 'violet'; Table = 'identities'; Filters = @{ Kind = 'Application' } }
            @{ Value = '{0:N0}' -f $withAccess; Label = 'still hold roles'; Tone = $(if ($withAccess) { 'warn' } else { 'good' }) }
        )
        Notices  = @($outcome.Notices | ForEach-Object { @{ Status = 'Warning'; Text = $_ } })
        Charts   = @(
            @{ Title = 'By kind'; Kind = 'donut'; CenterLabel = 'identities'; Items = @($identities | Group-Object Kind | Sort-Object Count -Descending | ForEach-Object { @{ Label = $_.Name; Value = $_.Count; Tone = $kindTones[$_.Name]; Filter = $_.Name } }); Table = 'identities'; Column = 'Kind' }
            @{ Title = 'By severity'; Items = @(foreach ($s in 'High', 'Medium', 'Low') { $n = @($identities | Where-Object Severity -EQ $s).Count; if ($n) { @{ Label = $s; Value = $n; Tone = $rank.Tone[$s]; Filter = $s } } }); Table = 'identities'; Column = 'Severity'; Tone = 'warn'; Console = $true }
            @{ Title = 'Most role assignments'; Items = @($identities | Where-Object RoleAssignments | Sort-Object RoleAssignments -Descending | Select-Object -First 10 | ForEach-Object { @{ Label = $_.Identity; Value = $_.RoleAssignments; Filter = $_.Identity } }); Table = 'identities'; Column = 'Identity'; Tone = 'bad' }
        )
        Tables   = @(
            @{ Id = 'identities'; Title = 'Orphaned identities'; Section = 'Identities'; Rows = $identities; Noun = 'identities'; GroupBy = @('Kind', 'Severity'); ConsoleLimit = 30
                Empty = 'No orphaned identities found.'; EmptyStatus = 'Success'
                Columns = @(
                    @{ Key = 'Severity'; Label = 'Severity'; Type = 'badge'; Tones = $rank.Tone; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'Kind'; Label = 'Kind'; Type = 'badge'; Tones = $kindTones; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'Identity'; Label = 'Identity'; Console = $true; Pdf = $true }
                    @{ Key = 'RoleAssignments'; Label = 'Roles'; Type = 'number'; Console = $true; Pdf = $true }
                    @{ Key = 'Reason'; Label = 'Why'; Type = 'wide'; Console = $true; Pdf = $true }
                    @{ Key = 'Roles'; Label = 'Role assignments'; Type = 'wide' }
                    @{ Key = 'LastSignIn'; Label = 'Last sign-in'; Type = 'date' }
                    @{ Key = 'DaysInactive'; Label = 'Days inactive'; Type = 'number' }
                    @{ Key = 'Credentials'; Label = 'Credentials' }
                    @{ Key = 'Owners'; Label = 'Owners'; Type = 'number' }
                    @{ Key = 'Remediation'; Label = 'What to do'; Type = 'wide'; Pdf = $true }
                    @{ Key = 'AppId'; Label = 'App / client ID'; Type = 'mono' }
                    @{ Key = 'PrincipalId'; Label = 'Object ID'; Type = 'mono' }
                    @{ Key = 'Subscription'; Label = 'Subscription'; Facet = $true }
                ) }
        )
        Hint     = '-Kind narrows it (DeletedPrincipal, UserAssignedIdentity, ManagedIdentity, Application); -InactiveDays sets what inactive means; -NoDisplay returns the identities; -HtmlPath, -PdfPath or -CsvPath for a report.'
    }
    Invoke-AACReportOutput -Report $report -Title $Title -CsvObject $identities -Noun 'identity' -CsvPath (& $resolve $CsvPath) -HtmlPath (& $resolve $HtmlPath) -PdfPath (& $resolve $PdfPath) `
        -ShowView:$interactive -NoPaging:$NoPaging -Object $identities -ReturnObject:($PassThru -or $NoDisplay -or $pipedOnward)
}