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 only (not Cloud Shell).
        Your account must have Global Administrator and Exchange Administrator rights.

    .EXAMPLE
        Invoke-365TuneConnectExchange

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


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

    $displayName = "365TUNE - Security and Compliance"

    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 -Force -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) {
            Connect-AzAccount -Identity -WarningAction SilentlyContinue | Out-Null
        } 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." }
    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").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
    $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
    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
    $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

        Invoke-RestMethod `
            -Uri     "https://graph.microsoft.com/v1.0/servicePrincipals/$objectId/appRoleAssignments" `
            -Headers $headers -Method POST -Body $consentBody | 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
    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
    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
}