Public/Entra/Object/Get-MgUserIdentitySummary.ps1

<#
    .SYNOPSIS
    Summarizes how every user of a Microsoft Entra ID tenant authenticates, internal and external alike.
 
    .DESCRIPTION
    Returns one row per distinct combination of (SignInType, Issuer, UserType, CreationType,
    InvitationState, IsExternal) with the number of identities matching it. It gives a one-page
    picture of the authentication landscape of the tenant.
 
    The granularity is the identity, not the user. A cloud-only internal user carries a single
    identity ('userPrincipalName' issued by this tenant). An external account that redeemed its
    invitation carries two: the local 'userPrincipalName' one plus a 'federated' one issued by its
    identity provider. An account that never redeemed its invitation only carries the local one,
    which is exactly what this view makes visible.
 
    Because internal and external accounts can share the very same (SignInType, Issuer) pair, the
    IsExternal column tells them apart. It uses the same detection as Get-MgExternalUser, so both
    reports always agree.
 
    .PARAMETER ExternalOnly
    (Optional) Restricts the summary to external accounts. Without it, every user of the tenant is
    included, which is the point of this function.
 
    .PARAMETER Anonymize
    (Optional) Replaces the real issuer domains with placeholders: <OUR-TENANT-DOMAIN> for a domain
    belonging to this tenant, <OTHER-DOMAIN> for any third-party domain. Well-known issuers
    (ExternalAzureAD, MSA, mail, google.com, ...) are kept as-is.
    Use it to share the summary outside of the organization.
    Requires the Domain.Read.All permission to know which domains belong to this tenant.
 
    .PARAMETER ForceNewToken
    Switch parameter to force getting a new token from Microsoft Graph.
 
    .PARAMETER ExportToExcel
    (Optional) If specified, exports the results to an Excel file in the user's profile directory.
 
    .PARAMETER ExportPath
    (Optional) Output directory for the Excel export. Defaults to the user profile.
 
    .EXAMPLE
    Get-MgUserIdentitySummary | Format-Table -AutoSize
 
    Displays the identity summary of the whole tenant as a table. Format-Table is needed because the
    objects carry more than four properties, which PowerShell would otherwise render as a list.
 
    .EXAMPLE
    Get-MgUserIdentitySummary -ExternalOnly | Format-Table -AutoSize
 
    Same summary restricted to the external accounts.
 
    .EXAMPLE
    Get-MgUserIdentitySummary -Anonymize | Format-Table -AutoSize
 
    Same summary with the real domains masked, suitable for sharing outside of the organization.
 
    .EXAMPLE
    Get-MgUserIdentitySummary -ExportToExcel
 
    Exports the identity summary to an Excel file in the user's profile directory.
 
    .OUTPUTS
    System.Collections.Generic.List[PSCustomObject]
 
    .NOTES
    OUTPUT PROPERTIES
    - Count : number of identities matching the combination
    - SignInType : signInType of the identity ('userPrincipalName', 'federated', ...)
                        '<no identities>' when Graph returned no identity at all for the account
    - Issuer : issuer of the identity (a tenant domain, ExternalAzureAD, MSA, mail, ...)
    - UserType : userType of the account holding the identity (Member or Guest)
    - CreationType : creationType of the account ('Invitation' when it comes from a B2B invitation)
    - InvitationState : Graph 'externalUserState' property (PendingAcceptance, Accepted, or empty)
    - IsExternal : whether the account holding the identity is external (see Get-MgExternalUser)
 
    Required Microsoft Graph permissions:
        - User.Read.All
        - Domain.Read.All (only with -Anonymize)
 
    .LINK
    https://ps365.clidsys.com/docs/commands/Get-MgUserIdentitySummary
#>


