From c2fb7c6ad1ff8b055189f3f36002d185a0926454 Mon Sep 17 00:00:00 2001 From: Avi Verma Date: Sun, 9 Aug 2026 19:42:53 -0700 Subject: [PATCH 1/6] Adding scripts for self-serveitem deletion --- .../tenant-remap-automated-deletion/Step0.ps1 | 69 +++++++++++ .../tenant-remap-automated-deletion/Step1.ps1 | 65 ++++++++++ .../tenant-remap-automated-deletion/Step2.ps1 | 59 +++++++++ .../tenant-remap-automated-deletion/Step3.ps1 | 63 ++++++++++ .../tenant-remap-automated-deletion/Step4.ps1 | 91 ++++++++++++++ .../tenant-remap-automated-deletion/Step5.ps1 | 114 ++++++++++++++++++ .../tenant-remap-automated-deletion/Step6.ps1 | 114 ++++++++++++++++++ 7 files changed, 575 insertions(+) create mode 100644 scripts/tenant-remap-automated-deletion/Step0.ps1 create mode 100644 scripts/tenant-remap-automated-deletion/Step1.ps1 create mode 100644 scripts/tenant-remap-automated-deletion/Step2.ps1 create mode 100644 scripts/tenant-remap-automated-deletion/Step3.ps1 create mode 100644 scripts/tenant-remap-automated-deletion/Step4.ps1 create mode 100644 scripts/tenant-remap-automated-deletion/Step5.ps1 create mode 100644 scripts/tenant-remap-automated-deletion/Step6.ps1 diff --git a/scripts/tenant-remap-automated-deletion/Step0.ps1 b/scripts/tenant-remap-automated-deletion/Step0.ps1 new file mode 100644 index 00000000..b8ac86f2 --- /dev/null +++ b/scripts/tenant-remap-automated-deletion/Step0.ps1 @@ -0,0 +1,69 @@ +#Requires -Modules Az.Accounts, Az.Resources + +function Get-WorkspaceIds { + [CmdletBinding()] + + param() + + $SharedWorkspaceIdsFilePath = 'sharedWorkspaceIds.txt' + $PersonalWorkspaceIdsFilePath = 'personalWorkspaceIds.txt' + $AllWorkspaceIdsFilePath = 'workspaceIds.txt' + + $ErrorActionPreference = 'Stop' + + Set-StrictMode -Version Latest + + if (($null -eq (Get-AzContext)) -or ((Get-AzContext).Account -like "MSI@*")) { + Connect-AzAccount -UseDeviceAuthentication | Out-Null + } + + $secureFabricToken = (Get-AzAccessToken -ResourceUrl 'https://api.fabric.microsoft.com').Token + $ssPtr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureFabricToken) + $plainTextFabricToken = [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($ssPtr) + + $h = @{ Authorization = "Bearer $plainTextFabricToken"; 'Content-Type' = 'application/json' } + + try { + $nonPersonalWorkspaceIds = [System.Collections.Generic.List[string]]::new() + $personalWorkspaceIds = [System.Collections.Generic.List[string]]::new() + + $uri = "https://api.fabric.microsoft.com/v1/admin/workspaces" + do { + $response = Invoke-RestMethod -Method GET -Uri $uri -Headers $h + + foreach ($ws in $response.workspaces) { + switch ($ws.type) { + 'Personal' { + $personalWorkspaceIds.Add($ws.id) + } + 'Workspace' { + $nonPersonalWorkspaceIds.Add($ws.id) + } + default { + Write-Warning "Skipping workspace $($ws.id) with unrecognized type '$($ws.type)'" + } + } + } + + $continuationToken = if ($response.PSObject.Properties.Name -contains 'continuationToken') { $response.continuationToken } else { $null } + if ($continuationToken) { + $uri = "https://api.fabric.microsoft.com/v1/admin/workspaces?continuationToken=$([System.Uri]::EscapeDataString($continuationToken))" + } + } while ($continuationToken) + + Set-Content -Path $SharedWorkspaceIdsFilePath -Value $nonPersonalWorkspaceIds + Set-Content -Path $PersonalWorkspaceIdsFilePath -Value $personalWorkspaceIds + Set-Content -Path $AllWorkspaceIdsFilePath -Value ($nonPersonalWorkspaceIds + $personalWorkspaceIds) + + Write-Host "Wrote $($nonPersonalWorkspaceIds.Count) shared or non-personal workspace ID(s) to $SharedWorkspaceIdsFilePath" + Write-Host "Wrote $($personalWorkspaceIds.Count) personal workspace ID(s) to $PersonalWorkspaceIdsFilePath" + Write-Host "Wrote $($nonPersonalWorkspaceIds.Count + $personalWorkspaceIds.Count) total workspace ID(s) to $AllWorkspaceIdsFilePath" + } + catch { + Write-Error "Error occurred: $($PSItem.Exception.Message)" + } + finally { + $plainTextFabricToken = [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ssPtr) + $h = $null + } +} \ No newline at end of file diff --git a/scripts/tenant-remap-automated-deletion/Step1.ps1 b/scripts/tenant-remap-automated-deletion/Step1.ps1 new file mode 100644 index 00000000..d413c950 --- /dev/null +++ b/scripts/tenant-remap-automated-deletion/Step1.ps1 @@ -0,0 +1,65 @@ +#Requires -Modules Az.Accounts, Az.Resources + +function Restore-Workspaces { + [CmdletBinding()] + + param( + [string]$AdminUpn=$null, + [Parameter(Mandatory=$true)] + [string]$WorkspaceIdsFilePath + ) + + $ErrorActionPreference = 'Stop' + + Set-StrictMode -Version Latest + + if ($WorkspaceIdsFilePath -ne $null -and (Test-Path -Path $WorkspaceIdsFilePath)) { + $workspaceIds = Get-Content -Path $WorkspaceIdsFilePath + } + else { + Write-Error "Workspace IDs file path is not provided or invalid. Please try again." + return + } + + if (($null -eq (Get-AzContext)) -or ((Get-AzContext).Account -like "MSI@*")) { + Connect-AzAccount -UseDeviceAuthentication | Out-Null + } + + if (-not $AdminUpn) { $AdminUpn = (Get-AzContext).Account.Id } + + $adminOid = (Get-AzADUser -UserPrincipalName $AdminUpn).Id + + $secureFabricToken = (Get-AzAccessToken -ResourceUrl 'https://api.fabric.microsoft.com').Token + $ssPtr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureFabricToken) + $plainTextFabricToken = [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($ssPtr) + + $h = @{ Authorization = "Bearer $plainTextFabricToken"; 'Content-Type' = 'application/json' } + + try { + foreach ($wsId in $workspaceIds) { + try { + $workspaceStatus = Invoke-RestMethod -Method GET -Uri "https://api.fabric.microsoft.com/v1/admin/workspaces/$wsId" -Headers $h + $workspaceDisplayName = $workspaceStatus.name + $workspaceState = $workspaceStatus.state + + if ($workspaceState -eq 'Deleted') { + $body = @{ newWorkspaceAdminPrincipal = @{ id = $adminOid; type = 'User' }; 'newWorkspaceName' = "RestoredWorkspace_$($workspaceDisplayName)_$wsId" } | ConvertTo-Json -Depth 5 + + Invoke-RestMethod -Method POST -Uri "https://api.fabric.microsoft.com/v1/admin/workspaces/$wsId/restore" -Headers $h -Body $body + Write-Host "Restored inactive workspace, new workspace name: RestoredWorkspace_$($workspaceDisplayName)_$wsId" + } + elseif ($workspaceState -eq 'Removing') { + Write-Host "Workspace $wsId is in 'Removing' state, cannot reliably restore workspace as it is in the process of being permanently deleted" + } + } + catch { + Write-Warning "Error occurred: $($PSItem.Exception.Message)" + continue + } + } + } + finally { + $plainTextFabricToken = [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ssPtr) + $h = $null + } +} \ No newline at end of file diff --git a/scripts/tenant-remap-automated-deletion/Step2.ps1 b/scripts/tenant-remap-automated-deletion/Step2.ps1 new file mode 100644 index 00000000..1e27104b --- /dev/null +++ b/scripts/tenant-remap-automated-deletion/Step2.ps1 @@ -0,0 +1,59 @@ +#Requires -Modules Az.Accounts, Az.Resources + +function Set-WorkspacesToCapacity { + [CmdletBinding()] + + param( + [Parameter(Mandatory=$true)] + [string]$WorkspaceIdsFilePath, + [Parameter(Mandatory=$true)] + [string]$CapacityId + ) + + $ErrorActionPreference = 'Stop' + + Set-StrictMode -Version Latest + + if ($WorkspaceIdsFilePath -ne $null -and (Test-Path -Path $WorkspaceIdsFilePath)) { + $workspaceIds = Get-Content -Path $WorkspaceIdsFilePath + } + else { + Write-Error "Workspace IDs file path is not provided or invalid. Please try again." + return + } + + if ($CapacityId -eq $null -or $CapacityId -eq '') { + Write-Error "Capacity ID is not provided or invalid. Please try again." + return + } + + if (($null -eq (Get-AzContext)) -or ((Get-AzContext).Account -like "MSI@*")) { + Connect-AzAccount -UseDeviceAuthentication | Out-Null + } + + $secureFabricToken = (Get-AzAccessToken -ResourceUrl 'https://api.fabric.microsoft.com').Token + $ssPtr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureFabricToken) + $plainTextFabricToken = [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($ssPtr) + + $h = @{ Authorization = "Bearer $plainTextFabricToken"; 'Content-Type' = 'application/json' } + + try { + foreach ($wsId in $workspaceIds) { + $ws = Invoke-RestMethod -Method GET -Uri "https://api.fabric.microsoft.com/v1/admin/workspaces/$wsId" -Headers $h + + if (-not $ws.capacityId) { + Write-Host "[Assign Capacity] Assigning workspace $wsId to capacity $CapacityId" + $body = @{ capacityId = $CapacityId } | ConvertTo-Json + + Invoke-RestMethod -Method POST -Uri "https://api.fabric.microsoft.com/v1/admin/workspaces/$wsId/assignToCapacity" -Headers $h -Body $body + } + } + } + catch { + Write-Error "Error occurred: $($PSItem.Exception.Message)" + } + finally { + $plainTextFabricToken = [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ssPtr) + $h = $null + } +} \ No newline at end of file diff --git a/scripts/tenant-remap-automated-deletion/Step3.ps1 b/scripts/tenant-remap-automated-deletion/Step3.ps1 new file mode 100644 index 00000000..cb5fa0d4 --- /dev/null +++ b/scripts/tenant-remap-automated-deletion/Step3.ps1 @@ -0,0 +1,63 @@ +#Requires -Modules Az.Accounts, Az.Resources + +function Add-AdminOnSharedWorkspaces { + [CmdletBinding()] + + param( + [string]$AdminUpn=$null, + [Parameter(Mandatory=$true)] + [string]$SharedWorkspaceIdsFilePath + ) + + $ErrorActionPreference = 'Stop' + + Set-StrictMode -Version Latest + + if ($SharedWorkspaceIdsFilePath -ne $null -and (Test-Path -Path $SharedWorkspaceIdsFilePath)) { + $sharedWorkspaceIds = Get-Content -Path $SharedWorkspaceIdsFilePath + } + else { + Write-Error "Shared workspace IDs file path is not provided or invalid. Please try again." + return + } + + # authentication to get admin object ID for API calls + if (($null -eq (Get-AzContext)) -or ((Get-AzContext).Account -like "MSI@*")) { + # force device authentication if no account present or if using Managed Service ID (MSI) + Connect-AzAccount -UseDeviceAuthentication | Out-Null + } + + if (-not $AdminUpn) { $AdminUpn = (Get-AzContext).Account.Id } + + $adminOid = (Get-AzADUser -UserPrincipalName $AdminUpn).Id + $adminEmailAddress = (Get-AzADUser -UserPrincipalName $AdminUpn).mail + + $secureFabricToken = (Get-AzAccessToken -ResourceUrl 'https://api.fabric.microsoft.com').Token + $ssPtr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureFabricToken) + $plainTextFabricToken = [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($ssPtr) + + $h = @{ Authorization = "Bearer $plainTextFabricToken"; 'Content-Type' = 'application/json' } + + + try { + foreach ($wsId in $sharedWorkspaceIds) { + $users = Invoke-RestMethod -Method GET -Uri "https://api.powerbi.com/v1.0/myorg/admin/groups/$wsId/users" -Headers $h + $isAdmin = $users.value | Where-Object { $_.graphId -eq $adminOid -and $_.groupUserAccessRight -eq 'Admin' } + + if ($isAdmin) { Write-Host "Already Admin on $wsId"; continue } + + $body = @{ emailAddress = $adminEmailAddress; groupUserAccessRight = 'Admin' } | ConvertTo-Json -Depth 5 + + Invoke-RestMethod -Method POST -Uri "https://api.powerbi.com/v1.0/myorg/admin/groups/$wsId/users" -Headers $h -Body $body + + Write-Host "Admin granted to $wsId via API" + } + } + catch { + Write-Error "Error occurred: $($PSItem.Exception.Message)" + } + finally { + $plainTextFabricToken = [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ssPtr) + $h = $null + } +} diff --git a/scripts/tenant-remap-automated-deletion/Step4.ps1 b/scripts/tenant-remap-automated-deletion/Step4.ps1 new file mode 100644 index 00000000..4da7f439 --- /dev/null +++ b/scripts/tenant-remap-automated-deletion/Step4.ps1 @@ -0,0 +1,91 @@ +#Requires -Modules Az.Accounts, Az.Resources + +function Add-AdminOnPersonalWorkspaces { + [CmdletBinding()] + + param( + [string]$AdminUpn=$null, + [Parameter(Mandatory=$true)] + [string]$PersonalWorkspaceIdsFilePath + ) + + $ErrorActionPreference = 'Stop' + + Set-StrictMode -Version Latest + + if ($PersonalWorkspaceIdsFilePath -ne $null -and (Test-Path -Path $PersonalWorkspaceIdsFilePath)) { + $personalWorkspaceIds = Get-Content -Path $PersonalWorkspaceIdsFilePath + } + else { + Write-Error "Personal workspace IDs file path is not provided or invalid. Please try again." + return + } + + if (($null -eq (Get-AzContext)) -or ((Get-AzContext).Account -like "MSI@*")) { + Connect-AzAccount -UseDeviceAuthentication | Out-Null + } + + if (-not $AdminUpn) { $AdminUpn = (Get-AzContext).Account.Id } + + $adminOid = (Get-AzADUser -UserPrincipalName $AdminUpn).Id + + $secureFabricToken = (Get-AzAccessToken -ResourceUrl 'https://api.fabric.microsoft.com').Token + $ssPtr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureFabricToken) + $plainTextFabricToken = [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($ssPtr) + + $h = @{ Authorization = "Bearer $plainTextFabricToken"; 'Content-Type' = 'application/json' } + + try { + $intervalBetweenRequestsMilliseconds = 2405 # 2.4s in ms with 5ms additional jitter + + foreach ($wsId in $personalWorkspaceIds) { + # to avoid 429 errors, smooth out requests every 2.4 seconds (60 sec/25 req/minute) and use retry-after for 429 edge cases + $attemptCounter = 0 + while ($true) { + $attemptCounter++ + if ($attemptCounter -gt 5) { + Write-Warning "Maximum retry attempts reached for $wsId. Skipping workspace." + break + } + + Start-Sleep -Milliseconds $intervalBetweenRequestsMilliseconds + + try { + Invoke-RestMethod -Method POST -Uri "https://api.fabric.microsoft.com/v1/admin/workspaces/$wsId/grantAdminTemporaryAccess" -Headers $h + + Write-Host "Admin granted to $wsId via API (lasts for 24 hours)" + break + } catch { + $error_returned = $_.Exception.Response.StatusCode + + if ($error_returned -ne 429) { + Write-Warning "API grant failed for $wsId ($error_returned). This may happen if already Admin. Use UI link if NOT already Admin: https://app.powerbi.com/groups/$wsId" + break + } + + # 429 error case + # docs: https://learn.microsoft.com/en-us/rest/api/fabric/articles/throttling + $retryAfter = $_.Exception.Response.Headers.RetryAfter + + # RetryAfter is a RetryConditionHeaderValue, so the seconds come from Delta rather than the object itself + if ($null -ne $retryAfter -and $null -ne $retryAfter.Delta) { + $retryAfterSeconds = [Math]::Ceiling($retryAfter.Delta.TotalSeconds) + } + else { + $retryAfterSeconds = 60 + } + + Write-Host "Throttled, waiting $retryAfterSeconds seconds before retrying to add admin to $wsId" + + Start-Sleep -Seconds $retryAfterSeconds + # request is automatically retried after because of the while true + } + } + } + } + finally { + $plainTextFabricToken = [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ssPtr) + $h = $null + } + +} \ No newline at end of file diff --git a/scripts/tenant-remap-automated-deletion/Step5.ps1 b/scripts/tenant-remap-automated-deletion/Step5.ps1 new file mode 100644 index 00000000..96ebff65 --- /dev/null +++ b/scripts/tenant-remap-automated-deletion/Step5.ps1 @@ -0,0 +1,114 @@ +#Requires -Modules Az.Accounts, Az.Resources + +function Remove-AllActiveArtifacts { + [CmdletBinding()] + + param( + [string]$AdminUpn=$null, + [Parameter(Mandatory=$true)] + [string]$WorkspaceIdsFilePath + ) + + $ErrorActionPreference = 'Stop' + + Set-StrictMode -Version Latest + + if ($WorkspaceIdsFilePath -ne $null -and (Test-Path -Path $WorkspaceIdsFilePath)) { + $workspaceIds = Get-Content -Path $WorkspaceIdsFilePath + } + else { + Write-Error "Workspace IDs file path is not provided or invalid. Please try again." + return + } + + if (($null -eq (Get-AzContext)) -or ((Get-AzContext).Account -like "MSI@*")) { + Connect-AzAccount -UseDeviceAuthentication | Out-Null + } + + if (-not $AdminUpn) { $AdminUpn = (Get-AzContext).Account.Id } + + $adminOid = (Get-AzADUser -UserPrincipalName $AdminUpn).Id + + $secureFabricToken = (Get-AzAccessToken -ResourceUrl 'https://api.fabric.microsoft.com').Token + $ssPtr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureFabricToken) + $plainTextFabricToken = [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($ssPtr) + + $h = @{ Authorization = "Bearer $plainTextFabricToken"; 'Content-Type' = 'application/json' } + + $confirmationPromptResponse = Read-Host "Are you sure that you want to permanently delete ALL of your active artifacts? Type YES (case-sensitive) to confirm" + if ($confirmationPromptResponse -cne "YES") { + Write-Error "Confirmation not received. Skipping deletion of active artifacts. Please try again." + return + } + + try { + foreach ($wsId in $workspaceIds) { + Write-Host "[Workspace] $($wsId)`n" + + $workspaceItems = [System.Collections.ArrayList]::new() + + # this can also have a 429 error, but unlikely + $uri = "https://api.fabric.microsoft.com/v1/workspaces/$wsId/items?include=DefaultIdentity" + do { + $response = Invoke-RestMethod -Method GET -Uri $uri -Headers $h + $response.value | ForEach-Object { $workspaceItems.Add($_) | Out-Null } + + $continuationToken = if ($response.PSObject.Properties.Name -contains 'continuationToken') { $response.continuationToken } else { $null } + if ($continuationToken) { + $uri = "https://api.fabric.microsoft.com/v1/workspaces/$wsId/items?include=DefaultIdentity&continuationToken=$([System.Uri]::EscapeDataString($continuationToken))" + } + } while ($continuationToken) + + $formattedWorkspaceItems = $workspaceItems | ConvertTo-Json -Depth 5 + $formattedWorkspaceItems | Out-File -FilePath "workspace_$($wsId)_active_artifacts.json" + + foreach ($artifact in $workspaceItems) { + $attemptCounter = 0 + while ($true) { + $attemptCounter++ + if ($attemptCounter -gt 5) { + Write-Warning "Maximum retry attempts reached for artifact $($artifact.id). Skipping artifact." + break + } + + try { + Write-Host "Found artifact, ID: $($artifact.id), Type: $($artifact.type), Name: $($artifact.displayName)`n" + + Invoke-RestMethod -Method DELETE -Uri "https://api.fabric.microsoft.com/v1/workspaces/$wsId/items/$($artifact.id)?hardDelete=True" -Headers $h + Write-Host "Hard deleted artifact, ID: $($artifact.id)" + break + } + catch { + $error_returned = $_.Exception.Response.StatusCode + + if ($error_returned -ne 429) { + Write-Warning "Deletion API failed for $($artifact.id) ($error_returned)" + break + } + + $retryAfter = $_.Exception.Response.Headers.RetryAfter + + # RetryAfter is a RetryConditionHeaderValue, so the seconds come from Delta rather than the object itself + if ($null -ne $retryAfter -and $null -ne $retryAfter.Delta) { + $retryAfterSeconds = [Math]::Ceiling($retryAfter.Delta.TotalSeconds) + } + else { + $retryAfterSeconds = 60 + } + + Write-Host "Throttled, waiting $retryAfterSeconds seconds before retrying to delete $($artifact.id)" + + Start-Sleep -Seconds $retryAfterSeconds + } + } + } + } + } + catch { + Write-Error "Error occurred: $($PSItem.Exception.Message)" + } + finally { + $plainTextFabricToken = [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ssPtr) + $h = $null + } +} \ No newline at end of file diff --git a/scripts/tenant-remap-automated-deletion/Step6.ps1 b/scripts/tenant-remap-automated-deletion/Step6.ps1 new file mode 100644 index 00000000..0d0ea534 --- /dev/null +++ b/scripts/tenant-remap-automated-deletion/Step6.ps1 @@ -0,0 +1,114 @@ +#Requires -Modules Az.Accounts, Az.Resources + +function Remove-AllSoftDeletedArtifacts { + [CmdletBinding()] + + param( + [string]$AdminUpn=$null, + [Parameter(Mandatory=$true)] + [string]$WorkspaceIdsFilePath + ) + + $ErrorActionPreference = 'Stop' + + Set-StrictMode -Version Latest + + if ($WorkspaceIdsFilePath -ne $null -and (Test-Path -Path $WorkspaceIdsFilePath)) { + $workspaceIds = Get-Content -Path $WorkspaceIdsFilePath + } + else { + Write-Error "Workspace IDs file path is not provided or invalid. Please try again." + return + } + + if (($null -eq (Get-AzContext)) -or ((Get-AzContext).Account -like "MSI@*")) { + Connect-AzAccount -UseDeviceAuthentication | Out-Null + } + + if (-not $AdminUpn) { $AdminUpn = (Get-AzContext).Account.Id } + + $adminOid = (Get-AzADUser -UserPrincipalName $AdminUpn).Id + + $secureFabricToken = (Get-AzAccessToken -ResourceUrl 'https://api.fabric.microsoft.com').Token + $ssPtr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureFabricToken) + $plainTextFabricToken = [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($ssPtr) + + $h = @{ Authorization = "Bearer $plainTextFabricToken"; 'Content-Type' = 'application/json' } + + $confirmationPromptResponse = Read-Host "Are you sure that you want to permanently delete ALL of your soft-deleted artifacts? Type YES (case-sensitive) to confirm" + if ($confirmationPromptResponse -cne "YES") { + Write-Error "Confirmation not received. Skipping deletion of soft-deleted artifacts. Please try again." + return + } + + try { + foreach ($wsId in $workspaceIds) { + Write-Host "[Workspace] $($wsId)`n" + + $workspaceItems = [System.Collections.ArrayList]::new() + + $uri = "https://api.fabric.microsoft.com/v1/workspaces/$wsId/recoverableItems?include=DefaultIdentity" + do { + $response = Invoke-RestMethod -Method GET -Uri $uri -Headers $h + $response.value | ForEach-Object { $workspaceItems.Add($_) | Out-Null } + + $continuationToken = if ($response.PSObject.Properties.Name -contains 'continuationToken') { $response.continuationToken } else { $null } + if ($continuationToken) { + $uri = "https://api.fabric.microsoft.com/v1/workspaces/$wsId/recoverableItems?include=DefaultIdentity&continuationToken=$([System.Uri]::EscapeDataString($continuationToken))" + } + } while ($continuationToken) + + $formattedWorkspaceItems = $workspaceItems | ConvertTo-Json -Depth 5 + $formattedWorkspaceItems | Out-File -FilePath "workspace_$($wsId)_softdeleted_artifacts.json" + + foreach ($artifact in $workspaceItems) { + $attemptCounter = 0 + while ($true) { + $attemptCounter++ + if ($attemptCounter -gt 5) { + Write-Warning "Maximum retry attempts reached for artifact $($artifact.id). Skipping artifact." + break + } + + try { + Write-Host "Found artifact, ID: $($artifact.id), Type: $($artifact.type), Name: $($artifact.displayName)`n" + + Invoke-RestMethod -Method DELETE -Uri "https://api.fabric.microsoft.com/v1/workspaces/$wsId/recoverableItems/$($artifact.id)?hardDelete=True" -Headers $h + Write-Host "Hard deleted artifact, ID: $($artifact.id)" + break + } + catch { + $error_returned = $_.Exception.Response.StatusCode + + if ($error_returned -ne 429) { + Write-Warning "Deletion API failed for $($artifact.id) ($error_returned)" + break + } + + $retryAfter = $_.Exception.Response.Headers.RetryAfter + + # RetryAfter is a RetryConditionHeaderValue, so the seconds come from Delta rather than the object itself + if ($null -ne $retryAfter -and $null -ne $retryAfter.Delta) { + $retryAfterSeconds = [Math]::Ceiling($retryAfter.Delta.TotalSeconds) + } + else { + $retryAfterSeconds = 60 + } + + Write-Host "Throttled, waiting $retryAfterSeconds seconds before retrying to delete $($artifact.id)" + + Start-Sleep -Seconds $retryAfterSeconds + } + } + + } + } + } + catch { + Write-Error "Error occurred: $($PSItem.Exception.Message)" + } + finally { + $plainTextFabricToken = [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ssPtr) + $h = $null + } +} \ No newline at end of file From 831c14d6c339888864910c9faecf040fea932f81 Mon Sep 17 00:00:00 2001 From: Avi Verma Date: Mon, 10 Aug 2026 09:56:40 -0700 Subject: [PATCH 2/6] Improving error handling for exceptions and filtering down to Fabric items --- .../tenant-remap-automated-deletion/Step4.ps1 | 10 ++++++++-- .../tenant-remap-automated-deletion/Step5.ps1 | 20 ++++++++++++++----- .../tenant-remap-automated-deletion/Step6.ps1 | 19 ++++++++++++++---- 3 files changed, 38 insertions(+), 11 deletions(-) diff --git a/scripts/tenant-remap-automated-deletion/Step4.ps1 b/scripts/tenant-remap-automated-deletion/Step4.ps1 index 4da7f439..dad2b6e2 100644 --- a/scripts/tenant-remap-automated-deletion/Step4.ps1 +++ b/scripts/tenant-remap-automated-deletion/Step4.ps1 @@ -56,7 +56,13 @@ function Add-AdminOnPersonalWorkspaces { Write-Host "Admin granted to $wsId via API (lasts for 24 hours)" break } catch { - $error_returned = $_.Exception.Response.StatusCode + $response = $_.Exception.Response + + if (-not $response) { + throw + } + + $error_returned = $response.StatusCode if ($error_returned -ne 429) { Write-Warning "API grant failed for $wsId ($error_returned). This may happen if already Admin. Use UI link if NOT already Admin: https://app.powerbi.com/groups/$wsId" @@ -65,7 +71,7 @@ function Add-AdminOnPersonalWorkspaces { # 429 error case # docs: https://learn.microsoft.com/en-us/rest/api/fabric/articles/throttling - $retryAfter = $_.Exception.Response.Headers.RetryAfter + $retryAfter = $response.Headers.RetryAfter # RetryAfter is a RetryConditionHeaderValue, so the seconds come from Delta rather than the object itself if ($null -ne $retryAfter -and $null -ne $retryAfter.Delta) { diff --git a/scripts/tenant-remap-automated-deletion/Step5.ps1 b/scripts/tenant-remap-automated-deletion/Step5.ps1 index 96ebff65..7e219fcd 100644 --- a/scripts/tenant-remap-automated-deletion/Step5.ps1 +++ b/scripts/tenant-remap-automated-deletion/Step5.ps1 @@ -47,7 +47,6 @@ function Remove-AllActiveArtifacts { $workspaceItems = [System.Collections.ArrayList]::new() - # this can also have a 429 error, but unlikely $uri = "https://api.fabric.microsoft.com/v1/workspaces/$wsId/items?include=DefaultIdentity" do { $response = Invoke-RestMethod -Method GET -Uri $uri -Headers $h @@ -62,7 +61,12 @@ function Remove-AllActiveArtifacts { $formattedWorkspaceItems = $workspaceItems | ConvertTo-Json -Depth 5 $formattedWorkspaceItems | Out-File -FilePath "workspace_$($wsId)_active_artifacts.json" - foreach ($artifact in $workspaceItems) { + # filter down to Fabric only + $fabricItems = $workspaceItems | Where-Object { $_.type -NotIn @('SemanticModel', 'Dashboard', 'Report', 'PaginatedReport')} + $formattedFabricItems = $fabricItems | ConvertTo-Json -Depth 5 + $formattedFabricItems | Out-File -FilePath "workspace_$($wsId)_active_fabric_artifacts.json" + + foreach ($artifact in $fabricItems) { $attemptCounter = 0 while ($true) { $attemptCounter++ @@ -79,14 +83,20 @@ function Remove-AllActiveArtifacts { break } catch { - $error_returned = $_.Exception.Response.StatusCode + $response = $_.Exception.Response + + if (-not $response) { + throw + } + + $error_returned = $response.StatusCode if ($error_returned -ne 429) { - Write-Warning "Deletion API failed for $($artifact.id) ($error_returned)" + Write-Warning "API grant failed for $wsId ($error_returned). This may happen if already Admin. Use UI link if NOT already Admin: https://app.powerbi.com/groups/$wsId" break } - $retryAfter = $_.Exception.Response.Headers.RetryAfter + $retryAfter = $response.Headers.RetryAfter # RetryAfter is a RetryConditionHeaderValue, so the seconds come from Delta rather than the object itself if ($null -ne $retryAfter -and $null -ne $retryAfter.Delta) { diff --git a/scripts/tenant-remap-automated-deletion/Step6.ps1 b/scripts/tenant-remap-automated-deletion/Step6.ps1 index 0d0ea534..68c5fdb7 100644 --- a/scripts/tenant-remap-automated-deletion/Step6.ps1 +++ b/scripts/tenant-remap-automated-deletion/Step6.ps1 @@ -61,7 +61,12 @@ function Remove-AllSoftDeletedArtifacts { $formattedWorkspaceItems = $workspaceItems | ConvertTo-Json -Depth 5 $formattedWorkspaceItems | Out-File -FilePath "workspace_$($wsId)_softdeleted_artifacts.json" - foreach ($artifact in $workspaceItems) { + # filter down to Fabric only + $fabricItems = $workspaceItems | Where-Object { $_.type -NotIn @('SemanticModel', 'Dashboard', 'Report', 'PaginatedReport')} + $formattedFabricItems = $fabricItems | ConvertTo-Json -Depth 5 + $formattedFabricItems | Out-File -FilePath "workspace_$($wsId)_softdeleted_fabric_artifacts.json" + + foreach ($artifact in $fabricItems) { $attemptCounter = 0 while ($true) { $attemptCounter++ @@ -78,14 +83,20 @@ function Remove-AllSoftDeletedArtifacts { break } catch { - $error_returned = $_.Exception.Response.StatusCode + $response = $_.Exception.Response + + if (-not $response) { + throw + } + + $error_returned = $response.StatusCode if ($error_returned -ne 429) { - Write-Warning "Deletion API failed for $($artifact.id) ($error_returned)" + Write-Warning "API grant failed for $wsId ($error_returned). This may happen if already Admin. Use UI link if NOT already Admin: https://app.powerbi.com/groups/$wsId" break } - $retryAfter = $_.Exception.Response.Headers.RetryAfter + $retryAfter = $response.Headers.RetryAfter # RetryAfter is a RetryConditionHeaderValue, so the seconds come from Delta rather than the object itself if ($null -ne $retryAfter -and $null -ne $retryAfter.Delta) { From bf83e83b85385c6a6e1462ee385fa43baa1347a0 Mon Sep 17 00:00:00 2001 From: Avi Verma Date: Wed, 12 Aug 2026 14:12:40 -0700 Subject: [PATCH 3/6] Adding standalone documentation for scripts, including commands, ordering, and context --- .../tenant-remap-automated-deletion/README.md | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 scripts/tenant-remap-automated-deletion/README.md diff --git a/scripts/tenant-remap-automated-deletion/README.md b/scripts/tenant-remap-automated-deletion/README.md new file mode 100644 index 00000000..3ee0087b --- /dev/null +++ b/scripts/tenant-remap-automated-deletion/README.md @@ -0,0 +1,38 @@ +## Automated Item Deletion Scripts + +If you need to clean up a large number of Fabric items before tenant remap, download `Step0.ps1` through `Step6.ps1` and follow these steps: + +The final two steps permanently delete your items, and you can't restore them. Confirm that you backed up all item definitions and data before you continue. + +1. Sign in to the Global Admin account in Azure and open an Azure Cloud Shell session. Upload `Step0.ps1` through `Step6.ps1` to this session, and name them exactly as given. +1. Dot source each script by running the following command: + + ```powershell + . ./Step0.ps1; . ./Step1.ps1; . ./Step2.ps1; . ./Step3.ps1; . ./Step4.ps1; . ./Step5.ps1; . ./Step6.ps1 + ``` + +1. Run `Get-WorkspaceIds`. Note where `workspaceIds.txt`, `personalWorkspaceIds.txt`, and `sharedWorkspaceIds.txt` are stored. +1. Run `Restore-Workspaces -WorkspaceIdsFilePath workspaceIds.txt`. This command assumes that you're in the same folder as when you ran `Get-WorkspaceIds`. If not, make sure that `workspaceIds.txt`, `personalWorkspaceIds.txt`, and `sharedWorkspaceIds.txt` are in the same folder, and run the command again from that folder. +1. Open the Power BI Admin Portal and go to **Capacity settings**. Find a **Healthy** capacity and copy its ID. In the following command, replace `ID` with the copied ID, and then run the following command: + + ```powershell + Set-WorkspacesToCapacity -WorkspaceIdsFilePath workspaceIds.txt -CapacityId ID + ``` + + If the capacity becomes full, find another healthy capacity and repeat the process until all workspaces are assigned to a capacity. +1. Run the following command: + + ```powershell + Add-AdminOnSharedWorkspaces -SharedWorkspaceIdsFilePath sharedWorkspaceIds.txt + ``` + +1. Run the following command: + + ```powershell + Add-AdminOnPersonalWorkspaces -PersonalWorkspaceIdsFilePath personalWorkspaceIds.txt + ``` + +1. Run `Remove-AllActiveArtifacts -WorkspaceIdsFilePath workspaceIds.txt`. This step permanently deletes your items, and you can't restore them after executing the command. When prompted, type the confirmation word `YES`. +1. Run `Remove-AllSoftDeletedArtifacts -WorkspaceIdsFilePath workspaceIds.txt`. When prompted, type the confirmation word `YES`. + +These scripts perform a best-effort item deletion across the tenant and have built-in error handling, including throttling handling. \ No newline at end of file From 819808bc07d0aa6048721187faca85b7e35dd8e8 Mon Sep 17 00:00:00 2001 From: Avi Verma Date: Mon, 17 Aug 2026 15:39:03 -0700 Subject: [PATCH 4/6] Adding clarification on the purpose of deletion scripts --- scripts/tenant-remap-automated-deletion/README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/tenant-remap-automated-deletion/README.md b/scripts/tenant-remap-automated-deletion/README.md index 3ee0087b..ea4fef3d 100644 --- a/scripts/tenant-remap-automated-deletion/README.md +++ b/scripts/tenant-remap-automated-deletion/README.md @@ -1,6 +1,8 @@ ## Automated Item Deletion Scripts -If you need to clean up a large number of Fabric items before tenant remap, download `Step0.ps1` through `Step6.ps1` and follow these steps: +These scripts enable you to delete all Fabric items across all workspaces in your tenant. This is needed for tenant remap, which moves your Fabric and Power BI home region. To pass readiness for executing remap, you must delete all Fabric items in your tenant. + +To clean up the Fabric items in your tenant, download `Step0.ps1` through `Step6.ps1` and follow these steps: The final two steps permanently delete your items, and you can't restore them. Confirm that you backed up all item definitions and data before you continue. @@ -35,4 +37,4 @@ The final two steps permanently delete your items, and you can't restore them. C 1. Run `Remove-AllActiveArtifacts -WorkspaceIdsFilePath workspaceIds.txt`. This step permanently deletes your items, and you can't restore them after executing the command. When prompted, type the confirmation word `YES`. 1. Run `Remove-AllSoftDeletedArtifacts -WorkspaceIdsFilePath workspaceIds.txt`. When prompted, type the confirmation word `YES`. -These scripts perform a best-effort item deletion across the tenant and have built-in error handling, including throttling handling. \ No newline at end of file +These scripts perform a best-effort item deletion across the tenant and have built-in error handling, including throttling handling. From 4453f3734fc95849848dcde413bac4d154364aae Mon Sep 17 00:00:00 2001 From: Avi Verma Date: Tue, 18 Aug 2026 11:02:59 -0700 Subject: [PATCH 5/6] Updating documentation on item deletion scripts: output, troubleshooting, etc --- .../tenant-remap-automated-deletion/README.md | 46 +++++++++++++++++-- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/scripts/tenant-remap-automated-deletion/README.md b/scripts/tenant-remap-automated-deletion/README.md index ea4fef3d..9728b4a9 100644 --- a/scripts/tenant-remap-automated-deletion/README.md +++ b/scripts/tenant-remap-automated-deletion/README.md @@ -1,10 +1,22 @@ -## Automated Item Deletion Scripts +# Automated Item Deletion Scripts -These scripts enable you to delete all Fabric items across all workspaces in your tenant. This is needed for tenant remap, which moves your Fabric and Power BI home region. To pass readiness for executing remap, you must delete all Fabric items in your tenant. +Set of PowerShell scripts that a tenant administrator runs to delete all Fabric items across all workspaces in a tenant. All Fabric items must be deleted before tenant remap, which moves a tenant's home region. -To clean up the Fabric items in your tenant, download `Step0.ps1` through `Step6.ps1` and follow these steps: +> The final two steps permanently delete your items, and you can't restore them. Confirm that you backed up all item definitions and data before you continue. + +--- + +## What these scripts delete + +| Deleted | Preserved | +|---|---| +| **Fabric items**: Lakehouses, Warehouses, Notebooks, Dataflows, Eventhouses, Eventstreams, and all other Fabric artifacts are **permanently deleted** | **Power BI items**: Semantic Models, Dashboards, Reports, and Paginated Reports are **skipped**| -The final two steps permanently delete your items, and you can't restore them. Confirm that you backed up all item definitions and data before you continue. +--- + +## Usage + +To clean up the Fabric items in your tenant, download `Step0.ps1` through `Step6.ps1` and follow these steps: 1. Sign in to the Global Admin account in Azure and open an Azure Cloud Shell session. Upload `Step0.ps1` through `Step6.ps1` to this session, and name them exactly as given. 1. Dot source each script by running the following command: @@ -37,4 +49,28 @@ The final two steps permanently delete your items, and you can't restore them. C 1. Run `Remove-AllActiveArtifacts -WorkspaceIdsFilePath workspaceIds.txt`. This step permanently deletes your items, and you can't restore them after executing the command. When prompted, type the confirmation word `YES`. 1. Run `Remove-AllSoftDeletedArtifacts -WorkspaceIdsFilePath workspaceIds.txt`. When prompted, type the confirmation word `YES`. -These scripts perform a best-effort item deletion across the tenant and have built-in error handling, including throttling handling. +These scripts perform best-effort deletion of items across the tenant and have built-in error handling, including throttling retries. + +--- + +## Output Files + +| File | Step | Content | +|---|---|---| +| `workspaceIds.txt` | Step0 | List of all workspace IDs found in the tenant | +| `sharedWorkspaceIds.txt` | Step0 | List of all shared workspace IDs found | +| `personalWorkspaceIds.txt` | Step0 | List of all personal workspace IDs found | +| `workspace_$($wsId)_active_artifacts.json` | Step5 | List of active items (Power BI and Fabric) found | +| `workspace_$($wsId)_active_fabric_artifacts.json` | Step5 | List of active Fabric items found which are targeted for deletion | +| `workspace_$($wsId)_softdeleted_artifacts.json` | Step6 | List of soft-deleted items (Power BI and Fabric) found | +| `workspace_$($wsId)_softdeleted_fabric_artifacts.json` | Step6 | List of soft-deleted Fabric items found which are targeted for deletion | + +--- + +## Troubleshooting + +- **Restore-Workspaces reports workspace in Removing state**: No action is needed. The workspace is already being deleted by the system and can't be reliably restored +- **Set-WorkspacesToCapacity errors on full capacity**: Find health capacity with available space or create a new capacity and assign all remaining workspaces to this capacity +- **Add-AdminOnPersonalWorkspaces fails on non-429 error**: You may already be an admin on the workspace, or the workspace ID may be incorrect. Open the workspace directly in Fabric or Power BI to check +- **Remaining items after Remove-AllActiveArtifacts**: If there are remaining items reported in the tenant remap readiness checks after executing the Step 5 script, make sure to run Step 6 script as well to clean up any soft-deleted items. These items are in the recycle bin of each workspace +- **Remaining items after running all scripts**: These scripts perform best-effort deletion of items in the tenant. There still may be additional items, such as on Admin Monitoring workspaces, that need to be deleted before passing readiness checks From bfb98aa71649abcb39b876d549a646b66a69846c Mon Sep 17 00:00:00 2001 From: Avi Verma Date: Fri, 21 Aug 2026 10:12:06 -0700 Subject: [PATCH 6/6] Cleaning up README.md for tenant remap --- .../tenant-remap-automated-deletion/README.md | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/scripts/tenant-remap-automated-deletion/README.md b/scripts/tenant-remap-automated-deletion/README.md index 9728b4a9..292ebdb3 100644 --- a/scripts/tenant-remap-automated-deletion/README.md +++ b/scripts/tenant-remap-automated-deletion/README.md @@ -1,6 +1,6 @@ # Automated Item Deletion Scripts -Set of PowerShell scripts that a tenant administrator runs to delete all Fabric items across all workspaces in a tenant. All Fabric items must be deleted before tenant remap, which moves a tenant's home region. +Set of PowerShell scripts that a tenant administrator can run to delete all Fabric items across all workspaces in a tenant. All Fabric items must be deleted before tenant remap, which moves a tenant's home region. > The final two steps permanently delete your items, and you can't restore them. Confirm that you backed up all item definitions and data before you continue. @@ -10,13 +10,13 @@ Set of PowerShell scripts that a tenant administrator runs to delete all Fabric | Deleted | Preserved | |---|---| -| **Fabric items**: Lakehouses, Warehouses, Notebooks, Dataflows, Eventhouses, Eventstreams, and all other Fabric artifacts are **permanently deleted** | **Power BI items**: Semantic Models, Dashboards, Reports, and Paginated Reports are **skipped**| +| **Fabric items**: Lakehouses, Warehouses, Notebooks, Dataflows, Eventhouses, Eventstreams, and all other Fabric artifacts are **permanently deleted** | **Power BI items**: Semantic Models, Dashboards, Reports, and Paginated Reports are **preserved**| --- ## Usage -To clean up the Fabric items in your tenant, download `Step0.ps1` through `Step6.ps1` and follow these steps: +To clean up all Fabric items in your tenant, download `Step0.ps1` through `Step6.ps1` and follow these steps: 1. Sign in to the Global Admin account in Azure and open an Azure Cloud Shell session. Upload `Step0.ps1` through `Step6.ps1` to this session, and name them exactly as given. 1. Dot source each script by running the following command: @@ -49,8 +49,6 @@ To clean up the Fabric items in your tenant, download `Step0.ps1` through `Step6 1. Run `Remove-AllActiveArtifacts -WorkspaceIdsFilePath workspaceIds.txt`. This step permanently deletes your items, and you can't restore them after executing the command. When prompted, type the confirmation word `YES`. 1. Run `Remove-AllSoftDeletedArtifacts -WorkspaceIdsFilePath workspaceIds.txt`. When prompted, type the confirmation word `YES`. -These scripts perform best-effort deletion of items across the tenant and have built-in error handling, including throttling retries. - --- ## Output Files @@ -61,16 +59,16 @@ These scripts perform best-effort deletion of items across the tenant and have b | `sharedWorkspaceIds.txt` | Step0 | List of all shared workspace IDs found | | `personalWorkspaceIds.txt` | Step0 | List of all personal workspace IDs found | | `workspace_$($wsId)_active_artifacts.json` | Step5 | List of active items (Power BI and Fabric) found | -| `workspace_$($wsId)_active_fabric_artifacts.json` | Step5 | List of active Fabric items found which are targeted for deletion | +| `workspace_$($wsId)_active_fabric_artifacts.json` | Step5 | List of active Fabric items found, targeted for deletion | | `workspace_$($wsId)_softdeleted_artifacts.json` | Step6 | List of soft-deleted items (Power BI and Fabric) found | -| `workspace_$($wsId)_softdeleted_fabric_artifacts.json` | Step6 | List of soft-deleted Fabric items found which are targeted for deletion | +| `workspace_$($wsId)_softdeleted_fabric_artifacts.json` | Step6 | List of soft-deleted Fabric items found, targeted for deletion | --- ## Troubleshooting -- **Restore-Workspaces reports workspace in Removing state**: No action is needed. The workspace is already being deleted by the system and can't be reliably restored -- **Set-WorkspacesToCapacity errors on full capacity**: Find health capacity with available space or create a new capacity and assign all remaining workspaces to this capacity -- **Add-AdminOnPersonalWorkspaces fails on non-429 error**: You may already be an admin on the workspace, or the workspace ID may be incorrect. Open the workspace directly in Fabric or Power BI to check -- **Remaining items after Remove-AllActiveArtifacts**: If there are remaining items reported in the tenant remap readiness checks after executing the Step 5 script, make sure to run Step 6 script as well to clean up any soft-deleted items. These items are in the recycle bin of each workspace -- **Remaining items after running all scripts**: These scripts perform best-effort deletion of items in the tenant. There still may be additional items, such as on Admin Monitoring workspaces, that need to be deleted before passing readiness checks +- **`Restore-Workspaces` reports workspace in Removing state**: No action is needed. The workspace is already being deleted by the system and can't be reliably restored, so let the system clean it up +- **`Set-WorkspacesToCapacity` errors on full capacity**: Find a different, healthy capacity with available space or create a new capacity. Assign remaining workspaces to this capacity +- **`Add-AdminOnPersonalWorkspaces` fails on non-429 error**: You may already be an admin in the workspace, or the workspace ID may be incorrect. Open the workspace directly in Fabric or Power BI to check. Also note that granting admin to a personal workspace is temporary (lasts for 24 hours), so you may need to run the script again +- **Remaining items after `Remove-AllActiveArtifacts`**: If there are remaining items reported in readiness checks after executing `Remove-AllActiveArtifacts`, make sure to run `Remove-AllSoftDeletedArtifacts` to clean up any remaining soft-deleted items. These soft-deleted items are in the recycle bin of each workspace +- **Remaining items after running all scripts**: These scripts perform best-effort deletion of items in the tenant. There may be remaining items, such as items in Admin Monitoring workspaces, that need to be deleted before passing readiness checks \ No newline at end of file