Scripts/Delete-WindowsBackup.ps1
|
# Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # Delete-WindowsBackup.ps1 - deletes ALL Windows backup for a user (whole-user, permanent; no per-device, no undo). # Runs on Windows PowerShell 5.1 OR PowerShell 7. Requires UserWindowsSettings.ReadWrite.All + User.Read.All # and the Microsoft 365 Backup Administrator role. # Run: powershell -ExecutionPolicy Bypass -File .\Delete-WindowsBackup.ps1 (or pwsh) param( [string] $UserId, # UPN or object id of the user whose backup to delete [switch] $Fresh # force a clean sign-in to switch accounts ) $ErrorActionPreference = 'Stop' # ============================================================ # STEP 1: Check PowerShell version (works on 5.1 and 7) # ============================================================ Write-Host "[1/4] PowerShell $($PSVersionTable.PSVersion) detected." -ForegroundColor Green if ($PSVersionTable.PSVersion.Major -lt 5) { Write-Host " This script needs Windows PowerShell 5.1 or PowerShell 7. Please upgrade." -ForegroundColor Red return } # ============================================================ # STEP 2: Ensure the Graph auth module is available # ============================================================ if (-not (Get-Module -ListAvailable -Name Microsoft.Graph.Authentication)) { Write-Host "[2/4] Installing Microsoft.Graph.Authentication module (one-time)..." -ForegroundColor Cyan # Make the install fully non-interactive (no NuGet / untrusted-repo prompts on 5.1). try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch { Write-Verbose "TLS 1.2 could not be set: $_" } try { Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope CurrentUser -Confirm:$false -ErrorAction SilentlyContinue | Out-Null } catch { Write-Verbose "NuGet provider bootstrap skipped: $_" } # Trust PSGallery only for this install, then restore the previous policy so we don't # permanently change the user's machine state. $prevGalleryPolicy = $null try { $prevGalleryPolicy = (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue).InstallationPolicy } catch { Write-Verbose "Could not read PSGallery policy: $_" } try { if ($prevGalleryPolicy -and $prevGalleryPolicy -ne 'Trusted') { try { Set-PSRepository -Name PSGallery -InstallationPolicy Trusted -ErrorAction SilentlyContinue } catch { Write-Verbose "Could not set PSGallery policy: $_" } } Install-Module Microsoft.Graph.Authentication -Scope CurrentUser -Force -AllowClobber -Confirm:$false -Repository PSGallery } finally { if ($prevGalleryPolicy -and $prevGalleryPolicy -ne 'Trusted') { try { Set-PSRepository -Name PSGallery -InstallationPolicy $prevGalleryPolicy -ErrorAction SilentlyContinue } catch { Write-Verbose "Could not restore PSGallery policy: $_" } } } } else { Write-Host "[2/4] Microsoft.Graph.Authentication module present." -ForegroundColor Green } Import-Module Microsoft.Graph.Authentication # --- GET helper that follows paging --- function Get-GraphCollection([string] $Uri) { $items = [System.Collections.Generic.List[object]]::new() $next = $Uri while ($next) { $resp = Invoke-MgGraphRequest -Method GET -Uri $next -ErrorAction Stop if ($resp.value) { foreach ($v in $resp.value) { $items.Add($v) } } $next = $resp.'@odata.nextLink' } return $items } # --- Build a deviceId -> display-name map from the backup's own deviceprofile # payload (profileId + deviceDisplayName). No extra scope needed. --- function Get-DeviceNameMap([object[]] $Settings) { $map = @{} foreach ($s in $Settings) { if ($s.payloadType -like '*platform.backuprestore.deviceprofile*') { $inst = $s.instances | Select-Object -First 1 if ($inst.payload) { try { $json = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($inst.payload)) | ConvertFrom-Json $profId = ($json.profileId -replace '[{}]', '') if ($profId -and $json.deviceDisplayName) { $map[$profId] = $json.deviceDisplayName } } catch { Write-Verbose "Skipping undecodable payload: $_" } } } } return $map } # --- Explain a 403 in plain language: it's a role/consent problem, not a bug. --- function Show-AccessDeniedHelp([string] $Operation, [string[]] $RequiredScopes, [string] $Account) { Write-Host "" Write-Host "=================== ACCESS DENIED (HTTP 403) ===================" -ForegroundColor Red Write-Host (" Signed in as: {0}" -f $Account) -ForegroundColor Yellow Write-Host (" This account is NOT authorized to {0}." -f $Operation) -ForegroundColor Yellow Write-Host "" Write-Host " Cross-user backup delete requires BOTH of the following:" -ForegroundColor Cyan Write-Host " 1. ROLE : Microsoft 365 Backup Administrator - MUST be explicitly assigned" -ForegroundColor Cyan Write-Host " to this account. Global Administrator alone does NOT grant it." -ForegroundColor Cyan Write-Host " 2. Admin-consented SCOPES:" -ForegroundColor Cyan $ctx = Get-MgContext $have = if ($ctx) { $ctx.Scopes } else { @() } foreach ($s in $RequiredScopes) { $granted = $s -in $have $mark = if ($granted) { 'granted' } else { 'MISSING' } $color = if ($granted) { 'Green' } else { 'Red' } Write-Host (" - {0} [{1}]" -f $s, $mark) -ForegroundColor $color } Write-Host "" Write-Host " Note: this API checks EXPLICIT role membership, not Global Admin privilege." -ForegroundColor Yellow Write-Host " Even Global Administrators get 403 here unless the Microsoft 365 Backup" -ForegroundColor Yellow Write-Host " Administrator role is directly assigned to their account. A correctly scoped" -ForegroundColor Yellow Write-Host " token is also not enough on its own - the role is enforced separately." -ForegroundColor Yellow Write-Host " Fix: assign the Microsoft 365 Backup Administrator role to this account AND" -ForegroundColor Cyan Write-Host " grant admin consent for the scopes above, then re-run with -Fresh ." -ForegroundColor Cyan Write-Host "===============================================================" -ForegroundColor Red } # --- Connectivity pre-check: verify we can reach Microsoft sign-in + Graph. # Explains the reason clearly instead of a cryptic auth/retry error. --- function Test-GraphConnectivity { $endpoints = @( @{ Host = 'login.microsoftonline.com'; Purpose = 'Microsoft sign-in' }, @{ Host = 'graph.microsoft.com'; Purpose = 'Microsoft Graph API' } ) $blocked = @() foreach ($e in $endpoints) { $ok = $false try { $client = New-Object System.Net.Sockets.TcpClient $iar = $client.BeginConnect($e.Host, 443, $null, $null) $ok = $iar.AsyncWaitHandle.WaitOne(5000, $false) -and $client.Connected $client.Close() } catch { $ok = $false } if (-not $ok) { $blocked += $e } } if ($blocked.Count -gt 0) { $netUp = $false try { $c = New-Object System.Net.Sockets.TcpClient $i = $c.BeginConnect('www.microsoft.com', 443, $null, $null) $netUp = $i.AsyncWaitHandle.WaitOne(5000, $false) -and $c.Connected $c.Close() } catch { $netUp = $false } Write-Host "" Write-Host "=================== CANNOT REACH MICROSOFT SIGN-IN ===================" -ForegroundColor Red foreach ($b in $blocked) { Write-Host (" X {0}:443 ({1}) is not reachable" -f $b.Host, $b.Purpose) -ForegroundColor Red } Write-Host "" if ($netUp) { Write-Host " Your machine HAS internet, but these Microsoft endpoints are blocked." -ForegroundColor Yellow Write-Host " This is usually a firewall or proxy restriction on this machine/network." -ForegroundColor Yellow } else { Write-Host " Your machine appears to have NO outbound internet access." -ForegroundColor Yellow } Write-Host "" Write-Host " WHY you see a sign-in error: signing in requires reaching" -ForegroundColor Yellow Write-Host " login.microsoftonline.com (Azure AD). Without it, NO tool can sign in" -ForegroundColor Yellow Write-Host " (this affects Connect-MgGraph, Graph Explorer, and the SDK too - not just this script)." -ForegroundColor Yellow Write-Host "" Write-Host " TO FIX: ask your network/IT admin to allow, on port 443:" -ForegroundColor Cyan Write-Host " login.microsoftonline.com" -ForegroundColor Cyan Write-Host " graph.microsoft.com" -ForegroundColor Cyan Write-Host " If your network uses a proxy, configure it, e.g.:" -ForegroundColor Cyan Write-Host ' netsh winhttp set proxy proxy-server="http://YOURPROXY:PORT"' -ForegroundColor DarkGray Write-Host "======================================================================" -ForegroundColor Red return $false } return $true } # --- Target --- if (-not $UserId) { $UserId = Read-Host "Enter the user UPN whose backup you want to delete" } if ($UserId) { $UserId = $UserId.Trim() } if ([string]::IsNullOrWhiteSpace($UserId)) { Write-Host "No user specified. Aborted." -ForegroundColor Yellow return } # --- Sign in with the DELETE scope (+ User.Read.All to resolve UPN). # -Fresh clears the cached session so you can sign in as a different account. # Interactive (browser) first; device code only if the browser sign-in fails. --- if ($Fresh) { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null Write-Host "Cleared cached sign-in. You'll sign in fresh." -ForegroundColor Cyan } Write-Host "[3/4] Signing in to Microsoft Graph..." -ForegroundColor Cyan # No upfront network check - only diagnose connectivity if sign-in actually fails, # so the normal (working) path stays fast. try { Connect-MgGraph -Scopes 'UserWindowsSettings.ReadWrite.All', 'User.Read.All' -NoWelcome -ErrorAction Stop -WarningAction SilentlyContinue } catch { try { Write-Host "Browser sign-in unavailable - use the device code below to sign in:" -ForegroundColor Cyan Connect-MgGraph -Scopes 'UserWindowsSettings.ReadWrite.All', 'User.Read.All' -NoWelcome -UseDeviceAuthentication -ErrorAction Stop } catch { Test-GraphConnectivity | Out-Null Write-Host "Sign-in failed. See the guidance above." -ForegroundColor Red return } } $ctx = Get-MgContext if (-not $ctx -or -not $ctx.Account) { Write-Host "Sign-in did not complete. Please try again." -ForegroundColor Red return } $tenantId = $ctx.TenantId Write-Host " Signed in as $($ctx.Account)." -ForegroundColor Green # --- Resolve UPN -> object id (delete needs {id}@{tenant}) --- Write-Host "[4/4] Resolving user and reading current backup..." -ForegroundColor Cyan try { if ($UserId -match '^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$') { $objId = $UserId; $target = $UserId } else { $encodedUserId = [System.Uri]::EscapeDataString($UserId) # Encode UPN for safe insertion into the URI path (e.g. '#') $u = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/users/$encodedUserId`?`$select=id,userPrincipalName" $objId = $u.id; $target = $u.userPrincipalName } } catch { Write-Host "Could not find user '$UserId' in this tenant: $($_.Exception.Message)" -ForegroundColor Red return } # --- Blast radius (read what would be deleted) --- $settings = @() try { $settings = Get-GraphCollection "https://graph.microsoft.com/beta/users/$objId@$tenantId/settings/windows" } catch { $msg = $_.Exception.Message $code = $null; try { $code = [int]$_.Exception.Response.StatusCode } catch { Write-Verbose "No HTTP status code on exception." } if ($code -eq 403 -or $msg -match '403' -or $msg -match 'Forbidden') { Show-AccessDeniedHelp -Operation ("read/delete the backup for '$target'") -RequiredScopes @('UserWindowsSettings.ReadWrite.All', 'User.Read.All') -Account $ctx.Account } else { Write-Host "Could not read backup for '$target': $msg" -ForegroundColor Red } return } if (-not $settings -or @($settings).Count -eq 0) { Write-Host "No backup data found for '$target' (nothing to delete)." -ForegroundColor Yellow return } # Project to objects first (Invoke-MgGraphRequest returns hashtables on PS 5.1, # which don't support -ExpandProperty / Group-Object on their keys). $devRows = foreach ($s in $settings) { [pscustomobject]@{ Device = $s.windowsDeviceId } } $deviceIds = @($devRows | Where-Object { $_.Device } | Select-Object -ExpandProperty Device -Unique) $devices = $deviceIds.Count $count = @($settings).Count # --- Show which devices/profiles will be removed (with display names) --- $nameMap = Get-DeviceNameMap $settings Write-Host "`n-- Device profiles to be deleted --" -ForegroundColor Yellow $devRows | Group-Object Device | ForEach-Object { $devId = $_.Name $dName = if ($devId) { if ($nameMap[$devId]) { $nameMap[$devId] } else { '(name unavailable)' } } else { '(account-level)' } [pscustomobject]@{ DeviceName = $dName DeviceId = if ($devId) { $devId } else { '(account-level/default)' } SettingCount = $_.Count } } | Format-Table -AutoSize # --- Single Y/N confirmation, then delete --- Write-Host "" $answer = Read-Host ("$target has $devices device profile(s) and $count setting(s). Press Y to delete ALL of it (this cannot be undone)") if ($answer -notmatch '^(y|yes)$') { Write-Host "Aborted. No data was deleted." -ForegroundColor Yellow return } # --- DELETE (whole-user, irreversible) --- $uri = "https://graph.microsoft.com/beta/users/$objId@$tenantId/settings/windows" try { Invoke-MgGraphRequest -Method DELETE -Uri $uri -ErrorAction Stop | Out-Null Write-Host "DELETED. All backup data for $target has been removed (HTTP 204)." -ForegroundColor Green } catch { $msg = $_.Exception.Message $code = $null; try { $code = [int]$_.Exception.Response.StatusCode } catch { Write-Verbose "No HTTP status code on exception." } if ($code -eq 403 -or $msg -match '403' -or $msg -match 'Forbidden') { Show-AccessDeniedHelp -Operation ("delete the backup for '$target'") -RequiredScopes @('UserWindowsSettings.ReadWrite.All', 'User.Read.All') -Account $ctx.Account } else { Write-Host "Delete failed (HTTP $code): $msg" -ForegroundColor Red } } Write-Host "`nDone. Press Enter to close..." -ForegroundColor Cyan [void](Read-Host) |