diff --git a/scripts/tenant-remap-automated-deletion/README.md b/scripts/tenant-remap-automated-deletion/README.md new file mode 100644 index 00000000..292ebdb3 --- /dev/null +++ b/scripts/tenant-remap-automated-deletion/README.md @@ -0,0 +1,74 @@ +# Automated Item Deletion Scripts + +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. + +--- + +## 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 **preserved**| + +--- + +## Usage + +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: + + ```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`. + +--- + +## 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, 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, 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, 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 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..dad2b6e2 --- /dev/null +++ b/scripts/tenant-remap-automated-deletion/Step4.ps1 @@ -0,0 +1,97 @@ +#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 { + $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" + break + } + + # 429 error case + # docs: https://learn.microsoft.com/en-us/rest/api/fabric/articles/throttling + $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) { + $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..7e219fcd --- /dev/null +++ b/scripts/tenant-remap-automated-deletion/Step5.ps1 @@ -0,0 +1,124 @@ +#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() + + $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" + + # 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++ + 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 { + $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" + break + } + + $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) { + $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..68c5fdb7 --- /dev/null +++ b/scripts/tenant-remap-automated-deletion/Step6.ps1 @@ -0,0 +1,125 @@ +#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" + + # 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++ + 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 { + $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" + break + } + + $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) { + $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