Public/Permissions/Invoke-365TuneRevokeExchange.ps1

function Invoke-365TuneRevokeExchange {
    <#
    .SYNOPSIS
        Revokes Exchange Online View-Only Configuration role from the 365TUNE Enterprise App.

    .DESCRIPTION
        Removes the View-Only Configuration role assignment and Service Principal
        registration from Exchange Online. Safe to re-run — exits cleanly if
        nothing is found.

        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-365TuneRevokeExchange

    .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 — Revoke Exchange Online Permissions" -ForegroundColor Cyan
    Write-Host "══════════════════════════════════════════════════════`n" -ForegroundColor Cyan

    # Step 1 — Check modules
    Write-Host "[1/4] Checking required modules..." -ForegroundColor Cyan
    foreach ($module in @("Az.Accounts", "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
    # 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/4] 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 — Connect to Exchange Online and check assignments
    Write-Host "`n[3/4] Connecting to Exchange Online..." -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." -ForegroundColor Green

    # Resolve which app name is present in this tenant via Graph API (avoids Az.MSGraph dependency issues)
    $graphTokenObj = Get-AzAccessToken -ResourceUrl "https://graph.microsoft.com"
    if ($graphTokenObj.Token -is [System.Security.SecureString]) {
        $graphToken = [System.Net.NetworkCredential]::new("", $graphTokenObj.Token).Password
    } else {
        $graphToken = $graphTokenObj.Token
    }
    $graphHeaders = @{ Authorization = "Bearer $graphToken"; "Content-Type" = "application/json" }

    function Find-365TuneSP ($name) {
        $encoded  = [Uri]::EscapeDataString("displayName eq '$name'")
        $response = Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/servicePrincipals?`$filter=$encoded" -Headers $graphHeaders -Method GET
        $response.value | Select-Object -First 1
    }

    $spCheck = Find-365TuneSP $displayNameProd
    if (-not $spCheck) {
        Write-Host " '$displayNameProd' not found — trying Beta..." -ForegroundColor Yellow
        $spCheck = Find-365TuneSP $displayNameBeta
    }
    if (-not $spCheck) {
        throw "Service Principal not found. Tried '$displayNameProd' and '$displayNameBeta'. Ensure the app has been consented to in this tenant."
    }
    $displayName = $spCheck.displayName

    $existingAssignment = Get-ManagementRoleAssignment -ErrorAction SilentlyContinue |
        Where-Object { $_.RoleAssigneeName -eq $displayName -and $_.Role -eq "View-Only Configuration" }

    $existingSP = Get-ServicePrincipal -ErrorAction SilentlyContinue |
        Where-Object { $_.DisplayName -eq $displayName }

    if (-not $existingAssignment -and -not $existingSP) {
        Write-Host "`n══════════════════════════════════════════════════════" -ForegroundColor Cyan
        Write-Host " No Exchange permissions found — nothing to revoke. ✅" -ForegroundColor Green
        Write-Host "══════════════════════════════════════════════════════`n" -ForegroundColor Cyan
        Disconnect-ExchangeOnline -Confirm:$false
        Start-Sleep -Milliseconds 500
        return
    }

    # Step 4 — Remove role and Service Principal
    Write-Host "`n[4/4] Removing role and Service Principal..." -ForegroundColor Cyan

    if ($existingAssignment) {
        try {
            Remove-ManagementRoleAssignment -Identity "$($existingAssignment.Name)" -Confirm:$false -ErrorAction Stop
            Write-Host " ✅ View-Only Configuration role removed." -ForegroundColor Green
        } catch {
            if ($_.Exception.Message -like "*couldn't be found*" -or $_.Exception.Message -like "*NotFound*") {
                Write-Warning " ⚠️ Role assignment not found — already removed"
            } else { throw }
        }
    } else {
        Write-Warning " ⚠️ No role assignment found — already removed"
    }

    if ($existingSP) {
        try {
            Remove-ServicePrincipal -Identity $existingSP.Identity -Confirm:$false -ErrorAction Stop
            Write-Host " ✅ Service Principal removed from Exchange Online." -ForegroundColor Green
        } catch {
            if ($_.Exception.Message -like "*couldn't be found*" -or $_.Exception.Message -like "*NotFound*") {
                Write-Warning " ⚠️ Service Principal not found — already removed"
            } else { throw }
        }
    } else {
        Write-Warning " ⚠️ No Service Principal found — already removed"
    }

    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 revoked. ✅" -ForegroundColor Green
    Write-Host " Tenant : $($context.Tenant.Id)" -ForegroundColor Green
    Write-Host " Account : $($context.Account.Id)" -ForegroundColor Green
    Write-Host "══════════════════════════════════════════════════════`n" -ForegroundColor Cyan
}