Entra/Get-UserSummary.ps1

<#
.SYNOPSIS
    Generates a summary of Entra ID user counts by type and status.
.DESCRIPTION
    Queries Microsoft Graph for all users and produces aggregate counts including
    total users, licensed users, guest accounts, disabled accounts, on-prem synced
    accounts, cloud-only accounts, and users with recent sign-in activity. Useful
    for tenant health checks and security assessments.
 
    Uses Invoke-MgGraphRequest with pagination for reliable operation across all
    tenant types and licensing tiers.
 
    Requires Microsoft.Graph.Authentication module and an active Graph connection
    with User.Read.All permission. AuditLog.Read.All is optional (enables
    sign-in activity tracking).
.PARAMETER OutputPath
    Optional path to export results as CSV. If not specified, results are returned
    to the pipeline.
.EXAMPLE
    PS> . .\Common\Connect-Service.ps1
    PS> Connect-Service -Service Graph -Scopes 'User.Read.All'
    PS> .\Entra\Get-UserSummary.ps1
 
    Displays a summary of user counts in the tenant.
.EXAMPLE
    PS> .\Entra\Get-UserSummary.ps1 -OutputPath '.\user-summary.csv'
 
    Exports user summary counts to CSV for reporting.
.NOTES
    M365 Assess
#>

[CmdletBinding()]
param(
    [Parameter()]
    [ValidateNotNullOrEmpty()]
    [string]$OutputPath
)

$ErrorActionPreference = 'Stop'

# Verify Graph connection
if (-not (Assert-GraphConnection)) { return }

# ------------------------------------------------------------------
# Retrieve all users via Graph API (paginated)
# ------------------------------------------------------------------
Write-Verbose "Retrieving all users (this may take a moment in large tenants)..."

$allUsers = [System.Collections.Generic.List[object]]::new()
$selectFields = 'id,displayName,userPrincipalName,userType,accountEnabled,assignedLicenses,onPremisesSyncEnabled,signInActivity'
$uri = "/v1.0/users?`$select=$selectFields&`$top=999"

# Try with signInActivity first; fall back without it if the tenant lacks AAD Premium
$fallback = $false
do {
    try {
        $response = Invoke-MgGraphRequest -Method GET -Uri $uri -Headers @{ 'ConsistencyLevel' = 'eventual' }
    }
    catch {
        # signInActivity requires AuditLog.Read.All + AAD Premium; retry without it
        if (-not $fallback -and $_.ToString() -match 'signInActivity|AuditLog|Authorization_RequestDenied|Insufficient privileges|Neither combinator') {
            Write-Warning "SignInActivity not available (requires AuditLog.Read.All + Azure AD Premium). Retrying without it."
            $fallback = $true
            $selectFields = 'id,displayName,userPrincipalName,userType,accountEnabled,assignedLicenses,onPremisesSyncEnabled'
            $uri = "/v1.0/users?`$select=$selectFields&`$top=999"
            $allUsers.Clear()
            continue
        }
        Write-Error "Failed to retrieve users from Microsoft Graph: $_"
        return
    }

    if ($response.value) {
        foreach ($user in $response.value) {
            $allUsers.Add($user)
        }
    }

    $uri = $response.'@odata.nextLink'
} while ($uri)

$totalUsers = $allUsers.Count

if ($totalUsers -eq 0) {
    Write-Warning "No users returned from Microsoft Graph. Check User.Read.All permission."
}

Write-Verbose "Processing $totalUsers users..."

# ------------------------------------------------------------------
# Count by category
# ------------------------------------------------------------------
$licensedCount = 0
$guestCount = 0
$disabledCount = 0
$syncedCount = 0
$cloudOnlyCount = 0
$activeSignInCount = 0

foreach ($user in $allUsers) {
    if ($user.assignedLicenses -and @($user.assignedLicenses).Count -gt 0) {
        $licensedCount++
    }

    if ($user.userType -eq 'Guest') {
        $guestCount++
    }

    if ($user.accountEnabled -eq $false) {
        $disabledCount++
    }

    if ($user.onPremisesSyncEnabled -eq $true) {
        $syncedCount++
    }
    else {
        $cloudOnlyCount++
    }

    # Sign-in activity (available only with AuditLog.Read.All + AAD Premium)
    if (-not $fallback -and $user.signInActivity.lastSignInDateTime) {
        $activeSignInCount++
    }
}

$report = @([PSCustomObject]@{
    TotalUsers       = $totalUsers
    Licensed         = $licensedCount
    GuestUsers       = $guestCount
    DisabledUsers    = $disabledCount
    SyncedFromOnPrem = $syncedCount
    CloudOnly        = $cloudOnlyCount
    WithMFA          = $activeSignInCount
})

Write-Verbose "User summary: $totalUsers total, $licensedCount licensed, $guestCount guests, $disabledCount disabled"

if ($OutputPath) {
    $report | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8
    Write-Output "Exported user summary to $OutputPath"
}
else {
    Write-Output $report
}