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