Private/Remove-365TuneElevation.ps1
|
function Remove-365TuneElevation { <# .SYNOPSIS Removes User Access Administrator elevation from root scope for the current user. #> $ctx = Get-AzContext $currentUser = $ctx.Account.Id $uaaRoleId = "18d7d88d-d35e-4fb5-a5c3-7773c20a72d9" # Decode OID from the current JWT - works for both user and MSI/SP accounts $currentOid = $null $meTokenStr = $null try { $meToken = Get-AzAccessToken -ResourceUrl "https://management.azure.com" -ErrorAction Stop $meTokenStr = if ($meToken.Token -is [System.Security.SecureString]) { [System.Net.NetworkCredential]::new("", $meToken.Token).Password } else { $meToken.Token } } catch { $meTokenStr = az account get-access-token --resource https://management.azure.com --query accessToken -o tsv 2>$null } if ($meTokenStr) { try { $jwtPayload = $meTokenStr.Split(".")[1] $pad = 4 - ($jwtPayload.Length % 4) if ($pad -ne 4) { $jwtPayload += "=" * $pad } $claims = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($jwtPayload)) | ConvertFrom-Json $currentOid = $claims.oid } catch { Write-Verbose "Could not decode token OID" } } # Primary: REST lookup by principalId - reliable for user AND MSI/SP (no SignInName dependency) $assignmentGuid = $null if ($currentOid -and $meTokenStr) { try { $mgmtHeaders = @{ Authorization = "Bearer $meTokenStr" } $result = Invoke-RestMethod -Uri "https://management.azure.com/providers/Microsoft.Authorization/roleAssignments?api-version=2022-04-01&`$filter=principalId eq '$currentOid'" -Headers $mgmtHeaders -Method GET -ErrorAction Stop -TimeoutSec 30 $found = $result.value | Where-Object { $_.properties.scope -eq "/" -and $_.properties.roleDefinitionId -like "*$uaaRoleId*" } | Select-Object -First 1 # Extract just the GUID from the id (last path segment) if ($found) { $assignmentGuid = $found.id.Split('/')[-1] } } catch { Write-Verbose "REST OID lookup failed: $_" } } # Fallback: Az cmdlet - works when SignInName or ObjectId matches (user accounts at subscription scope) if (-not $assignmentGuid) { $azAssignment = Get-AzRoleAssignment -RoleDefinitionId $uaaRoleId -Scope "/" -ErrorAction SilentlyContinue | Where-Object { $_.SignInName -eq $currentUser -or ($currentOid -and $_.ObjectId -eq $currentOid) } if ($azAssignment) { $assignmentGuid = $azAssignment.RoleAssignmentId.Split('/')[-1] } } if (-not $assignmentGuid) { Write-Host " Elevation already removed." -ForegroundColor Gray return } Write-Host " Waiting for propagation..." -ForegroundColor Gray Start-Sleep -Seconds 20 # Root-scope ARM DELETE requires double-slash: https://management.azure.com//providers/.../roleAssignments/{guid} # The resource id returned by GET has a single leading slash (/providers/...) which when naively prepended to # the base URL produces a single-slash URL that ARM routes differently and returns 403. # The double-slash explicitly encodes scope="/" in the URL: {base}//{resource-path} $deleteUri = "https://management.azure.com//providers/Microsoft.Authorization/roleAssignments/$assignmentGuid`?api-version=2022-04-01" # Prefer a v2/MSAL-style token (--scope) which is closer to what Az PowerShell uses and avoids # the v1 CLI token that gets cached pre-elevation and may carry stale auth-cache authorization results $deleteToken = $meTokenStr if (Get-Command az -ErrorAction SilentlyContinue) { $v2Token = az account get-access-token --scope "https://management.azure.com/.default" --query accessToken -o tsv 2>$null if ($v2Token) { $deleteToken = $v2Token } } $deleteStatus = $null $deleteError = $null # Method 1: Invoke-WebRequest DELETE with v2 ARM token + double-slash root-scope URL if ($deleteToken) { try { $delResp = Invoke-WebRequest -Uri $deleteUri -Method DELETE -Headers @{ Authorization = "Bearer $deleteToken" } -UseBasicParsing $deleteStatus = [int]$delResp.StatusCode } catch { if ($_.Exception.Response) { $deleteStatus = [int]$_.Exception.Response.StatusCode $deleteError = $_.ErrorDetails.Message # PS7: JSON error body from ARM } else { throw } } if ($deleteStatus -in @(200, 204)) { Write-Host " [OK] Elevation removed." -ForegroundColor Green return } } # Method 2: az role assignment delete --scope / (CLI constructs the correct double-slash root-scope URL internally) if ($currentOid -and (Get-Command az -ErrorAction SilentlyContinue)) { $azRaOut = az role assignment delete --assignee-object-id $currentOid --role $uaaRoleId --scope / 2>&1 if ($LASTEXITCODE -eq 0) { Write-Host " [OK] Elevation removed." -ForegroundColor Green return } Write-Verbose "az role assignment delete: $azRaOut" } # Method 3: az rest DELETE with double-slash URL if (Get-Command az -ErrorAction SilentlyContinue) { $azRestOut = az rest --method DELETE --url $deleteUri 2>&1 if ($LASTEXITCODE -eq 0) { Write-Host " [OK] Elevation removed." -ForegroundColor Green return } Write-Verbose "az rest DELETE: $azRestOut" } # Method 4: Graph API PATCH - sets elevatedAccessForAuthorizationManagement=false # This is the same mechanism the Azure Portal uses when toggling the "Access management" switch to No. # It calls a different API surface (Graph, not ARM) so it is unaffected by the ARM auth cache issue. if (Get-Command az -ErrorAction SilentlyContinue) { $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 if ($graphToken -and $tenantId) { try { $graphResp = Invoke-WebRequest ` -Uri "https://graph.microsoft.com/beta/organization/$tenantId" ` -Method PATCH ` -Headers @{ Authorization = "Bearer $graphToken"; "Content-Type" = "application/json" } ` -Body '{"elevatedAccessForAuthorizationManagement": false}' ` -UseBasicParsing if ([int]$graphResp.StatusCode -in @(200, 204)) { Write-Host " [OK] Elevation removed via Graph API." -ForegroundColor Green return } } catch { Write-Verbose "Graph PATCH failed: $_" } } } # Method 5: Remove-AzRoleAssignment cmdlet (works in regular PowerShell with MSAL token) if ($currentOid) { try { Remove-AzRoleAssignment -ObjectId $currentOid -RoleDefinitionId $uaaRoleId -Scope "/" -SkipClientSideScopeValidation -ErrorAction Stop | Out-Null Write-Host " [OK] Elevation removed." -ForegroundColor Green return } catch { Write-Verbose "Remove-AzRoleAssignment failed: $_" } } # Build a useful diagnostic message from the ARM error body (JSON) if available $errDetail = "" if ($deleteError) { try { $parsed = $deleteError | ConvertFrom-Json -ErrorAction Stop if ($parsed.error.code) { $errDetail = "; ARM: $($parsed.error.code) - $($parsed.error.message)" } } catch { } } Write-Warning " [WARN] Could not remove elevation (HTTP $deleteStatus$errDetail) -- remove manually: Azure Portal > Properties > Access management for Azure resources." } |