function Get-MgUserIdentitySummary {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $false)]
        [switch]$ExternalOnly,

        [Parameter(Mandatory = $false)]
        [switch]$Anonymize,

        [Parameter(Mandatory = $false)]
        [switch]$ForceNewToken,

        [Parameter(Mandatory = $false)]
        [switch]$ExportToExcel,

        [Parameter(Mandatory = $false, HelpMessage = 'Optional output directory for the Excel export (defaults to the user profile).')]
        [string]$ExportPath
    )

    try {
        $null = Import-Module 'Microsoft.Graph.Authentication' -ErrorAction Stop
    }
    catch {
        Write-Warning 'Please install Microsoft.Graph.Authentication first'
        return
    }

    $permissionsNeeded = @('User.Read.All')
    if ($Anonymize.IsPresent) {
        # Telling our own domains apart from third-party ones requires reading the tenant domains
        $permissionsNeeded += 'Domain.Read.All'
    }

    $isConnected = $null -ne (Get-MgContext -ErrorAction SilentlyContinue)
    if ($ForceNewToken.IsPresent) {
        $null = Disconnect-MgGraph -ErrorAction SilentlyContinue
        $isConnected = $false
    }
    if (-not $isConnected) {
        Write-Host -ForegroundColor Cyan 'Connecting to Microsoft Graph'
        $null = Connect-MgGraph -Scopes $permissionsNeeded -NoWelcome
    }

    if (-not (Test-MgGraphPermission -RequiredScopes $permissionsNeeded -CallerName $MyInvocation.MyCommand.Name)) {
        return
    }

    [System.Collections.Generic.List[string]]$tenantDomains = @()
    if ($Anonymize.IsPresent) {
        Write-Host -ForegroundColor Cyan 'Retrieving tenant domains'
        $domainsUri = 'https://graph.microsoft.com/v1.0/domains?$select=id&$top=999'
        try {
            do {
                $domainsResponse = Invoke-MgGraphRequestWithRetry -Method GET -Uri $domainsUri
                foreach ($domain in $domainsResponse.value) {
                    $tenantDomains.Add($domain.id)
                }
                $domainsUri = $domainsResponse.'@odata.nextLink'
            } while ($domainsUri)
        }
        catch {
            Write-Warning "Unable to retrieve the tenant domains: $_"
            Write-Warning 'Tenant domains will be masked as <OTHER-DOMAIN> like any third-party domain.'
        }
    }

    # Issuers that are not domains, or whose name carries no information about the organization
    $knownIssuers = @('ExternalAzureAD', 'MSA', 'mail', 'PhoneNumber', 'DefaultDirectory',
        'google.com', 'facebook.com', 'twitter.com', 'linkedin.com', 'github.com', 'apple.com', 'amazon.com')

    $selectProperties = 'id,userPrincipalName,userType,creationType,externalUserState,identities'
    $uri = "https://graph.microsoft.com/v1.0/users?`$select=$selectProperties&`$top=999"

    Write-Host -ForegroundColor Cyan 'Retrieving users from Microsoft Entra ID'

    [System.Collections.Generic.List[PSCustomObject]]$identityRows = @()
    $processedCount = 0
    $externalCount = 0

    do {
        try {
            $response = Invoke-MgGraphRequestWithRetry -Method GET -Uri $uri -ErrorAction Stop
        }
        catch {
            Write-Warning "Unable to retrieve users: $_"
            return
        }

        foreach ($user in $response.value) {
            $processedCount++

            $isExternal = Test-MgUserIsExternal -User $user
            if ($isExternal) {
                $externalCount++
            }
            elseif ($ExternalOnly.IsPresent) {
                continue
            }

            # An account with no identity at all still deserves a row, otherwise it silently vanishes
            $identities = if ($user.identities) {
                $user.identities
            }
            else {
                @(@{ signInType = '<no identities>'; issuer = $null })
            }

            foreach ($identity in $identities) {
                $issuerValue = $identity.issuer

                if ($Anonymize.IsPresent) {
                    $issuerValue = if (-not $issuerValue) { '<none>' }
                                   elseif ($issuerValue -in $knownIssuers) { $issuerValue }
                                   elseif ($issuerValue -in $tenantDomains) { '<OUR-TENANT-DOMAIN>' }
                                   else { '<OTHER-DOMAIN>' }
                }

                $identityRows.Add([PSCustomObject][ordered]@{
                        SignInType      = $identity.signInType
                        Issuer          = $issuerValue
                        UserType        = $user.userType
                        CreationType    = $user.creationType
                        InvitationState = $user.externalUserState
                        IsExternal      = $isExternal
                    })
            }
        }

        $uri = $response.'@odata.nextLink'
        Write-Progress -Activity 'Retrieving users' -Status "$processedCount user(s) scanned - $externalCount external user(s) found"
    } while ($uri)

    Write-Progress -Activity 'Retrieving users' -Completed

    if ($identityRows.Count -eq 0) {
        Write-Host -ForegroundColor Yellow "No user found among the $processedCount user(s) scanned."
        return
    }

    Write-Host -ForegroundColor Green "Scanned $processedCount user(s): $externalCount external, $($processedCount - $externalCount) internal, for $($identityRows.Count) identity(ies) summarized."

    [System.Collections.Generic.List[PSCustomObject]]$summaryArray = @()
    $groups = $identityRows |
        Group-Object SignInType, Issuer, UserType, CreationType, InvitationState, IsExternal |
        Sort-Object Count -Descending

    foreach ($group in $groups) {
        $sample = $group.Group[0]
        $summaryArray.Add([PSCustomObject][ordered]@{
                Count           = $group.Count
                SignInType      = $sample.SignInType
                Issuer          = $sample.Issuer
                UserType        = $sample.UserType
                CreationType    = $sample.CreationType
                InvitationState = $sample.InvitationState
                IsExternal      = $sample.IsExternal
            })
    }

    if ($ExportToExcel.IsPresent) {
        $now = Get-Date -Format 'yyyy-MM-dd_HHmmss'
        $exportDirectory = if ($ExportPath) { $ExportPath } else { $env:userprofile }
        $excelFilePath = Join-Path -Path $exportDirectory -ChildPath "$now-MgUserIdentitySummary.xlsx"
        Write-Host -ForegroundColor Cyan "Exporting identity summary to Excel file: $excelFilePath"
        $summaryArray | Export-Excel -Path $excelFilePath -AutoSize -AutoFilter -WorksheetName 'Entra-IdentitySummary'
        Write-Host -ForegroundColor Green 'Export completed successfully!'
    }
    else {
        return $summaryArray
    }
}