public/Connect-MsecAdmin.ps1

function Connect-MsecAdmin {
    <#
    .SYNOPSIS
        Signs in AS YOU, with delegated write scopes, for the commands that change something.
        Read commands keep using the app; this session is only for writes.

    .DESCRIPTION
        THE APP CANNOT DO THIS, ON PURPOSE. Every Graph permission New-MsecApp consents is
        *.Read.All, so the certificate in Key Vault cannot change anything - that is the
        module's central promise and it is enforced by the token, not by naming. Writes
        therefore run as a person instead: attributable to a named account, subject to your
        Conditional Access and MFA, bounded by your own Defender RBAC rather than tenant-wide
        application permissions, and impossible from an unattended pipeline unless somebody
        deliberately sets one up.

        NOT THE -AsCurrentUser PATTERN, AND HERE IS WHY. Connect-MsecTeams borrows the Az
        context's token, which works because Azure PowerShell's first-party app holds the
        scopes those commands need. Measured on a live tenant, its Graph token carries
        Application.ReadWrite.All, Group.ReadWrite.All, Directory.AccessAsUser.All and
        User.Read.All - and nothing for security. There is no SecurityAlert.ReadWrite.All in
        it, so borrowing cannot resolve an alert. This asks for consent properly instead.

        CONSENT REQUESTED IS NOT CONSENT GRANTED. Connect-MgGraph succeeds when a tenant
        declines a scope; the context simply comes back without it, and the first write then
        fails with a 403 that names nothing. Every requested scope is checked against what was
        actually granted, and a missing one is reported by name here rather than discovered
        later.

        IT WILL REFUSE A DIFFERENT TENANT FROM THE ONE YOU ARE READING. If Connect-Msec holds
        a session, this must sign in to the same tenant. Reading one tenant and writing to
        another is the kind of mistake that is obvious afterwards and invisible at the time.

    .PARAMETER Scope
        Delegated scopes to request. Defaults to resolving alerts and incidents. Ask for the
        narrowest set that covers what you intend to do in this session.

    .PARAMETER TenantId
        Tenant to sign in to. Defaults to the tenant Connect-Msec is using, which is almost
        always what you want.

    .PARAMETER PassThru
        Emit the Graph context as well as the summary.

    .EXAMPLE
        Connect-Msec -KeyVaultName kv-msec # reads, as the app
        Connect-MsecAdmin # writes, as you

    .EXAMPLE
        # Only what this session needs.
        Connect-MsecAdmin -Scope SecurityAlert.ReadWrite.All

    .OUTPUTS
        One PSCustomObject describing the session: Account, TenantId, GrantedScope.

    .NOTES
        Needs the Microsoft.Graph.Authentication module, which is NOT a dependency of msec -
        it is only required by the write commands.

        Disconnect with Disconnect-MgGraph. Connect-Msec and this session are independent;
        disconnecting one leaves the other alone.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [string[]] $Scope = @('SecurityIncident.ReadWrite.All', 'SecurityAlert.ReadWrite.All'),

        [string] $TenantId,

        [switch] $PassThru
    )

    if (-not (Get-Module -ListAvailable -Name Microsoft.Graph.Authentication)) {
        throw 'Microsoft.Graph.Authentication is required for Connect-MsecAdmin. Install with: Install-Module Microsoft.Graph.Authentication -Scope CurrentUser'
    }
    Import-Module Microsoft.Graph.Authentication -ErrorAction Stop

    # Default to the tenant being READ, so a write cannot land somewhere else by accident.
    if (-not $TenantId -and $script:MsecSession -and $script:MsecSession.TenantId) {
        $TenantId = $script:MsecSession.TenantId
    }

    $connectArgs = @{ Scopes = $Scope; NoWelcome = $true; ErrorAction = 'Stop' }
    if ($TenantId) { $connectArgs['TenantId'] = $TenantId }

    Connect-MgGraph @connectArgs

    $context = Get-MgContext
    if (-not $context) { throw 'Connect-MgGraph returned no context. The sign-in did not complete.' }

    # See the help: a declined scope is not an error, it is an absence.
    $granted = @($context.Scopes)
    $missing = @($Scope | Where-Object { $_ -notin $granted })
    if ($missing.Count) {
        throw ("Signed in as $($context.Account), but the tenant did not grant: $($missing -join ', '). " +
               "Connect-MgGraph does not fail on a declined scope - it returns a context without it, and the " +
               "first write would 403 naming nothing. An administrator must consent these for the Microsoft " +
               "Graph PowerShell application. Granted: $($granted -join ', ')")
    }

    # Reading one tenant and writing to another is silently wrong rather than loudly broken.
    if ($script:MsecSession -and $script:MsecSession.TenantId -and
        $context.TenantId -and $context.TenantId -ne $script:MsecSession.TenantId) {
        Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null
        throw ("Connect-Msec is reading tenant $($script:MsecSession.TenantId) but this sign-in landed on " +
               "$($context.TenantId). Refusing rather than writing to a different tenant than you are reading. " +
               'Pass -TenantId explicitly if that is genuinely what you intend.')
    }

    $script:MsecAdminSession = [PSCustomObject]@{
        PSTypeName   = 'MsecAdminSession'
        Account      = [string] $context.Account
        TenantId     = [string] $context.TenantId
        GrantedScope = $granted
        ConnectedUtc = [datetime]::UtcNow
    }

    Write-Verbose "Write session established as $($context.Account) in tenant $($context.TenantId)."

    $script:MsecAdminSession
    if ($PassThru) { $context }
}