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 } |