Src/Private/Get-AbrEntraIDUsers.ps1

function Get-AbrEntraIDUsers {
    <#
    .SYNOPSIS
    Documents the Microsoft Entra ID user inventory and account configuration.
    .DESCRIPTION
        Collects and reports on:
          - User summary statistics (totals, guests, admins, disabled)
          - User account details (UPN, type, account status, last sign-in)
          - Guest user inventory
    .NOTES
        Version: 0.1.21
        Author: Pai Wei Sing
    #>

    [CmdletBinding()]
    param (
        [Parameter(Position = 0, Mandatory)]
        [string]$TenantId
    )

    begin {
        Write-PScriboMessage -Message "Collecting Entra ID User information for tenant $TenantId."
        Show-AbrDebugExecutionTime -Start -TitleMessage 'Users'
    }

    process {
        #region Users
        # Section{} created unconditionally so catch{} always writes inside it (never to document root)
        Section -Style Heading2 'Users' {
            Paragraph "The following section documents the user accounts configured in tenant $TenantId."
            BlankLine
            if ($script:Charts['Users']) {
                Image -Text 'User Type Breakdown' -Base64 $script:Charts['Users'] -Percent 65 -Align Center
                Paragraph "Figure: User Breakdown -- $($script:ChartData['MemberUsers']) members, $($script:ChartData['GuestUsers']) guests, $($script:ChartData['DisabledUsers']) disabled"
                BlankLine
            }

            try {
                Write-Host " - Retrieving all users (this may take a moment)..."
                $AllUsers = Get-MgUser -All `
                    -Property Id,DisplayName,UserPrincipalName,UserType,AccountEnabled,CreatedDateTime,SignInActivity,AssignedLicenses,Mail,JobTitle,Department,OnPremisesSyncEnabled,LastPasswordChangeDateTime `
                    -ErrorAction Stop

                if ($AllUsers) {

                    #region User Statistics Summary
                    $TotalUsers    = @($AllUsers).Count
                    $MemberUsers   = @($AllUsers | Where-Object { $_.UserType -eq 'Member' }).Count
                    $GuestUsers    = @($AllUsers | Where-Object { $_.UserType -eq 'Guest' }).Count
                    $DisabledUsers = @($AllUsers | Where-Object { -not $_.AccountEnabled }).Count
                    # Store for chart caption at section top
                    $script:ChartData['MemberUsers']   = $MemberUsers
                    $script:ChartData['GuestUsers']    = $GuestUsers
                    $script:ChartData['DisabledUsers'] = $DisabledUsers
                    # Generate chart (outside PScribo pipeline)
                    try {
                        if (Get-Command New-AbrUserBreakdownDonut -ErrorAction SilentlyContinue) {
                            $script:Charts['Users'] = New-AbrUserBreakdownDonut -Members ([int]$MemberUsers) -Guests ([int]$GuestUsers) -Disabled ([int]$DisabledUsers) -TenantId $TenantId
                        }
                    } catch { Write-AbrDebugLog "User chart failed: $($_.Exception.Message)" 'WARN' 'CHART' }
                    $SyncedUsers   = @($AllUsers | Where-Object { $_.OnPremisesSyncEnabled -eq $true }).Count
                    $LicensedUsers = @($AllUsers | Where-Object { $_.AssignedLicenses.Count -gt 0 }).Count

                    $SummaryObj = [System.Collections.ArrayList]::new()
                    $summaryInObj = [ordered] @{
                        'Total Users'              = $TotalUsers
                        'Member Users'             = $MemberUsers
                        'Guest Users'              = $GuestUsers
                        'Disabled Accounts'        = $DisabledUsers
                        'Hybrid (On-Prem Synced)'  = $SyncedUsers
                        'Licensed Users'           = $LicensedUsers
                        'Cloud-Only Users'         = ($TotalUsers - $SyncedUsers)
                    }
                    $SummaryObj.Add([pscustomobject]$summaryInObj) | Out-Null

                    $null = (& {if ($HealthCheck.EntraID.Guests) {
                        $null = ($SummaryObj | Where-Object { [int]$_.'Guest Users' -gt 0 } | Set-Style -Style Warning | Out-Null)
                    }})

                    $SumTableParams = @{ Name = "User Summary - $TenantId"; List = $true; ColumnWidths = 50, 50 }
                    if ($Report.ShowTableCaptions) { $SumTableParams['Caption'] = "- $($SumTableParams.Name)" }
                    $SummaryObj | Table @SumTableParams

                    #endregion # chart rendered at section top
                    #endregion

                    #region User Account Table (InfoLevel 2: per-user detail)
                    if ($InfoLevel.Users -ge 2) {
                    $UserObj = [System.Collections.ArrayList]::new()
                    foreach ($User in ($AllUsers | Sort-Object DisplayName)) {
                        try {
                            $LastSignIn = if ($User.SignInActivity.LastSignInDateTime) {
                                ($User.SignInActivity.LastSignInDateTime).ToString('yyyy-MM-dd HH:mm')
                            } elseif ($User.SignInActivity.LastNonInteractiveSignInDateTime) {
                                ($User.SignInActivity.LastNonInteractiveSignInDateTime).ToString('yyyy-MM-dd HH:mm') + ' (Non-Interactive)'
                            } else { 'Never / Unknown' }

                            $userInObj = [ordered] @{
                                'Display Name'    = $User.DisplayName
                                'UPN'             = $User.UserPrincipalName
                                'User Type'       = $User.UserType
                                'Account Enabled' = if ($User.AccountEnabled) { 'Yes' } else { 'No' }
                                'Licensed'        = if ($User.AssignedLicenses.Count -gt 0) { 'Yes' } else { 'No' }
                                'Hybrid Synced'   = if ($User.OnPremisesSyncEnabled) { 'Yes' } else { 'No' }
                                'Last Sign-In'    = $LastSignIn
                                'Job Title'       = if ($User.JobTitle)   { $User.JobTitle }   else { '--' }
                                'Department'      = if ($User.Department) { $User.Department } else { '--' }
                                'Created'         = if ($User.CreatedDateTime) { ($User.CreatedDateTime).ToString('yyyy-MM-dd') } else { '--' }
                            }
                            $UserObj.Add([pscustomobject](ConvertTo-HashToYN $userInObj)) | Out-Null
                        } catch {
                            Write-PScriboMessage -IsWarning -Message "User '$($User.DisplayName)': $($_.Exception.Message)"
                        }
                    }

                    $null = (& {
                        if ($HealthCheck.EntraID.MFA) {
                            $null = ($UserObj | Where-Object { $_.'Account Enabled' -eq 'Yes' -and $_.'Last Sign-In' -eq 'Never / Unknown' } | Set-Style -Style Warning | Out-Null)
                            $null = ($UserObj | Where-Object { $_.'Account Enabled' -eq 'No' } | Set-Style -Style Critical | Out-Null)
                            $StaleUsers = $UserObj | Where-Object {
                                $_.'Account Enabled' -eq 'Yes' -and
                                $_.'User Type' -eq 'Member' -and
                                $_.'Last Sign-In' -notin @('Never / Unknown', '--') -and
                                $_.'Last Sign-In' -notlike '*(Non-Interactive)*'
                            } | Where-Object {
                                try {
                                    ((Get-Date) - [datetime]::ParseExact($_.'Last Sign-In'.Trim(), 'yyyy-MM-dd HH:mm', $null)).Days -gt 90
                                } catch { $false }
                            }
                            foreach ($StaleUser in $StaleUsers) {
                                $null = ($StaleUser | Set-Style -Style Warning | Out-Null)
                            }
                        }
                    })

                    $UserTableParams = @{ Name = "User Accounts - $TenantId"; List = $false; ColumnWidths = 15, 20, 8, 9, 8, 8, 14, 8, 6, 4 }
                    if ($Report.ShowTableCaptions) { $UserTableParams['Caption'] = "- $($UserTableParams.Name)" }
                    $UserObj | Table @UserTableParams

                    $null = ($script:ExcelSheets['All Users'] = $UserObj)
                    } # end InfoLevel.Users -ge 2
                    #endregion

                    #region Guest Users Detail (Level 2)
                    if ($InfoLevel.Users -ge 2) {
                        $Guests = $AllUsers | Where-Object { $_.UserType -eq 'Guest' }
                        if ($Guests) {
                            Section -Style Heading3 'Guest Users' {
                                Paragraph "The following guest accounts have been provisioned in tenant $TenantId. Review regularly to ensure access is still required."
                                BlankLine

                                $GuestObj = [System.Collections.ArrayList]::new()
                                foreach ($Guest in ($Guests | Sort-Object DisplayName)) {
                                    $LastGuestSignIn = if ($Guest.SignInActivity.LastSignInDateTime) {
                                        ($Guest.SignInActivity.LastSignInDateTime).ToString('yyyy-MM-dd')
                                    } else { 'Never / Unknown' }

                                    $guestInObj = [ordered] @{
                                        'Display Name'    = $Guest.DisplayName
                                        'Email / UPN'     = $Guest.Mail
                                        'Account Enabled' = if ($Guest.AccountEnabled) { 'Yes' } else { 'No' }
                                        'Created'         = if ($Guest.CreatedDateTime) { ($Guest.CreatedDateTime).ToString('yyyy-MM-dd') } else { '--' }
                                        'Last Sign-In'    = $LastGuestSignIn
                                    }
                                    $GuestObj.Add([pscustomobject](ConvertTo-HashToYN $guestInObj)) | Out-Null
                                }

                                $null = (& {
                                    if ($HealthCheck.EntraID.Guests) {
                                        $null = ($GuestObj | Where-Object { $_.'Account Enabled' -eq 'Yes' -and $_.'Last Sign-In' -eq 'Never / Unknown' } | Set-Style -Style Critical | Out-Null)
                                        $StaleGuests = $GuestObj | Where-Object {
                                            $_.'Account Enabled' -eq 'Yes' -and
                                            $_.'Last Sign-In' -ne 'Never / Unknown' -and
                                            $_.'Last Sign-In' -ne '--'
                                        } | Where-Object {
                                            try {
                                                ((Get-Date) - [datetime]::ParseExact($_.'Last Sign-In', 'yyyy-MM-dd', $null)).Days -gt 90
                                            } catch { $false }
                                        }
                                        foreach ($StaleGuest in $StaleGuests) {
                                            $null = ($StaleGuest | Set-Style -Style Warning | Out-Null)
                                        }
                                    }
                                })

                                $GuestTableParams = @{ Name = "Guest Users - $TenantId"; List = $false; ColumnWidths = 22, 28, 12, 12, 26 }
                                if ($Report.ShowTableCaptions) { $GuestTableParams['Caption'] = "- $($GuestTableParams.Name)" }
                                $GuestObj | Table @GuestTableParams
                                $null = ($script:ExcelSheets['Guest Users'] = $GuestObj)
                            }
                        }
                    }
                    #endregion

                    #region ACSC E8 Users Assessment
                    BlankLine
                    Paragraph "ACSC Essential Eight Maturity Level Assessment -- User Account Management:"
                    BlankLine
                    try {
                        $DisabledCount = @($AllUsers | Where-Object { -not $_.AccountEnabled }).Count
                        $GuestCount    = @($AllUsers | Where-Object { $_.UserType -eq 'Guest' }).Count
                        $NeverSignedIn = @($AllUsers | Where-Object {
                            $_.AccountEnabled -and
                            -not $_.SignInActivity.LastSignInDateTime -and
                            -not $_.SignInActivity.LastNonInteractiveSignInDateTime
                        }).Count
                        $StaleCount    = @($AllUsers | Where-Object {
                            $_.AccountEnabled -and $_.UserType -eq 'Member' -and
                            $_.SignInActivity.LastSignInDateTime -and
                            ((Get-Date) - $_.SignInActivity.LastSignInDateTime).Days -gt 90
                        }).Count
                        $LicensedGuests = @($AllUsers | Where-Object {
                            $_.UserType -eq 'Guest' -and $_.AssignedLicenses.Count -gt 0
                        }).Count

                        #region ACSC E8 Assessment (definitions from Src/Compliance/ACSC.E8.json)
                        $_ComplianceVars = @{
                            'StaleCount' = $StaleCount
                            'Stale45Count' = $Stale45Count
                            'DisabledCount' = $DisabledCount
                            'GuestCount' = $GuestCount
                            'LicensedGuests' = $LicensedGuests
                            'GuestAdminCount' = $GuestAdminCount
                            'NeverSignedIn' = $NeverSignedIn
                        }
                        $E8UserChecks = Build-AbrComplianceChecks `
                            -Definitions (Get-AbrE8Checks -Section 'Users') `
                            -Framework E8 `
                            -CallerVariables $_ComplianceVars
                        New-AbrE8AssessmentTable -Checks $E8UserChecks -Name 'User Management' -TenantId $TenantId
                        # Consolidated into ACSC E8 Assessment sheet
                    if ($E8UserChecks) { $null = $script:E8AllChecks.AddRange([object[]](@($E8UserChecks | Select-Object @{N='Section';E={'Users'}}, ML, Control, Status, Detail ))) }
                        #endregion

                        if ($script:IncludeCISBaseline) {
                            BlankLine
                            Paragraph "CIS Microsoft 365 Foundations Benchmark Assessment -- User Account Management:"
                            BlankLine
                            #region CIS Assessment (definitions from Src/Compliance/CIS.M365.json)
                            $CISUserChecks = Build-AbrComplianceChecks `
                                -Definitions (Get-AbrCISChecks -Section 'Users') `
                                -Framework CIS `
                                -CallerVariables $_ComplianceVars
                            New-AbrCISAssessmentTable -Checks $CISUserChecks -Name 'User Management' -TenantId $TenantId
                            # Consolidated into CIS Assessment sheet
                    if ($CISUserChecks) { $null = $script:CISAllChecks.AddRange([object[]](@($CISUserChecks | Select-Object @{N='Section';E={'Users'}}, CISControl, Level, Status, Detail ))) }
                            #endregion
                        }
                    } catch {
                        Write-AbrSectionError -Section 'E8 Users Assessment' -Message "$($_.Exception.Message)"
                    }
                    #endregion

                } else {
                    Paragraph "No users were returned for tenant $TenantId."
                }
            } catch {
                Write-AbrSectionError -Section 'Users section' -Message "$($_.Exception.Message)"
            }
        } # end Section Users
        #endregion
    }

    end {
        Show-AbrDebugExecutionTime -End -TitleMessage 'Users'
    }
}