public/Get-MsecEntraDisabledUser.ps1

function Get-MsecEntraDisabledUser {
    <#
    .SYNOPSIS
        Every disabled ("archived") user in the tenant, with how long each has been disabled
        where that is knowable, and what it is still costing in licences.

    .DESCRIPTION
        Lists users with accountEnabled = false, then works out when each was disabled.

        HOW LONG IS THE HARD PART, AND IT IS NOT ALWAYS ANSWERABLE
        ----------------------------------------------------------
        Entra stores no 'disabledDateTime'. A user object records when it was created and
        when it last signed in, but nothing about when somebody switched it off. The only
        record of the act is in the directory audit log - an 'Update user' event whose
        modifiedProperties show AccountEnabled going from [true] to [false].

        Those logs retain 30 days on Entra ID P1/P2 and 7 days on the free tier. So:

          * disabled recently -> DisabledSince and DisabledDays are EXACT, and DisabledBy
                                  names who did it.
          * disabled long ago -> the event has aged out. DisabledSince is $null, and the
                                  row instead carries a BRACKET: DisabledAtLeastDays (the
                                  window searched, since nothing was found inside it) and
                                  DisabledAtMostDays (days since the last SUCCESSFUL sign-in,
                                  because a disabled account cannot sign in successfully).

        The upper bound comes from signInActivity, which is a property Entra persists on the
        user object rather than a log query - so unlike DisabledSince it is NOT capped at the
        audit retention window and happily reaches back years. It does need Entra ID P1.

        It rests specifically on lastSuccessfulSignInDateTime, and the distinction matters:
        lastSignInDateTime records the last interactive ATTEMPT, and a disabled account still
        gets attempted - by an ex-employee, a stale client, a password spray. Computing the
        bound from an attempt would report "disabled at most 7 days" for an account switched
        off three years ago. Where no successful sign-in is recorded, DisabledAtMostDays is
        left blank rather than guessed.

        That bracket is the honest answer and is usually enough to act on - "between 30 and
        400 days" tells you it is long dead. A single invented number would not be.

        LICENCES ARE THE REASON THIS IS WORTH RUNNING. A disabled account still holding
        assigned licences is both spend and attack surface, so LicenseCount is projected and
        the examples sort by it.

        "LAST UPDATED" IS THREE DIFFERENT COLUMNS, BECAUSE IT IS THREE DIFFERENT QUESTIONS
        ----------------------------------------------------------------------------------
        Graph exposes no lastModifiedDateTime on a user, so there is no single answer. What
        exists is:

          LastDirectoryChange newest audit event against the object, plus
          (+ ...What) LastDirectoryChangeWhat naming which properties moved. The
                               closest thing to "last edited", and bounded by the same audit
                               retention as DisabledSince - so $null here means "nothing in
                               the last -Days days", NOT "never touched".

          LastPasswordChange unbounded and always present, straight off the user object.
                               On a disabled account this is usually the best marker of when
                               it was genuinely last in use.

          OnPremisesLastSync synced accounts only. Worth watching for the case where sync
                               is still enabled but this stopped advancing: the on-premises
                               source object is gone and what is left in Entra is an orphan
                               nothing will ever update or clean up again.

    .PARAMETER Days
        How far back to search the audit log for the disable event. Default 30, which is the
        P1/P2 retention ceiling - asking for more cannot find more, it just takes longer. Drop
        to 7 on a free-tier tenant.

    .PARAMETER UserType
        'Member', 'Guest' or 'All'. Default All. Disabled guests are worth separating: a guest
        left disabled is usually a finished engagement nobody cleaned up.

    .PARAMETER ExcludeSignInActivity
        Skip the last-sign-in lookup. That property is what makes DisabledAtMostDays possible,
        so skipping it leaves the upper half of the bracket blank - but it is premium-only, and
        on a tenant without Entra ID P1 it costs a round trip to learn nothing.

    .EXAMPLE
        Get-MsecEntraDisabledUser | Where-Object LicenseCount -gt 0 |
            Sort-Object LicenseCount -Descending |
            Format-Table UserPrincipalName, LicenseCount, DisabledDays, DisabledAtLeastDays

        Disabled accounts still consuming licences, worst first.

    .EXAMPLE
        # Who has been switched off in the last week, and by whom.
        Get-MsecEntraDisabledUser -Days 7 | Where-Object DisabledSince |
            Sort-Object DisabledSince -Descending |
            Format-Table UserPrincipalName, DisabledSince, DisabledBy

    .EXAMPLE
        # Long-dead accounts: nothing in the audit window, and no sign-in for over a year.
        Get-MsecEntraDisabledUser |
            Where-Object { -not $_.DisabledSince -and $_.DisabledAtMostDays -gt 365 }

    .OUTPUTS
        PSCustomObject per disabled user, PSTypeName 'MsecEntraDisabledUser'.

    .NOTES
        Needs User.Read.All and AuditLog.Read.All, both of which New-MsecApp already grants -
        no re-run required.

        DisabledSince comes from the audit log, so it reflects the last time the account was
        disabled. An account switched off, back on, and off again reports the most recent
        event, which is the one you want.

        On-premises-synced accounts (OnPremisesSyncEnabled) are disabled in Active Directory
        and the state syncs down. The audit event still appears, attributed to the sync
        account rather than to a person, so DisabledBy will name the directory synchronisation
        service - that is correct, not a lookup failure.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [ValidateRange(1, 30)]
        [int] $Days = 30,

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

        [switch] $ExcludeSignInActivity
    )

    Assert-MsecSession

    $now = [DateTime]::UtcNow

    # ---- the disabled users -----------------------------------------------------------------

    $select = @(
        'id', 'displayName', 'userPrincipalName', 'userType', 'accountEnabled'
        'createdDateTime', 'onPremisesSyncEnabled', 'onPremisesLastSyncDateTime'
        'lastPasswordChangeDateTime', 'department', 'jobTitle'
        'assignedLicenses'
    )

    $filter = 'accountEnabled eq false'
    if ($UserType -ne 'All') { $filter += " and userType eq '$UserType'" }

    # signInActivity is a premium property and is rejected outright on some tenants rather
    # than returned null, so it is requested separately and the whole call retried without it.
    # Losing the last-sign-in column is a smaller loss than losing the list.
    $withActivity = -not $ExcludeSignInActivity
    $users = $null

    foreach ($attempt in 1, 2) {
        $fields = if ($withActivity) { $select + 'signInActivity' } else { $select }
        $path = "/v1.0/users?`$filter=$([uri]::EscapeDataString($filter))&`$select=$($fields -join ',')&`$top=999"

        try {
            $users = @(Invoke-MsecGraphRequest -Path $path -All)
            break
        }
        catch {
            $detail = Get-MsecGraphErrorMessage -ErrorRecord $_
            if ($withActivity -and $attempt -eq 1) {
                Write-Warning "Could not read signInActivity (usually means no Entra ID P1 on this tenant); retrying without it, so DisabledAtMostDays will be blank. Graph said: $detail"
                $withActivity = $false
                continue
            }
            throw "Could not list disabled users: $detail"
        }
    }

    if (-not $users.Count) {
        Write-Verbose 'No disabled users found.'
        return
    }

    # ---- when was each one disabled ----------------------------------------------------------

    # Filtered server-side to 'Update user', which is what switching accountEnabled off
    # produces and is a small fraction of all audit traffic. The AccountEnabled test itself
    # has to happen client-side: modifiedProperties is a nested collection and Graph will not
    # filter inside it.
    $cutoff = $now.AddDays(-$Days).ToString('yyyy-MM-ddTHH:mm:ssZ')
    $auditFilter = "activityDateTime ge $cutoff and activityDisplayName eq 'Update user'"

    $disabledAt  = @{}
    $lastUpdated = @{}
    try {
        $events = @(Invoke-MsecGraphRequest -All `
            -Path "/v1.0/auditLogs/directoryAudits?`$filter=$([uri]::EscapeDataString($auditFilter))")

        foreach ($event in $events) {
            foreach ($target in @($event.targetResources)) {
                if (-not $target.id) { continue }

                $when = [datetime] $event.activityDateTime

                # Newest change of ANY kind, recorded before the AccountEnabled test below -
                # a department move or a licence change is still the object being touched,
                # and this comes free from events already fetched.
                if (-not $lastUpdated.ContainsKey($target.id) -or $when -gt $lastUpdated[$target.id].When) {
                    $lastUpdated[$target.id] = [pscustomobject]@{
                        When = $when
                        What = (@($target.modifiedProperties) | ForEach-Object { $_.displayName } |
                                    Where-Object { $_ } | Select-Object -Unique) -join ', '
                    }
                }

                # Values arrive as JSON-ish text - '[true]' / '[false]' - not as booleans.
                $turnedOff = @($target.modifiedProperties) | Where-Object {
                    $_.displayName -eq 'AccountEnabled' -and $_.newValue -match 'false'
                }
                if (-not $turnedOff) { continue }

                # Keep the MOST RECENT event per user: an account disabled, re-enabled and
                # disabled again should report the disable that is currently in force.
                if (-not $disabledAt.ContainsKey($target.id) -or $when -gt $disabledAt[$target.id].When) {
                    $disabledAt[$target.id] = [pscustomobject]@{
                        When = $when
                        By   = $event.initiatedBy.user.userPrincipalName ??
                               $event.initiatedBy.app.displayName ??
                               $event.initiatedBy.user.displayName
                    }
                }
            }
        }
    }
    catch {
        # An audit-log failure costs the timestamps, not the list. Every row still says the
        # user is disabled; only the "how long" columns go blank, and the warning says why.
        Write-Warning "Could not read the directory audit log, so no disable dates are available this run (the user list is unaffected). Graph said: $(Get-MsecGraphErrorMessage -ErrorRecord $_)"
    }

    # ---- project ------------------------------------------------------------------------------

    foreach ($user in $users) {
        $event = $disabledAt[$user.id]

        # signInActivity carries several timestamps and they do NOT mean the same thing.
        $activity = $user.signInActivity
        $asDate = { param($v) if ($v) { [datetime] $v } else { $null } }

        $lastSuccessful     = & $asDate $activity.lastSuccessfulSignInDateTime
        $lastInteractive    = & $asDate $activity.lastSignInDateTime
        $lastNonInteractive = & $asDate $activity.lastNonInteractiveSignInDateTime

        # "Last seen" for the reader: the newest of any kind.
        $lastSignIn = @($lastInteractive, $lastNonInteractive, $lastSuccessful) |
            Where-Object { $_ } | Sort-Object -Descending | Select-Object -First 1

        # The bracket for a user whose disable event has aged out of the log:
        # at least - nothing was found in the window searched, so it happened before that.
        # at most - an account cannot sign in successfully after being disabled, so the
        # last sign-in is the earliest the disable can have happened.
        # The upper bound rests on one fact: a disabled account cannot sign in SUCCESSFULLY.
        # So it must come from lastSuccessfulSignInDateTime and from nothing else.
        #
        # lastSignInDateTime is the tempting substitute and it is wrong for this. It records
        # the last interactive sign-in ATTEMPT, and a disabled account still gets attempted -
        # by the ex-employee, by a stale client, by a password spray. One attempt last week
        # against an account switched off three years ago would compute "disabled at most 7
        # days", which is not merely imprecise but confidently backwards. Better to leave the
        # bound blank and say so.
        $atLeast = $null
        $atMost  = $null
        if (-not $event) {
            $atLeast = $Days
            if ($lastSuccessful) { $atMost = [int] ($now - $lastSuccessful).TotalDays }
        }

        [pscustomobject]@{
            PSTypeName            = 'MsecEntraDisabledUser'
            DisplayName           = $user.displayName
            UserPrincipalName     = $user.userPrincipalName
            UserType              = $user.userType
            Department            = $user.department
            JobTitle              = $user.jobTitle

            DisabledSince         = $event.When
            DisabledDays          = $(if ($event) { [int] ($now - $event.When).TotalDays } else { $null })
            DisabledBy            = $event.By
            DisabledAtLeastDays   = $atLeast
            DisabledAtMostDays    = $atMost
            # 'AuditLog' = exact. 'Beyond audit retention' = the bracket columns are the
            # answer. Named rather than left to be inferred from a null.
            DisabledSource        = $(if ($event) { 'AuditLog' } else { "Beyond audit retention ($Days d)" })

            # A disabled account holding licences is spend and attack surface both.
            LicenseCount          = @($user.assignedLicenses).Count

            # LastSignIn is the newest of any kind - what you want for "when was this last
            # seen". The three underneath are kept because they answer different questions:
            # LastSuccessfulSignIn is the only one that proves the account WORKED (and so the
            # only one DisabledAtMostDays can rest on); LastInteractiveSignIn may be a failed
            # attempt; LastNonInteractiveSignIn is how a service account looks alive while
            # showing nothing interactive for years.
            LastSignIn               = $lastSignIn
            LastSuccessfulSignIn     = $lastSuccessful
            LastInteractiveSignIn    = $lastInteractive
            LastNonInteractiveSignIn = $lastNonInteractive

            # ---- three separate answers to "when was this last touched" ----
            # Graph has no lastModifiedDateTime on a user, so there is no single one. These
            # are the three real signals, and they mean different things:
            #
            # LastDirectoryChange - newest audit event against the object, and the closest
            # thing to "last edited". Bounded by audit retention, so
            # $null means "not in the last $Days days", NOT "never".
            # LastPasswordChange - unbounded and always present. On a disabled account it
            # is usually the last time the account was really in use.
            # OnPremisesLastSync - synced accounts only. If this has stopped advancing
            # while sync is still enabled, the object is orphaned in
            # Entra and the on-prem source is gone.
            LastDirectoryChange   = $lastUpdated[$user.id].When
            LastDirectoryChangeWhat = $lastUpdated[$user.id].What
            LastPasswordChange    = $(if ($user.lastPasswordChangeDateTime) { [datetime] $user.lastPasswordChangeDateTime } else { $null })
            OnPremisesLastSync    = $(if ($user.onPremisesLastSyncDateTime) { [datetime] $user.onPremisesLastSyncDateTime } else { $null })

            OnPremisesSyncEnabled = $user.onPremisesSyncEnabled
            CreatedDateTime       = $(if ($user.createdDateTime) { [datetime] $user.createdDateTime } else { $null })
            Id                    = $user.id
            Raw                   = $user
        }
    }
}