Private/New-HygieneAuditContext.ps1

<#
.SYNOPSIS
    Shared state passed to every check during one audit run.
.DESCRIPTION
    Several checks need the same expensive collections -- the user list above all.
    The context carries a small cache so a full audit reads /users once rather than
    once per check, and carries the tenant's verified domains, which the mail
    forwarding check needs to decide what 'external' means.
#>

function New-HygieneAuditContext {
    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [int] $InactiveDayThreshold = 90,
        [int] $GlobalAdminWarnThreshold = 4,
        [int] $MaxPages = 200
    )

    @{
        StartedAt                = [datetime]::UtcNow
        InactiveDayThreshold     = $InactiveDayThreshold
        GlobalAdminWarnThreshold = $GlobalAdminWarnThreshold
        MaxPages                 = $MaxPages
        Tenant                   = $null
        VerifiedDomains          = @()
        Cache                    = @{}
    }
}

<#
.SYNOPSIS
    Reads the tenant's identity: display name, id and verified domains.
.DESCRIPTION
    Uses /organization, which accepts the least-privileged delegated permission
    User.Read for exactly the three properties needed here. Requesting
    Organization.Read.All would return more fields and require more consent for
    no benefit.
#>

function Initialize-HygieneTenantInfo {
    [CmdletBinding()]
    [OutputType([void])]
    param([Parameter(Mandatory)][hashtable] $Context)

    $org = @(Invoke-HygieneGraphRequest -Uri 'organization?$select=id,displayName,verifiedDomains' -MaxPages 1)
    if ($org.Count -eq 0) {
        Write-HygieneLog -Level Warning -Message 'Could not read /organization; the report will show the tenant as unknown.'
        return
    }

    $first = $org[0]
    $Context.Tenant = [pscustomobject]@{
        Id          = $first.id
        DisplayName = $first.displayName
    }
    $Context.VerifiedDomains = @($first.verifiedDomains | ForEach-Object { $_.name } | Where-Object { $_ })

    Write-HygieneLog -Message "Tenant '$($Context.Tenant.DisplayName)' has $($Context.VerifiedDomains.Count) verified domain(s)."
}

<#
.SYNOPSIS
    Returns the tenant's users, reading them from Graph at most once per audit.
.DESCRIPTION
    signInActivity is only returned when explicitly selected, and selecting it caps
    the page size at 500 -- both documented Graph behaviours that are easy to miss and
    produce silently empty results when missed.
#>

function Get-HygieneUser {
    [CmdletBinding()]
    [OutputType([object[]])]
    param(
        [Parameter(Mandatory)] [hashtable] $Context,
        [switch] $IncludeSignInActivity
    )

    $key = if ($IncludeSignInActivity) { 'UsersWithSignIn' } else { 'Users' }
    if ($Context.Cache.ContainsKey($key)) { return $Context.Cache[$key] }

    # A cached richer set answers the poorer question too.
    if (-not $IncludeSignInActivity -and $Context.Cache.ContainsKey('UsersWithSignIn')) {
        return $Context.Cache['UsersWithSignIn']
    }

    $select = 'id,displayName,userPrincipalName,accountEnabled,userType,createdDateTime,assignedLicenses'
    $top    = 999
    if ($IncludeSignInActivity) {
        $select += ',signInActivity'
        # Documented Graph limit: selecting signInActivity caps $top at 500.
        $top = 500
    }

    Write-HygieneLog -Message "Reading users from Graph (signInActivity: $($IncludeSignInActivity.IsPresent))."
    $users = Invoke-HygieneGraphRequest -Uri "users?`$select=$select&`$top=$top" -MaxPages $Context.MaxPages

    $Context.Cache[$key] = $users
    return $users
}

<#
.SYNOPSIS
    True when an address sits outside every verified domain of the tenant.
.DESCRIPTION
    Deliberately fails closed: with no verified domain list, nothing is called
    external, because guessing would manufacture findings out of missing data.
#>

function Test-HygieneExternalAddress {
    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [string] $Address,
        [string[]] $VerifiedDomain
    )

    if ([string]::IsNullOrWhiteSpace($Address)) { return $false }
    if (-not $VerifiedDomain -or $VerifiedDomain.Count -eq 0) { return $false }
    if ($Address -notmatch '@') { return $false }

    $domain = ($Address -split '@')[-1].Trim().TrimEnd('>').ToLowerInvariant()
    if (-not $domain) { return $false }

    foreach ($known in $VerifiedDomain) {
        $k = $known.Trim().ToLowerInvariant()
        if ($domain -eq $k -or $domain.EndsWith(".$k")) { return $false }
    }

    return $true
}