Public/Permissions/Invoke-365TuneConnectAzure.ps1

function Invoke-365TuneConnectAzure {
    <#
    .SYNOPSIS
        Assigns Reader permissions to the 365TUNE Enterprise App in Azure.

    .DESCRIPTION
        Grants Reader access at root scope "/" and AAD IAM scope
        "/providers/Microsoft.aadiam". Temporarily elevates to User Access
        Administrator then self-cleans after assignment.

        Run from local PowerShell or Cloud Shell.
        Your account must have Global Administrator rights and
        "Access management for Azure resources" enabled in Entra ID > Properties.

    .EXAMPLE
        Invoke-365TuneConnectAzure

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


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

    $displayName = "365TUNE - Security and Compliance"

    Write-Host "`n======================================================" -ForegroundColor Cyan
    Write-Host " 365TUNE - Assign Azure 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")) {
        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
    Write-Host " [OK] 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 - Fetch Service Principal via Graph REST (avoids Az.MSGraph dependency issues)
    Write-Host "`n[3/5] Looking up 365TUNE Service Principal..." -ForegroundColor Cyan
    $graphToken    = (Get-AzAccessToken -ResourceUrl "https://graph.microsoft.com" -ErrorAction Stop)
    $graphTokenStr = if ($graphToken.Token -is [System.Security.SecureString]) { [System.Net.NetworkCredential]::new("", $graphToken.Token).Password } else { $graphToken.Token }
    $graphHeaders  = @{ Authorization = "Bearer $graphTokenStr"; "Content-Type" = "application/json" }
    $spFilter      = [Uri]::EscapeDataString("displayName eq '$displayName'")
    $spResponse    = Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/servicePrincipals?`$filter=$spFilter" -Headers $graphHeaders -Method GET
    $sp            = $spResponse.value | Select-Object -First 1
    if (-not $sp) {
        throw "Service Principal '$displayName' not found. Ensure the 365TUNE app has been consented to in this tenant."
    }
    $servicePrincipalId = $sp.id
    Write-Host " Display Name : $($sp.displayName)"
    Write-Host " Object ID : $servicePrincipalId"
    Write-Host " App ID : $($sp.appId)"

    # Step 4 - Elevate and assign Reader
    Write-Host "`n[4/5] Elevating and assigning Reader permissions..." -ForegroundColor Cyan
    Write-Host " (Elevation is temporary - removed at end of script)"
    Invoke-365TuneElevation

    foreach ($scope in @("/", "/providers/Microsoft.aadiam")) {
        try {
            New-AzRoleAssignment `
                -ObjectId           $servicePrincipalId `
                -Scope              $scope `
                -RoleDefinitionName "Reader" `
                -ObjectType         "ServicePrincipal" `
                -SkipClientSideScopeValidation `
                -ErrorAction Stop | Out-Null
            Write-Host " [OK] Reader assigned at '$scope'" -ForegroundColor Green
        } catch {
            if ($_.Exception.Message -like "*Conflict*"  -or
                $_.Exception.Message -like "*Forbidden*" -or
                $_.Exception.Message -like "*already exists*") {
                Write-Warning " [WARN] Reader at '$scope' already exists - skipping"
            } else { throw }
        }
    }

    # Step 5 - Verify and remove elevation
    Write-Host "`n[5/5] Verifying and removing elevation..." -ForegroundColor Cyan
    # Verify both assignments via REST
    $mgmtToken    = Get-AzAccessToken -ResourceUrl "https://management.azure.com" -ErrorAction Stop
    $mgmtTokenStr = if ($mgmtToken.Token -is [System.Security.SecureString]) { [System.Net.NetworkCredential]::new("", $mgmtToken.Token).Password } else { $mgmtToken.Token }
    $mgmtHeaders  = @{ Authorization = "Bearer $mgmtTokenStr"; "Content-Type" = "application/json" }
    $readerRoleId = "acdd72a7-3385-48ef-bd42-f606fba81ae7"
    $verifyRoot   = (Invoke-RestMethod -Uri "https://management.azure.com/providers/Microsoft.Authorization/roleAssignments?api-version=2022-04-01&`$filter=principalId eq '$servicePrincipalId'" -Headers $mgmtHeaders -Method GET).value | Where-Object { $_.properties.scope -eq "/" -and $_.properties.roleDefinitionId -like "*$readerRoleId*" }
    $verifyAadiam = (Invoke-RestMethod -Uri "https://management.azure.com/providers/Microsoft.aadiam/providers/Microsoft.Authorization/roleAssignments?api-version=2022-04-01&`$filter=principalId eq '$servicePrincipalId'" -Headers $mgmtHeaders -Method GET).value | Where-Object { $_.properties.roleDefinitionId -like "*$readerRoleId*" }
    if ($verifyRoot)   { Write-Host " Verified scope: /" -ForegroundColor Green }
    if ($verifyAadiam) { Write-Host " Verified scope: /providers/Microsoft.aadiam" -ForegroundColor Green }
    if (-not $verifyRoot -and -not $verifyAadiam) { Write-Warning " Could not verify assignments - check Azure Portal." }
    Remove-365TuneElevation

    Write-Host "`n======================================================" -ForegroundColor Cyan
    Write-Host " 365TUNE Azure 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
}