Public/Permissions/Invoke-365TuneConnectExchange.ps1

function Invoke-365TuneConnectExchange {
    <#
    .SYNOPSIS
        Assigns Exchange Online View-Only Configuration role to the 365TUNE Enterprise App.

    .DESCRIPTION
        Grants admin consent for Exchange.ManageAsApp in the customer tenant,
        registers the Service Principal in Exchange Online, and assigns the
        View-Only Configuration management role.

        Operates entirely within the customer tenant — no changes made to the
        365TUNE app registration in the Metawise tenant.

        Works in both local PowerShell and Azure Cloud Shell.
        - Local PowerShell : interactive browser login
        - Cloud Shell : uses the existing authenticated session — no extra login required

        Your account must have Global Administrator and Exchange Administrator rights.

    .EXAMPLE
        Invoke-365TuneConnectExchange

    .NOTES
        Author : Metawise Consulting LLC
        Module : 365TUNE
        Version : 2.1.7
    #>


    [CmdletBinding()]
    param(
        [switch]$SkipAuth
    )

    $displayNameProd = "365TUNE - Security and Compliance"
    $displayNameBeta = "365TUNE - Security and Compliance - Beta"

    Write-Host "`n══════════════════════════════════════════════════════" -ForegroundColor Cyan
    Write-Host " 365TUNE — Assign Exchange Online Permissions" -ForegroundColor Cyan
    Write-Host "══════════════════════════════════════════════════════`n" -ForegroundColor Cyan

    # Step 1 — Check modules
    Write-Host "[1/5] Checking required modules..." -ForegroundColor Cyan
    foreach ($module in @("Az.Accounts", "Az.Resources", "ExchangeOnlineManagement")) {
        $installed = Get-Module -ListAvailable -Name $module | Sort-Object Version -Descending | Select-Object -First 1
        $needsInstall = -not $installed
        $needsUpgrade = ($module -eq "ExchangeOnlineManagement") -and $installed -and ($installed.Version.Major -lt 3)
        if ($needsInstall -or $needsUpgrade) {
            Write-Host " Installing $module..." -ForegroundColor Yellow
            Install-Module -Name $module -Force -Scope CurrentUser -AllowClobber
        }
    }
    Import-Module Az.Accounts, Az.Resources
    # Load EXO by explicit path to bypass any system-level version Cloud Shell ships with
    $exoModulePath = (Get-Module -ListAvailable -Name ExchangeOnlineManagement | Sort-Object Version -Descending | Select-Object -First 1).Path
    if (-not $exoModulePath) { throw "ExchangeOnlineManagement module not found. Run: Install-Module ExchangeOnlineManagement -Scope CurrentUser -Force" }
    Import-Module $exoModulePath -Force
    Write-Host " ✅ All modules ready." -ForegroundColor Green

    # Detect Cloud Shell
    $inCloudShell = ($env:ACC_CLOUD -eq "PROD") -or
                    ($env:POWERSHELL_DISTRIBUTION_CHANNEL -like "*CloudShell*") -or
                    ($env:AZUREPS_HOST_ENVIRONMENT -like "*cloud-shell*")

    # Step 2 — Authenticate
    Write-Host "`n[2/5] Authenticating..." -ForegroundColor Cyan
    if (-not $SkipAuth) {
        if ($inCloudShell) {
            Write-Host " Cloud Shell detected — using existing session." -ForegroundColor Gray
            # Cloud Shell is already authenticated; just ensure context is present
        } else {
            Disconnect-AzAccount -ErrorAction SilentlyContinue | Out-Null
            Connect-AzAccount -WarningAction SilentlyContinue | Out-Null
        }
    }
    $context = Get-AzContext
    if (-not $context) { throw "Not authenticated. Please try again." }
    Write-Host " Tenant : $($context.Tenant.Id)" -ForegroundColor Gray
    Write-Host " Account : $($context.Account.Id)" -ForegroundColor Gray
    Write-Host " ✅ Authenticated." -ForegroundColor Green

    # Step 3 — Resolve IDs from customer tenant only
    Write-Host "`n[3/5] Resolving IDs..." -ForegroundColor Cyan

    $graphTokenObj = Get-AzAccessToken -ResourceUrl "https://graph.microsoft.com"
    # Az.Accounts 2.17+ returns Token as SecureString; older versions return plain string
    if ($graphTokenObj.Token -is [System.Security.SecureString]) {
        $graphToken = [System.Net.NetworkCredential]::new("", $graphTokenObj.Token).Password
    } else {
        $graphToken = $graphTokenObj.Token
    }
    $headers = @{ Authorization = "Bearer $graphToken"; "Content-Type" = "application/json" }

    # Fetch 365TUNE SP via Graph API (avoids Az.MSGraph dependency issues)
    function Find-365TuneSP ($name) {
        $encoded  = [Uri]::EscapeDataString("displayName eq '$name'")
        $response = Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/servicePrincipals?`$filter=$encoded" -Headers $headers -Method GET
        $response.value | Select-Object -First 1
    }

    $sp = Find-365TuneSP $displayNameProd
    if (-not $sp) {
        Write-Host " '$displayNameProd' not found — trying Beta..." -ForegroundColor Yellow
        $sp = Find-365TuneSP $displayNameBeta
    }
    if (-not $sp) { throw "Service Principal not found. Tried '$displayNameProd' and '$displayNameBeta'. Ensure the app has been consented to in this tenant." }
    $displayName = $sp.displayName
    $objectId    = $sp.id
    $appId       = $sp.appId
    Write-Host " Display Name : $($sp.displayName)"
    Write-Host " Object ID : $objectId"
    Write-Host " App ID : $appId"

    # Fetch Exchange Online SP
    $exchangeSPResponse = Invoke-RestMethod `
        -Uri     "https://graph.microsoft.com/v1.0/servicePrincipals?`$filter=displayName eq 'Office 365 Exchange Online'" `
        -Headers $headers -Method GET
    if (-not $exchangeSPResponse.value) { throw "Office 365 Exchange Online SP not found in tenant." }

    $exchangeSP              = $exchangeSPResponse.value[0]
    $exchangeSPId            = $exchangeSP.id

    $exchangeManageAsAppRole = $exchangeSP.appRoles | Where-Object { $_.value -eq "Exchange.ManageAsApp" }
    if (-not $exchangeManageAsAppRole) { throw "Exchange.ManageAsApp role not found." }
    $exchangeManageAsAppId   = $exchangeManageAsAppRole.id

    Write-Host " Exchange SP ID : $exchangeSPId"
    Write-Host " Exchange.ManageAsApp ID : $exchangeManageAsAppId"
    Write-Host " ✅ All IDs resolved." -ForegroundColor Green

    # Step 4 — Grant admin consent directly in customer tenant
    # No app registration changes — consent granted directly on the SP
    Write-Host "`n[4/5] Granting admin consent..." -ForegroundColor Cyan

    $existingGrant  = Invoke-RestMethod `
        -Uri     "https://graph.microsoft.com/v1.0/servicePrincipals/$objectId/appRoleAssignments" `
        -Headers $headers -Method GET
    $alreadyGranted = $existingGrant.value | Where-Object { $_.appRoleId -eq $exchangeManageAsAppId }

    if ($alreadyGranted) {
        Write-Warning " ⚠️ Admin consent already granted — skipping"
    } else {
        $consentBody = @{
            principalId = $objectId
            resourceId  = $exchangeSPId
            appRoleId   = $exchangeManageAsAppId
        } | ConvertTo-Json

        Invoke-RestMethod `
            -Uri     "https://graph.microsoft.com/v1.0/servicePrincipals/$objectId/appRoleAssignments" `
            -Headers $headers -Method POST -Body $consentBody | Out-Null
        Write-Host " ✅ Admin consent granted." -ForegroundColor Green
    }

    # Step 5 — Connect Exchange Online, register SP and assign role
    Write-Host "`n[5/5] Connecting to Exchange Online and assigning role..." -ForegroundColor Cyan
    if ($inCloudShell) {
        # Reuse the existing session token — no extra login prompt
        $exoTokenObj = Get-AzAccessToken -ResourceUrl "https://outlook.office365.com"
        if ($exoTokenObj.Token -is [System.Security.SecureString]) {
            $exoToken = [System.Net.NetworkCredential]::new("", $exoTokenObj.Token).Password
        } else {
            $exoToken = $exoTokenObj.Token
        }
        # MSI account has no UPN — resolve the tenant's default domain from Get-AzTenant
        $tenantDomain = (Get-AzTenant -TenantId $context.Tenant.Id).DefaultDomain
        Connect-ExchangeOnline -AccessToken $exoToken -DelegatedOrganization $tenantDomain -ShowBanner:$false
    } else {
        Connect-ExchangeOnline -ShowBanner:$false
    }
    Start-Sleep -Milliseconds 500
    Write-Host " ✅ Connected to Exchange Online." -ForegroundColor Green

    try {
        New-ServicePrincipal -AppId $appId -ObjectId $objectId -DisplayName $displayName -ErrorAction Stop | Out-Null
        Write-Host " ✅ Service Principal registered in Exchange Online." -ForegroundColor Green
    } catch {
        if ($_.Exception.Message -like "*already exists*" -or $_.Exception.Message -like "*Conflict*") {
            Write-Warning " ⚠️ Service Principal already registered — skipping"
        } else { throw }
    }

    try {
        New-ManagementRoleAssignment -Role "View-Only Configuration" -App $displayName -ErrorAction Stop | Out-Null
        Write-Host " ✅ View-Only Configuration role assigned." -ForegroundColor Green
    } catch {
        if ($_.Exception.Message -like "*already exists*" -or $_.Exception.Message -like "*Conflict*") {
            Write-Warning " ⚠️ Role already assigned — skipping"
        } else { throw }
    }

    Disconnect-ExchangeOnline -Confirm:$false
    Start-Sleep -Milliseconds 500
    Write-Host " Exchange Online session closed." -ForegroundColor Gray

    Write-Host "`n══════════════════════════════════════════════════════" -ForegroundColor Cyan
    Write-Host " 365TUNE Exchange Online permissions configured. ✅" -ForegroundColor Green
    Write-Host " Tenant : $($context.Tenant.Id)" -ForegroundColor Green
    Write-Host " Account : $($context.Account.Id)" -ForegroundColor Green
    Write-Host "══════════════════════════════════════════════════════`n" -ForegroundColor Cyan
}