Public/Get-GkStaleUser.ps1

function Get-GkStaleUser {
    <#
    .SYNOPSIS
        Report users with no sign-in activity for a threshold number of days, flagging disabled
        and guest accounts.

    .DESCRIPTION
        Reads GET /users with the signInActivity property and computes staleness from the most
        recent interactive OR non-interactive sign-in. Users who have never signed in are treated
        as stale. Each result is flagged for account state (AccountEnabled) and guest status.

        Reading signInActivity requires a Microsoft Entra ID P1 or P2 license and the
        AuditLog.Read.All permission. When the tenant lacks the license, Graph returns no
        signInActivity data; every user then appears as "never signed in" and a warning is
        emitted so the result is not misread. (Degrade mode: warn and continue.)

        Notes on the sign-in timestamps (per Microsoft Graph):
          * LastSignIn = last interactive sign-in (success or failure).
          * LastNonInteractiveSignIn = last non-interactive sign-in.
          * LastSuccessfulSignIn = last successful sign-in; only populated from 2023-12-01 and
            not backfilled, so it is reported but not used as the primary staleness signal.
        LastActivity is the most recent of the interactive and non-interactive timestamps.

    .PARAMETER InactiveDays
        Staleness threshold in days (default 90). A user is stale when their last sign-in is at
        least this many days ago, or when they have never signed in.

    .PARAMETER UserType
        Limit to 'Member', 'Guest', or 'All' (default). Filtered client-side because signInActivity
        cannot be combined with other server-side filters.

    .PARAMETER IncludeAll
        Return every user with the computed staleness fields, not just the stale ones.

    .PARAMETER AsReport
        Add export-friendly context columns (StaleThresholdDays, ReportGeneratedUtc) for clean
        Export-Csv / Export-Excel output.

    .EXAMPLE
        Get-GkStaleUser -InactiveDays 120

        Users with no sign-in in the last 120 days (including never-signed-in), flagged.

    .EXAMPLE
        Get-GkStaleUser -UserType Guest -InactiveDays 60 |
            Where-Object AccountEnabled |
            Sort-Object InactiveDays -Descending

        Enabled guest accounts stale for 60+ days, most inactive first.

    .EXAMPLE
        Get-GkStaleUser -InactiveDays 90 -AsReport |
            Export-Csv .\stale-users.csv -NoTypeInformation

        Export a stale-user report with threshold/timestamp context columns.

    .OUTPUTS
        PSGraphKit.StaleUser
    #>

    [CmdletBinding()]
    [OutputType('PSGraphKit.StaleUser')]
    param(
        [ValidateRange(1, 3650)]
        [int] $InactiveDays = 90,

        [ValidateSet('All', 'Member', 'Guest')]
        [string] $UserType = 'All',

        [switch] $IncludeAll,

        [switch] $AsReport
    )

    begin {
        Test-GkConnection -FunctionName 'Get-GkStaleUser' | Out-Null
        $now = [datetime]::UtcNow
    }

    process {
        $select = 'id,displayName,userPrincipalName,userType,accountEnabled,signInActivity'
        $uri = "/users?`$select=$select&`$top=500"

        $users = Invoke-GkGraphRequest -Uri $uri -CallerFunction 'Get-GkStaleUser'

        $sawAnySignInActivity = $false

        foreach ($u in $users) {
            $uType = [string](Get-GkDictValue $u 'userType')
            if ($UserType -ne 'All' -and $uType -ne $UserType) { continue }

            $activity          = Get-GkDictValue $u 'signInActivity'
            $lastInteractive   = ConvertTo-GkDateTime (Get-GkDictValue $activity 'lastSignInDateTime')
            $lastNonInteractive = ConvertTo-GkDateTime (Get-GkDictValue $activity 'lastNonInteractiveSignInDateTime')
            $lastSuccessful    = ConvertTo-GkDateTime (Get-GkDictValue $activity 'lastSuccessfulSignInDateTime')

            if ($null -ne $activity) { $sawAnySignInActivity = $true }

            # Most recent of interactive / non-interactive sign-in.
            $lastActivity = $null
            foreach ($d in @($lastInteractive, $lastNonInteractive)) {
                if ($null -ne $d -and ($null -eq $lastActivity -or $d -gt $lastActivity)) { $lastActivity = $d }
            }

            $neverSignedIn = ($null -eq $lastActivity)
            $inactive      = if ($neverSignedIn) { $null } else { [int][math]::Floor(($now - $lastActivity).TotalDays) }
            $isStale       = $neverSignedIn -or ($inactive -ge $InactiveDays)

            if (-not $IncludeAll -and -not $isStale) { continue }

            $obj = [ordered]@{
                PSTypeName               = 'PSGraphKit.StaleUser'
                DisplayName              = [string](Get-GkDictValue $u 'displayName')
                UserPrincipalName        = [string](Get-GkDictValue $u 'userPrincipalName')
                UserType                 = $uType
                AccountEnabled           = [bool](Get-GkDictValue $u 'accountEnabled')
                IsGuest                  = ($uType -eq 'Guest')
                LastActivity             = $lastActivity
                InactiveDays             = $inactive
                NeverSignedIn            = $neverSignedIn
                IsStale                  = $isStale
                LastSignIn               = $lastInteractive
                LastNonInteractiveSignIn = $lastNonInteractive
                LastSuccessfulSignIn     = $lastSuccessful
                Id                       = [string](Get-GkDictValue $u 'id')
            }
            if ($AsReport) {
                $obj['StaleThresholdDays'] = $InactiveDays
                $obj['ReportGeneratedUtc'] = $now
            }
            [pscustomobject]$obj
        }

        if (-not $sawAnySignInActivity -and @($users).Count -gt 0) {
            Write-Warning "No signInActivity data was returned for any user. Reading signInActivity requires a Microsoft Entra ID P1/P2 license and the AuditLog.Read.All scope; without it, all users appear as never-signed-in. Results may be incomplete."
        }
    }
}