Scripts/View-WindowsBackup.ps1
|
# Copyright (c) Microsoft Corporation. # Licensed under the MIT License. <# View-WindowsBackup.ps1 Standalone admin script to VIEW Windows Backup for Organizations data. What it does: - Signs you in (handles scopes automatically). - Views your own backup (press Enter) or another user's (type their UPN). - Shows a summary: total devices/settings/size, then a per-device rollup. - Lets you drill into one device to see its settings. - Optionally exports the data to a JSON file (Y/N). Requirements: - Windows PowerShell 5.1 OR PowerShell 7 (both supported). - Microsoft.Graph.Authentication module (auto-installed). - To view ANOTHER user: Microsoft 365 Backup Administrator role + admin consent for UserWindowsSettings.Read.All and User.Read.All. Run (works on either PowerShell version): powershell -ExecutionPolicy Bypass -File .\View-WindowsBackup.ps1 -- or -- pwsh -ExecutionPolicy Bypass -File .\View-WindowsBackup.ps1 #> param( [string] $UserId, # UPN or object id; blank = your own backup [string] $ExportPath = $env:USERPROFILE, # where a JSON export is saved if you choose Y [int] $TopDevices = 15, # how many devices to list before summarizing the rest [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 # --- -Fresh: drop any cached session so you can sign in as a different account --- if ($Fresh) { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null Write-Host "Cleared cached sign-in. You'll sign in fresh." -ForegroundColor Cyan } # --- 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) { # Is general internet up? (helps explain WHY) $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 } # --- Sign in with the scopes needed (reuse session if it already has them). # Interactive (browser) first; device code only if the browser sign-in fails. --- function Connect-Backup([string[]] $Scopes) { if (-not $script:Fresh) { $ctx = Get-MgContext $missing = if ($ctx) { $Scopes | Where-Object { $_ -notin $ctx.Scopes } } else { $Scopes } if ($ctx -and -not $missing) { return $ctx } } # Try interactive (browser/WAM) first; fall back to device code if it fails. # No upfront network check - we only diagnose connectivity if sign-in actually fails, # so the normal (working) path stays fast. try { Connect-MgGraph -Scopes $Scopes -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 $Scopes -NoWelcome -UseDeviceAuthentication -ErrorAction Stop } catch { # Both sign-in methods failed - diagnose WHY (network/proxy) now. Test-GraphConnectivity | Out-Null throw "Sign-in failed. See the guidance above." } } $ctx = Get-MgContext if (-not $ctx -or -not $ctx.Account) { throw "Sign-in did not complete. Please try again." } return $ctx } # --- 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 straight from the backup's own # 'deviceprofile' payload (profileId + deviceDisplayName). No extra scope, # and it works even for devices not registered in Entra/Intune. --- 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 '[{}]', '') # strip braces -> matches windowsDeviceId 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. # Runs additional checks on the current token's scopes to show what's missing. --- 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 access 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 } # --- Ask who to view --- if (-not $UserId) { $UserId = Read-Host "Enter the user UPN to view (or press Enter to view your own backup)" } if ($UserId) { $UserId = $UserId.Trim() } $useMe = [string]::IsNullOrWhiteSpace($UserId) # --- Build the request (self vs another user) --- try { Write-Host "[3/4] Signing in to Microsoft Graph..." -ForegroundColor Cyan if ($useMe) { $ctx = Connect-Backup @('UserWindowsSettings.Read') $target = $ctx.Account $uri = 'https://graph.microsoft.com/beta/me/settings/windows' } else { $ctx = Connect-Backup @('UserWindowsSettings.Read.All', 'User.Read.All') # Resolve UPN -> object id (cross-user beta calls need {id}@{tenant}) 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 } $uri = "https://graph.microsoft.com/beta/users/$objId@$($ctx.TenantId)/settings/windows" } Write-Host " Signed in as $($ctx.Account)." -ForegroundColor Green Write-Host "[4/4] Reading backup for '$target'..." -ForegroundColor Cyan $settings = Get-GraphCollection $uri } catch { $msg = $_.Exception.Message $code = $null; try { $code = [int]$_.Exception.Response.StatusCode } catch { Write-Verbose "No HTTP status code on exception." } $who = if ($target) { $target } else { $UserId } if ($code -eq 403 -or $msg -match '403' -or $msg -match 'Forbidden') { $acct = try { (Get-MgContext).Account } catch { $null } $req = if ($useMe) { @('UserWindowsSettings.Read') } else { @('UserWindowsSettings.Read.All', 'User.Read.All') } Show-AccessDeniedHelp -Operation ("read the backup for '$who'") -RequiredScopes $req -Account $acct } else { Write-Host "Could not read backup for '$who': $msg" -ForegroundColor Red } return } if (-not $settings -or @($settings).Count -eq 0) { Write-Host "No backup data found for '$target'." -ForegroundColor Yellow return } Write-Host "Retrieved $(@($settings).Count) setting(s). Building view..." -ForegroundColor Green # --- Flatten to rows --- $rows = foreach ($s in $settings) { $inst = $s.instances | Select-Object -First 1 [pscustomobject]@{ Device = $s.windowsDeviceId PayloadType = $s.payloadType SettingType = $s.settingType LastModified = $inst.lastModifiedDateTime Expiration = $inst.expirationDateTime Bytes = if ($inst.payload) { [System.Text.Encoding]::UTF8.GetByteCount($inst.payload) } else { 0 } Payload = $inst.payload } } # --- Resolve device display names from the backup's own deviceprofile payload --- $deviceNameMap = Get-DeviceNameMap $settings # --- Per-device rollup --- $byDevice = $rows | Group-Object Device | ForEach-Object { $devId = $_.Name $dName = if ($devId) { if ($deviceNameMap[$devId]) { $deviceNameMap[$devId] } else { '(name unavailable)' } } else { '(account-level)' } [pscustomobject]@{ DeviceName = $dName DeviceId = if ($devId) { $devId } else { '(account-level/default)' } SettingCount = $_.Count SizeKB = [math]::Round((($_.Group.Bytes | Measure-Object -Sum).Sum)/1KB, 1) NewestMod = ($_.Group.LastModified | Where-Object { $_ } | Sort-Object | Select-Object -Last 1) } } | Sort-Object SizeKB -Descending $totalKB = [math]::Round((($rows.Bytes | Measure-Object -Sum).Sum)/1KB, 1) Write-Host "`n============================================================" -ForegroundColor Cyan Write-Host (" Backup for: {0}" -f $target) -ForegroundColor Cyan Write-Host (" Devices: {0} Settings: {1} Total size: {2} KB" -f $byDevice.Count, $rows.Count, $totalKB) -ForegroundColor Cyan Write-Host "============================================================`n" -ForegroundColor Cyan Write-Host "-- Devices / profiles (largest first) --" -ForegroundColor Cyan $byDevice | Select-Object -First $TopDevices | Format-Table DeviceName, DeviceId, SettingCount, SizeKB, @{n='NewestMod';e={ if($_.NewestMod){([datetime]$_.NewestMod).ToString('yyyy-MM-dd')} }} -AutoSize if ($byDevice.Count -gt $TopDevices) { $rest = $byDevice | Select-Object -Skip $TopDevices Write-Host (" ... and {0} more device(s), {1} KB total." -f $rest.Count, (($rest.SizeKB | Measure-Object -Sum).Sum)) -ForegroundColor DarkGray } # --- Optional drill-down into one device --- $drillRows = $null # settings of the device the admin drilled into (if any) $drillId = $null $pick = Read-Host "`nEnter a device id (full or first 8 chars) to VIEW its settings, or press Enter to skip" if ($pick -and $pick.Trim()) { $p = $pick.Trim() $drillRows = $rows | Where-Object { $_.Device -and ($_.Device -eq $p -or $_.Device.StartsWith($p)) } if (-not $drillRows) { Write-Host "No device matched '$p'." -ForegroundColor Yellow } else { $drillId = ($drillRows | Select-Object -First 1).Device $drillName = if ($drillId -and $deviceNameMap[$drillId]) { $deviceNameMap[$drillId] } else { '(name unavailable)' } Write-Host "`n-- Settings for device $drillName [$drillId] ($($drillRows.Count)) --" -ForegroundColor Cyan $i = 0 $drillRows | ForEach-Object { $i++ [pscustomobject]@{ '#' = $i PayloadType = ($_.PayloadType -replace '^windows\.data\.','') LastModified = $(if($_.LastModified){([datetime]$_.LastModified).ToString('yyyy-MM-dd HH:mm')}) Bytes = $_.Bytes } } | Format-Table -AutoSize # Dig deeper: decode a specific setting's payload to see its actual value. $sel = Read-Host "Enter a # (or part of a PayloadType) to DECODE its payload, or press Enter to skip" if ($sel -and $sel.Trim()) { $s = $sel.Trim() $one = if ($s -match '^\d+$') { $drillRows[[int]$s - 1] } else { $drillRows | Where-Object { $_.PayloadType -like "*$s*" } | Select-Object -First 1 } if (-not $one) { Write-Host "No matching setting." -ForegroundColor Yellow } else { Write-Host "`n-- $($one.PayloadType) (decoded) --" -ForegroundColor Cyan if ($one.Payload) { try { $decoded = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($one.Payload)) if ($decoded.Length -gt 4000) { $decoded = $decoded.Substring(0,4000) + " ...(truncated)" } $decoded } catch { Write-Host "<payload is binary - not readable as text>" -ForegroundColor DarkGray } } else { Write-Host "<no payload>" -ForegroundColor DarkGray } } } } } # --- Optional export: ask WHETHER + SCOPE first, then FORMAT --- $doExport = (Read-Host "`nDo you want to export the data? (Y/N)").Trim().ToUpper() $fmt = 'N' $exportRows = $rows $scopeLabel = 'all devices' if ($doExport -in 'Y','YES') { # 1. Scope: type a device id for one, A for all, Enter to skip. Re-prompt until a valid choice. $scopeChosen = $false while (-not $scopeChosen) { $scopeInput = (Read-Host "Export scope - type a device id (full or first 8 chars) for just one, press A for ALL devices, or press Enter to skip export").Trim() if (-not $scopeInput) { # Enter = skip export entirely. $doExport = 'N' $scopeChosen = $true } elseif ($scopeInput.ToUpper() -eq 'A') { $exportRows = $rows $scopeLabel = 'all devices' $scopeChosen = $true } else { $match = $rows | Where-Object { $_.Device -and ($_.Device -eq $scopeInput -or $_.Device.StartsWith($scopeInput)) } if ($match) { $exportRows = $match $scopeLabel = "device $(($match | Select-Object -First 1).Device)" $scopeChosen = $true } else { Write-Host "No device matched '$scopeInput'. Press Enter to skip, A for ALL devices, or try a valid device id." -ForegroundColor Yellow } } } # 2. Format: which kind of export? (skipped if the admin chose to skip above) if ($doExport -in 'Y','YES') { Write-Host "`nExport format:" -ForegroundColor Cyan Write-Host " [J] JSON - metadata only (device configuration, no payloads)" Write-Host " [P] JSON - WITH decoded payloads (actual setting values)" Write-Host " [D] DAT - ZIP of raw .dat payload files, one per setting, per device" $fmt = (Read-Host "Choose J / P / D").Trim().ToUpper() } } if ($fmt -in 'J','P','D') { if (-not (Test-Path $ExportPath)) { New-Item -ItemType Directory -Force -Path $ExportPath | Out-Null } $safe = ($target -replace '[^\w.@-]', '_') $stamp = Get-Date -Format 'yyyyMMdd-HHmmss' if ($fmt -eq 'D') { # DAT: {deviceId}/{payloadType}.dat = decoded payload bytes, + manifest.json, zipped. $staging = Join-Path ([System.IO.Path]::GetTempPath()) "wba_$safe`_$stamp" New-Item -ItemType Directory -Force -Path $staging | Out-Null $manifest = foreach ($r in $exportRows) { $devFolder = if ($r.Device) { $r.Device } else { '_account-level' } $devDir = Join-Path $staging $devFolder if (-not (Test-Path $devDir)) { New-Item -ItemType Directory -Force -Path $devDir | Out-Null } $datName = (($r.PayloadType -replace '[^\w.-]','_') + '.dat') $datPath = Join-Path $devDir $datName $sizeB = 0 if ($r.Payload) { try { $b = [Convert]::FromBase64String($r.Payload); [System.IO.File]::WriteAllBytes($datPath, $b); $sizeB = $b.Length } catch { Set-Content -LiteralPath $datPath -Value $r.Payload -Encoding Ascii } } else { New-Item -ItemType File -Force -Path $datPath | Out-Null } [pscustomobject]@{ device=$r.Device; payloadType=$r.PayloadType; lastModified=$r.LastModified; decodedBytes=$sizeB; file="$devFolder/$datName" } } [ordered]@{ exportedBy=$ctx.Account; exportedUtc=(Get-Date).ToUniversalTime().ToString('o'); targetUser=$target; scope=$scopeLabel; settingCount=@($exportRows).Count; entries=$manifest } | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath (Join-Path $staging 'manifest.json') -Encoding UTF8 $zip = Join-Path $ExportPath "$safe`_windowsbackup_$stamp.zip" if (Test-Path $zip) { Remove-Item $zip -Force } Compress-Archive -Path (Join-Path $staging '*') -DestinationPath $zip -Force Remove-Item $staging -Recurse -Force -ErrorAction SilentlyContinue Write-Warning "DAT export contains decoded payload bytes (personal data). Store it securely." Write-Host "Exported ($scopeLabel) to: $zip" -ForegroundColor Green } else { # JSON: metadata-only (J) or with decoded payloads (P). $withPayload = ($fmt -eq 'P') $out = foreach ($r in $exportRows) { $o = [ordered]@{ Device=$r.Device; PayloadType=$r.PayloadType; SettingType=$r.SettingType; LastModified=$r.LastModified; Expiration=$r.Expiration; Bytes=$r.Bytes } if ($withPayload) { $o.DecodedPayload = if ($r.Payload) { try { [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($r.Payload)) } catch { '<binary payload>' } } else { $null } } [pscustomobject]$o } $file = Join-Path $ExportPath "$safe`_windowsbackup_$stamp.json" [ordered]@{ exportedBy=$ctx.Account; exportedUtc=(Get-Date).ToUniversalTime().ToString('o'); targetUser=$target; scope=$scopeLabel; includesPayload=$withPayload; settingCount=@($out).Count; settings=$out } | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $file -Encoding UTF8 if ($withPayload) { Write-Warning "JSON export includes decoded payloads (personal data). Store it securely." } Write-Host "Exported ($scopeLabel) to: $file" -ForegroundColor Green } } else { Write-Host "Skipped export. No file was written." -ForegroundColor DarkGray } Write-Host "`nDone. Press Enter to close..." -ForegroundColor Cyan [void](Read-Host) |