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
    )

    $displayNameProd = "365TUNE - Security and Compliance"
    $displayNameBeta = "365TUNE - Security and Compliance - Beta"

    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 -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) {
            # Cloud Shell always has MSI loaded; the portal also injects the user's context.
            # Switch to the first non-MSI context if available, otherwise try interactive login.
            $context = Get-AzContext
            if (-not $context -or $context.Account.Type -eq 'ManagedService') {
                $userContext = Get-AzContext -ListAvailable | Where-Object { $_.Account.Type -ne 'ManagedService' } | Select-Object -First 1
                if ($userContext) {
                    Write-Host " Switching to user context..." -ForegroundColor Yellow
                    Set-AzContext -Context $userContext | Out-Null
                } else {
                    # az CLI in Cloud Shell is always authenticated as the portal user.
                    Write-Host " Bridging from Azure CLI session..." -ForegroundColor Yellow
                    try {
                        $armToken   = az account get-access-token --resource https://management.azure.com --query accessToken -o tsv 2>$null
                        $graphToken = az account get-access-token --resource https://graph.microsoft.com --query accessToken -o tsv 2>$null
                        $tenantId   = az account show --query tenantId -o tsv 2>$null
                        $accountId  = az account show --query user.name -o tsv 2>$null
                        if ($armToken -and $tenantId -and $accountId) {
                            Connect-AzAccount -AccessToken $armToken -GraphAccessToken $graphToken -TenantId $tenantId -AccountId $accountId -WarningAction SilentlyContinue | Out-Null
                        }
                    } catch {
                        Write-Verbose "Azure CLI bridge failed: $_"
                    }
                }
            }
        } 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 "Authenticated as Managed Service Identity. Run 'Connect-AzAccount' in Cloud Shell then 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 - Fetch Service Principal via Graph REST (avoids Az.MSGraph dependency issues)
    Write-Host "`n[3/5] Looking up 365TUNE Service Principal..." -ForegroundColor Cyan
    try {
        $graphTokenObj = Get-AzAccessToken -ResourceUrl "https://graph.microsoft.com" -ErrorAction Stop
        $graphTokenStr = if ($graphTokenObj.Token -is [System.Security.SecureString]) { [System.Net.NetworkCredential]::new("", $graphTokenObj.Token).Password } else { $graphTokenObj.Token }
    } catch {
        $graphTokenStr = az account get-access-token --resource https://graph.microsoft.com --query accessToken -o tsv 2>$null
        if (-not $graphTokenStr) { throw }
    }
    $graphHeaders  = @{ Authorization = "Bearer $graphTokenStr"; "Content-Type" = "application/json" }
    $spFilter      = [Uri]::EscapeDataString("displayName eq '$displayNameProd'")
    $spResponse    = Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/servicePrincipals?`$filter=$spFilter" -Headers $graphHeaders -Method GET -TimeoutSec 30
    $sp            = $spResponse.value | Select-Object -First 1
    if (-not $sp) {
        Write-Host " '$displayNameProd' not found - trying Beta..." -ForegroundColor Yellow
        $spFilter   = [Uri]::EscapeDataString("displayName eq '$displayNameBeta'")
        $spResponse = Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/servicePrincipals?`$filter=$spFilter" -Headers $graphHeaders -Method GET -TimeoutSec 30
        $sp         = $spResponse.value | Select-Object -First 1
    }
    if (-not $sp) {
        throw "Service Principal not found. Tried '$displayNameProd' and '$displayNameBeta'. 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

    try {
    $readerRoleId = "acdd72a7-3385-48ef-bd42-f606fba81ae7"

    foreach ($scope in @("/", "/providers/Microsoft.aadiam")) {
        # Build REST PUT URL and role definition ID for this scope
        $assignGuid = [System.Guid]::NewGuid().ToString()
        if ($scope -eq "/") {
            $assignUrl = "https://management.azure.com//providers/Microsoft.Authorization/roleAssignments/$assignGuid`?api-version=2022-04-01"
            $roleDefId = "/providers/Microsoft.Authorization/roleDefinitions/$readerRoleId"
        } else {
            $assignUrl = "https://management.azure.com$scope/providers/Microsoft.Authorization/roleAssignments/$assignGuid`?api-version=2022-04-01"
            $roleDefId = "$scope/providers/Microsoft.Authorization/roleDefinitions/$readerRoleId"
        }
        $tempBody = [System.IO.Path]::GetTempFileName()
        @{ properties = @{ roleDefinitionId = $roleDefId; principalId = $servicePrincipalId; principalType = "ServicePrincipal" } } |
            ConvertTo-Json -Depth 5 | Out-File -FilePath $tempBody -Encoding utf8 -NoNewline
        $assigned = $false

        # Get ARM token (try Az context first; fall back to az CLI)
        try {
            $armObj2 = Get-AzAccessToken -ResourceUrl "https://management.azure.com" -ErrorAction Stop
            $armStr2 = if ($armObj2.Token -is [System.Security.SecureString]) { [System.Net.NetworkCredential]::new("", $armObj2.Token).Password } else { $armObj2.Token }
        } catch {
            $armStr2 = az account get-access-token --resource https://management.azure.com --query accessToken -o tsv 2>$null
        }

        # Method 1: Invoke-WebRequest PUT with ARM token
        if ($armStr2) {
            $putHeaders = @{ Authorization = "Bearer $armStr2"; "Content-Type" = "application/json" }
            $putBody    = Get-Content $tempBody -Raw
            try {
                $putResp = Invoke-WebRequest -Uri $assignUrl -Method PUT -Headers $putHeaders -Body $putBody -UseBasicParsing
                if ([int]$putResp.StatusCode -in @(200, 201)) {
                    Write-Host " [OK] Reader assigned at '$scope'" -ForegroundColor Green
                    $assigned = $true
                }
            } catch {
                if ($_.Exception.Response) {
                    $sc = [int]$_.Exception.Response.StatusCode
                    if ($sc -eq 409) {
                        Write-Warning " [WARN] Reader at '$scope' already exists - skipping"
                        $assigned = $true
                    }
                }
            }
        }

        # Method 2: az rest PUT (CLI native auth - handles ARM differently)
        if (-not $assigned -and (Get-Command az -ErrorAction SilentlyContinue)) {
            Write-Verbose "Trying az rest PUT for '$scope'..."
            $azOut = az rest --method PUT --url $assignUrl --body "@$tempBody" 2>&1
            if ($LASTEXITCODE -eq 0) {
                Write-Host " [OK] Reader assigned at '$scope'" -ForegroundColor Green
                $assigned = $true
            } elseif ($azOut -like "*Conflict*" -or $azOut -like "*409*") {
                Write-Warning " [WARN] Reader at '$scope' already exists - skipping"
                $assigned = $true
            }
        }

        Remove-Item $tempBody -ErrorAction SilentlyContinue

        # Method 3: New-AzRoleAssignment cmdlet (fallback for regular PowerShell)
        if (-not $assigned) {
            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
    try {
        $mgmtTokenObj = Get-AzAccessToken -ResourceUrl "https://management.azure.com" -ErrorAction Stop
        $mgmtTokenStr = if ($mgmtTokenObj.Token -is [System.Security.SecureString]) { [System.Net.NetworkCredential]::new("", $mgmtTokenObj.Token).Password } else { $mgmtTokenObj.Token }
    } catch {
        $mgmtTokenStr = az account get-access-token --resource https://management.azure.com --query accessToken -o tsv 2>$null
        if (-not $mgmtTokenStr) { throw }
    }
    $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 -TimeoutSec 30).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 -TimeoutSec 30).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." }
    } finally {
        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
}