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.

        Run from local PowerShell or Cloud Shell.
        Your account must have Global Administrator and Exchange Administrator rights.

    .EXAMPLE
        Invoke-365TuneConnectExchange

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


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

    $displayName = "365TUNE - Security and Compliance"

    $context = Get-AzContext
    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")) {
        if (-not (Get-Module -ListAvailable -Name $module)) {
            Write-Host " Installing $module..." -ForegroundColor Yellow
            Install-Module -Name $module -Scope CurrentUser
        }
    }
    Import-Module Az.Accounts, Az.Resources, ExchangeOnlineManagement
    Write-Host " [OK] All modules ready." -ForegroundColor Green

    # Step 2 - Authenticate
    Write-Host "`n[2/5] Authenticating..." -ForegroundColor Cyan
    if (-not $SkipAuth) {
        $inCloudShell = ($env:ACC_CLOUD -eq "PROD") -or
                        ($env:POWERSHELL_DISTRIBUTION_CHANNEL -like "*CloudShell*") -or
                        ($env:AZUREPS_HOST_ENVIRONMENT -like "*cloud-shell*")
        if ($inCloudShell) {
            # Cloud Shell pre-loads the signed-in user's context on startup.
            # Calling Connect-AzAccount overrides it with the Cloud Shell MSI - do not call it.
        } else {
            Disconnect-AzAccount -ErrorAction SilentlyContinue | Out-Null
            Connect-AzAccount -WarningAction SilentlyContinue | Out-Null
        }
    }
    $context = Get-AzContext
    if (-not $context) { throw "Not authenticated. Run without -SkipAuth or log in manually first." }
    if ($context.Account.Type -eq 'ManagedService') { throw "Cloud Shell is authenticated as Managed Service Identity (MSI), not as a user. Open a fresh Cloud Shell from the Azure Portal and re-run." }
    Write-Host " Tenant : $($context.Tenant.Id)" -ForegroundColor Gray
    Write-Host " Account : $($context.Account.Id)" -ForegroundColor Gray
    Write-Host " [OK] Authenticated." -ForegroundColor Green

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

    $secureToken = (Get-AzAccessToken -ResourceUrl "https://graph.microsoft.com" -ErrorAction Stop).Token
    $token       = [System.Net.NetworkCredential]::new("", $secureToken).Password
    $headers     = @{ Authorization = "Bearer $token"; "Content-Type" = "application/json" }

    # Fetch 365TUNE SP
    $spFilter   = [Uri]::EscapeDataString("displayName eq '$displayName'")
    $spResponse = Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/servicePrincipals?`$filter=$spFilter" -Headers $headers -Method GET -ErrorAction Stop -TimeoutSec 30
    $sp         = $spResponse.value | Select-Object -First 1
    if (-not $sp) { throw "Service Principal '$displayName' not found. Ensure the app has been consented to in this tenant." }
    $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 -ErrorAction Stop -TimeoutSec 30
    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 " [OK] 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 -ErrorAction Stop -TimeoutSec 30
    $alreadyGranted = $existingGrant.value | Where-Object { $_.appRoleId -eq $exchangeManageAsAppId }

    if ($alreadyGranted) {
        Write-Warning " [WARN] Admin consent already granted - skipping"
    } else {
        $consentBody = @{
            principalId = $objectId
            resourceId  = $exchangeSPId
            appRoleId   = $exchangeManageAsAppId
        } | ConvertTo-Json -Depth 10

        Invoke-RestMethod `
            -Uri     "https://graph.microsoft.com/v1.0/servicePrincipals/$objectId/appRoleAssignments" `
            -Headers $headers -Method POST -Body $consentBody -ErrorAction Stop | Out-Null
        Write-Host " [OK] 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
    Connect-ExchangeOnline -ShowBanner:$false -ErrorAction Stop
    Start-Sleep -Milliseconds 500
    Write-Host " [OK] Connected to Exchange Online." -ForegroundColor Green

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

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

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

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