public/Get-MsecContext.ps1

function Get-MsecContext {
    <#
    .SYNOPSIS
        One row per session surface - Azure, the msec app, the write session and each
        workload - with the tenant it is bound to and whether that matches the others.

    .DESCRIPTION
        ANSWERS "WHERE AM I?", WHICH NOTHING ELSE DOES. Working with msec means up to seven
        separate sessions: the Az context, the app-only session from Connect-Msec, the
        delegated session from Connect-MsecAdmin, and one each for Exchange, Teams,
        SharePoint and Purview. Each is established by its own command, each remembers its
        own tenant, and nothing has ever shown them together. Finding out which tenant a
        given command was about to use meant running something and inferring it from the
        answer.

        A STALE SESSION IS THE FAILURE THIS EXISTS FOR, NOT A MISSING ONE. An absent session
        fails loudly and immediately - a command says it is not connected. A session left
        behind on the tenant you switched away from SUCCEEDS, and returns correct-looking
        data from the wrong tenant. Get-EXOMailbox against yesterday's Exchange connection
        does not complain. The Aligned column is the whole point of the command.

        NOT CONNECTED AND CANNOT TELL ARE DIFFERENT, AND BOTH ARE REPORTED. A workload whose
        module is not loaded cannot have a session in this runspace, and says 'ModuleNotLoaded'
        rather than pretending to a clean 'NotConnected'. A probe that throws says 'Unknown'.
        Reporting either as "not connected" would turn "I could not look" into "there is
        nothing there", which is the mistake that makes a stale session invisible.

        IT NEVER CONNECTS, IMPORTS OR DISCONNECTS ANYTHING. Probing a workload by importing
        its module would change the thing being measured, and auto-connecting on a read is
        how a 10-second Exchange sign-in ends up inside an unrelated command. Only modules
        already loaded in this session are asked.

        THE GRAPH SDK AND THE WRITE SESSION SHARE Get-MgContext and are told apart by
        AuthType: Connect-MsecGraphSdk signs in app-only, Connect-MsecAdmin delegated. One
        Get-MgContext result therefore belongs to exactly one of those two rows, and reading
        it as both would report a write session that does not exist.

    .PARAMETER List
        Show the SAVED contexts instead of the live sessions - what you can switch to, rather
        than where you are. Mirrors Get-AzContext -ListAvailable.

    .PARAMETER IncludeWorkload
        Also probe Exchange, Teams, SharePoint and Purview. Off by default: those probes call
        into other modules, and a couple of them are slow enough to notice.

    .EXAMPLE
        Get-MsecContext

        The core three - Azure, app session, write session - and whether they agree.

    .EXAMPLE
        Get-MsecContext -IncludeWorkload | Where-Object Aligned -eq 'DIFFERENT'

        Every session pointing somewhere other than the current Azure context. Empty is the
        answer you want before running anything that writes.

    .OUTPUTS
        One PSCustomObject per surface, PSTypeName 'MsecContext'.

    .NOTES
        Read-only, and local: it inspects session state in this process and calls no API.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [switch] $IncludeWorkload,

        # The SAVED contexts rather than the live ones - the same split as Get-AzContext and
        # Get-AzContext -ListAvailable. Answers "what can I switch to", where the default
        # answers "where am I".
        [switch] $List
    )

    if ($List) {
        $store = Read-MsecContextStore
        foreach ($n in $store.Keys) {
            $c = $store[$n]
            [PSCustomObject]@{
                PSTypeName   = 'MsecSavedContext'
                Name         = $n
                Subscription = $c.Subscription
                TenantId     = $c.TenantId
                KeyVaultName = $c.KeyVaultName
                AzAccount    = $c.AzAccount
                # Empty means this context does not write, and Set-MsecContext will not try.
                AdminAccount = $c.AdminAccount
                SavedUtc     = $c.SavedUtc
            }
        }
        return
    }

    # The tenant everything else is compared against. The Az context is the one surface that
    # is always meaningful - it is what Set-MsecContext moves and what the Azure-plane
    # commands run on.
    $azTenant = $null
    $rows = [System.Collections.Generic.List[object]]::new()

    $add = {
        param($Surface, $Status, $Account, $Tenant, $Detail)
        $aligned = if (-not $Tenant -or -not $azTenant) { 'n/a' }
                   elseif ($Tenant -eq $azTenant)       { 'Aligned' }
                   else                                 { 'DIFFERENT' }
        $rows.Add([PSCustomObject]@{
            PSTypeName = 'MsecContext'
            Surface    = $Surface
            Status     = $Status
            Account    = $Account
            TenantId   = $Tenant
            # The column the command exists for - see the help.
            Aligned    = $aligned
            Detail     = $Detail
        })
    }

    # ---- Azure -----------------------------------------------------------------------
    try {
        $az = Get-AzContext -ErrorAction Stop
        if ($az) {
            $azTenant = [string] $az.Tenant.Id
            & $add 'Azure' 'Connected' ([string] $az.Account.Id) $azTenant `
                   ("subscription $($az.Subscription.Name)")
        }
        else { & $add 'Azure' 'NotConnected' $null $null 'Connect-AzAccount' }
    }
    catch { & $add 'Azure' 'Unknown' $null $null $_.Exception.Message }

    # ---- msec app session (Connect-Msec) ---------------------------------------------
    if ($script:MsecSession) {
        & $add 'App (Connect-Msec)' 'Connected' ([string] $script:MsecSession.ClientId) `
               ([string] $script:MsecSession.TenantId) `
               ("vault $($script:MsecSession.KeyVaultName)")
    }
    else { & $add 'App (Connect-Msec)' 'NotConnected' $null $null 'Connect-Msec -KeyVaultName <kv>' }

    # ---- write session (Connect-MsecAdmin) -------------------------------------------
    if ($script:MsecAdminSession) {
        $n = @($script:MsecAdminSession.GrantedScope).Count
        & $add 'Write (Connect-MsecAdmin)' 'Connected' ([string] $script:MsecAdminSession.Account) `
               ([string] $script:MsecAdminSession.TenantId) "$n scope(s) on the token"
    }
    else { & $add 'Write (Connect-MsecAdmin)' 'NotConnected' $null $null 'Connect-MsecAdmin' }

    if (-not $IncludeWorkload) { return $rows }

    # ---- workloads -------------------------------------------------------------------
    # Only modules ALREADY loaded are asked. Importing one to probe it would change the
    # state being reported, and is how a read turns into a sign-in.
    $probe = {
        param($Surface, $ModuleName, $Probe)
        if (-not (Get-Module -Name $ModuleName)) {
            & $add $Surface 'ModuleNotLoaded' $null $null "$ModuleName is not loaded in this session"
            return
        }
        try { & $Probe }
        catch { & $add $Surface 'Unknown' $null $null $_.Exception.Message }
    }

    & $probe 'Graph SDK (app-only)' 'Microsoft.Graph.Authentication' {
        $c = Get-MgContext -ErrorAction Stop
        # Shared with the write session; AuthType is what separates them.
        if ($c -and [string]$c.AuthType -eq 'AppOnly') {
            & $add 'Graph SDK (app-only)' 'Connected' ([string]$c.ClientId) ([string]$c.TenantId) 'app-only'
        }
        else { & $add 'Graph SDK (app-only)' 'NotConnected' $null $null 'Connect-MsecGraphSdk' }
    }

    & $probe 'Exchange Online' 'ExchangeOnlineManagement' {
        $ci = @(Get-ConnectionInformation -ErrorAction Stop)
        # Purview (IPPSSession) surfaces here too; told apart by the endpoint it connected to.
        $exo = @($ci | Where-Object { [string]$_.ConnectionUri -notmatch 'compliance|protection\.outlook' })
        if ($exo) {
            & $add 'Exchange Online' 'Connected' ([string]$exo[0].UserPrincipalName) `
                   ([string]$exo[0].TenantId) ([string]$exo[0].ConnectionUri)
        }
        else { & $add 'Exchange Online' 'NotConnected' $null $null 'Connect-MsecExchangeOnline' }

        $ipps = @($ci | Where-Object { [string]$_.ConnectionUri -match 'compliance|protection\.outlook' })
        if ($ipps) {
            & $add 'Purview' 'Connected' ([string]$ipps[0].UserPrincipalName) `
                   ([string]$ipps[0].TenantId) ([string]$ipps[0].ConnectionUri)
        }
        else { & $add 'Purview' 'NotConnected' $null $null 'Connect-MsecPurview' }
    }

    & $probe 'SharePoint (PnP)' 'PnP.PowerShell' {
        # PnP THROWS INSTEAD OF RETURNING NULL when there is no connection, so the generic
        # catch would file a perfectly knowable "not signed in" under Unknown - blurring the
        # one distinction this command exists to keep sharp. Recognised by message and
        # reported as what it is.
        try {
            $c = Get-PnPConnection -ErrorAction Stop
            if ($c) { & $add 'SharePoint (PnP)' 'Connected' ([string]$c.ClientId) ([string]$c.Tenant) ([string]$c.Url) }
            else    { & $add 'SharePoint (PnP)' 'NotConnected' $null $null 'Connect-MsecSharePointOnline' }
        }
        catch {
            if ("$($_.Exception.Message)" -match 'not signed in|No connection|Connect-PnPOnline') {
                & $add 'SharePoint (PnP)' 'NotConnected' $null $null 'Connect-MsecSharePointOnline'
            }
            else { throw }
        }
    }

    # Teams has no cheap "am I connected" call - Get-CsTenant is a round trip - so the module
    # being loaded is reported WITHOUT claiming a session either way. Saying 'NotConnected'
    # here would be a guess, and this command exists to stop guesses about session state.
    if (Get-Module -Name MicrosoftTeams) {
        & $add 'Teams' 'Unknown' $null $null `
               'MicrosoftTeams is loaded; no cheap probe exists (Get-CsTenant is a round trip)'
    }
    else { & $add 'Teams' 'ModuleNotLoaded' $null $null 'MicrosoftTeams is not loaded in this session' }

    $rows
}