Core enhancements: - Expanded default export/import scope to ~45 object types including DeviceManagementIntents - Added -AllPages pagination support across Graph queries for large tenants - Invoke-GraphRequest now throws on 4xx/5xx instead of silently returning null - Added macOS Keychain fallback for secret retrieval in headless auth flow - Added NameSearchPattern/NameReplacePattern mutation support through export/import forms New toolkit scripts: - Bulk-AppAssignment.ps1: bulk-assign apps to groups/All Users/All Devices - Bulk-AssignmentManager.ps1: add/remove assignments for any policy type with correct @odata.type - Backup-Restore-Assignments.ps1: JSON backup with cross-tenant group resolution - Export-AssignmentsToCsv.ps1: CSV/Markdown documentation output - Bulk-RenamePolicies.ps1: regex search/replace and prefix mutations - Bulk-DeviceOperations.ps1: delete/retire/wipe/lock/sync with -WhatIf safeguards - Start-IntuneManagementTui.ps1: interactive terminal UI for headless operations - Create-IntuneManagementApp.ps1: helper for app registration setup Updated existing scripts: - Export-Policies.ps1 / Import-Policies.ps1: wired mutation params through - Start-HeadlessIntune.ps1: integrated TUI and new parameter forwarding
524 lines
21 KiB
PowerShell
524 lines
21 KiB
PowerShell
#requires -Version 5.1
|
|
<#
|
|
.SYNOPSIS
|
|
Backup and restore Intune policy/app assignments.
|
|
.DESCRIPTION
|
|
Backs up assignments for selected object types to a JSON file,
|
|
or restores assignments from a previously created backup.
|
|
Works cross-platform (macOS/Linux/Windows) using the headless auth stack.
|
|
.EXAMPLE
|
|
# Backup
|
|
./Scripts/Backup-Restore-Assignments.ps1 -TenantId "..." -Mode Backup -OutputPath ./backups/assignments-backup.json
|
|
|
|
# Restore
|
|
./Scripts/Backup-Restore-Assignments.ps1 -TenantId "..." -Mode Restore -InputPath ./backups/assignments-backup.json
|
|
#>
|
|
[CmdletBinding()]
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$TenantId,
|
|
|
|
[Parameter(Mandatory = $true)]
|
|
[ValidateSet("Backup","Restore")]
|
|
[string]$Mode,
|
|
|
|
[string]$OutputPath,
|
|
|
|
[string]$InputPath,
|
|
|
|
[string]$AppId,
|
|
|
|
[string]$Secret,
|
|
|
|
[string]$Certificate,
|
|
|
|
[ValidateSet("AppOnly","Browser","DeviceCode")]
|
|
[string]$AuthMode = "AppOnly",
|
|
|
|
[string]$RedirectUri,
|
|
|
|
[string]$SettingsFile
|
|
)
|
|
|
|
$ErrorActionPreference = "Stop"
|
|
|
|
#region Helper functions
|
|
function Test-FzfAvailable
|
|
{
|
|
return [bool](Get-Command fzf -ErrorAction SilentlyContinue)
|
|
}
|
|
|
|
function Show-FzfMenu
|
|
{
|
|
param(
|
|
[Parameter(Mandatory)]
|
|
[string[]]$Items,
|
|
[string]$Header = "Select one",
|
|
[switch]$Multi
|
|
)
|
|
$argsList = @("--header=$Header")
|
|
if($Multi) { $argsList += "--multi" }
|
|
$selected = $Items | fzf @argsList --bind=space:toggle
|
|
if(-not $selected) { return $null }
|
|
if($Multi) { return @($selected -split "`r?`n" | Where-Object { $_ }) }
|
|
return $selected
|
|
}
|
|
|
|
function Show-NumberedMenu
|
|
{
|
|
param(
|
|
[Parameter(Mandatory)]
|
|
[string[]]$Items,
|
|
[string]$Header = "Select one or more",
|
|
[switch]$Multi
|
|
)
|
|
Write-Host "`n$Header" -ForegroundColor Cyan
|
|
for($i=0; $i -lt $Items.Count; $i++)
|
|
{
|
|
Write-Host " $($i+1). $($Items[$i])"
|
|
}
|
|
if($Multi)
|
|
{
|
|
$prompt = "Enter numbers separated by commas (e.g. 1,3,5) or 'all'"
|
|
}
|
|
else
|
|
{
|
|
$prompt = "Enter a number"
|
|
}
|
|
$choice = Read-Host $prompt
|
|
if($choice -eq "all" -and $Multi) { return $Items }
|
|
$indices = $choice -split "," | ForEach-Object { $_.Trim() } | Where-Object { $_ -match "^\d+$" } | ForEach-Object { [int]$_ - 1 } | Where-Object { $_ -ge 0 -and $_ -lt $Items.Count }
|
|
if($Multi)
|
|
{
|
|
return $Items[$indices] | Select-Object -Unique
|
|
}
|
|
else
|
|
{
|
|
if($indices.Count -eq 0) { return $null }
|
|
return $Items[$indices[0]]
|
|
}
|
|
}
|
|
|
|
function Select-MenuItem
|
|
{
|
|
param(
|
|
[Parameter(Mandatory)]
|
|
[string[]]$Items,
|
|
[string]$Header = "Select one",
|
|
[switch]$Multi
|
|
)
|
|
if(Test-FzfAvailable)
|
|
{
|
|
return Show-FzfMenu -Items $Items -Header $Header -Multi:$Multi
|
|
}
|
|
return Show-NumberedMenu -Items $Items -Header $Header -Multi:$Multi
|
|
}
|
|
|
|
function Read-YesNo
|
|
{
|
|
param(
|
|
[string]$Prompt,
|
|
[bool]$Default = $false
|
|
)
|
|
$defaultChar = if($Default) { "Y" } else { "N" }
|
|
$response = Read-Host "$Prompt [Y/n] (default: $defaultChar)"
|
|
if([string]::IsNullOrWhiteSpace($response)) { return $Default }
|
|
return $response -match "^\s*y"
|
|
}
|
|
|
|
function Get-DefaultSettingsPath
|
|
{
|
|
if($IsWindows -or $env:OS -eq "Windows_NT")
|
|
{
|
|
if($env:LOCALAPPDATA) { return (Join-Path $env:LOCALAPPDATA "macOS_IntuneManagement\Settings.json") }
|
|
return (Join-Path $env:USERPROFILE "AppData\Local\macOS_IntuneManagement\Settings.json")
|
|
}
|
|
if($IsMacOS) { return (Join-Path $HOME "Library/Application Support/macOS_IntuneManagement/Settings.json") }
|
|
return (Join-Path $HOME ".local/share/macOS_IntuneManagement/Settings.json")
|
|
}
|
|
#endregion
|
|
|
|
#region Validate paths
|
|
if($Mode -eq "Backup" -and -not $OutputPath)
|
|
{
|
|
throw "Backup mode requires -OutputPath."
|
|
}
|
|
if($Mode -eq "Restore" -and -not $InputPath)
|
|
{
|
|
throw "Restore mode requires -InputPath."
|
|
}
|
|
if($Mode -eq "Restore" -and -not (Test-Path $InputPath))
|
|
{
|
|
throw "Input file not found: $InputPath"
|
|
}
|
|
#endregion
|
|
|
|
#region Initialize Runtime
|
|
$projectRoot = Split-Path -Parent $PSScriptRoot
|
|
$runtimeModule = Join-Path $projectRoot "Runtime/IntuneManagement.Runtime.psd1"
|
|
if(-not (Test-Path $runtimeModule))
|
|
{
|
|
throw "Could not find IntuneManagement.Runtime.psd1 in $projectRoot"
|
|
}
|
|
|
|
$settingsPath = $SettingsFile
|
|
if(-not $settingsPath)
|
|
{
|
|
$settingsPath = Get-DefaultSettingsPath
|
|
}
|
|
|
|
# Pre-load auth from settings
|
|
if($AuthMode -eq "AppOnly" -and (Test-Path $settingsPath) -and (-not $AppId -or (-not $Secret -and -not $Certificate)))
|
|
{
|
|
try
|
|
{
|
|
$raw = Get-Content -Path $settingsPath -Raw -ErrorAction Stop
|
|
$settingsObj = ConvertFrom-Json $raw -AsHashtable -ErrorAction Stop
|
|
if($settingsObj -and $settingsObj.ContainsKey($TenantId))
|
|
{
|
|
$tenantNode = $settingsObj[$TenantId]
|
|
if(-not $AppId -and $tenantNode.ContainsKey("GraphAzureAppId"))
|
|
{
|
|
$AppId = $tenantNode["GraphAzureAppId"]
|
|
}
|
|
if(-not $Secret -and $tenantNode.ContainsKey("GraphAzureAppSecret"))
|
|
{
|
|
$Secret = $tenantNode["GraphAzureAppSecret"]
|
|
}
|
|
if(-not $Certificate -and $tenantNode.ContainsKey("GraphAzureAppCert"))
|
|
{
|
|
$Certificate = $tenantNode["GraphAzureAppCert"]
|
|
}
|
|
}
|
|
|
|
if(-not $Secret -and $IsMacOS -and $AppId)
|
|
{
|
|
try
|
|
{
|
|
$keychainSecret = security find-generic-password -a "IntuneManagement" -s "IntuneMgmt-$AppId" -w 2>$null
|
|
if($keychainSecret) { $Secret = $keychainSecret }
|
|
}
|
|
catch { }
|
|
}
|
|
}
|
|
catch { }
|
|
}
|
|
|
|
$invokeParams = @{
|
|
Silent = $true
|
|
JSonSettings = $true
|
|
JSonFile = $settingsPath
|
|
TenantId = $TenantId
|
|
AppId = $AppId
|
|
AuthMode = $AuthMode
|
|
}
|
|
if($RedirectUri) { $invokeParams.RedirectUri = $RedirectUri }
|
|
if($AuthMode -eq "AppOnly" -and $Secret) { $invokeParams.Secret = $Secret }
|
|
elseif($AuthMode -eq "AppOnly") { $invokeParams.Certificate = $Certificate }
|
|
|
|
Import-Module $runtimeModule -Force
|
|
Initialize-IntuneManagementRuntime -View "IntuneGraphAPI" @invokeParams
|
|
#endregion
|
|
|
|
#region Ensure Graph connectivity
|
|
if(-not (Get-Command Invoke-GraphRequest -ErrorAction SilentlyContinue))
|
|
{
|
|
throw "Graph runtime did not load Invoke-GraphRequest. Aborting."
|
|
}
|
|
|
|
Write-Host "`nConnecting to Microsoft Graph..." -ForegroundColor Cyan
|
|
try
|
|
{
|
|
$org = Invoke-GraphRequest "/organization"
|
|
Write-Host "Connected to tenant: $($org.value[0].displayName) ($($org.value[0].id))" -ForegroundColor Green
|
|
}
|
|
catch
|
|
{
|
|
throw "Failed to connect to Graph. Ensure auth parameters are correct. Error: $_"
|
|
}
|
|
#endregion
|
|
|
|
#region Object type registry
|
|
$assignableTypes = @(
|
|
[PSCustomObject]@{ Title = "Applications"; API = "/deviceAppManagement/mobileApps"; AssignmentsType = "mobileAppAssignments"; HasIntent = $true; NameProp = "displayName" },
|
|
[PSCustomObject]@{ Title = "Device Configuration"; API = "/deviceManagement/deviceConfigurations"; AssignmentsType = "assignments"; HasIntent = $false; NameProp = "displayName" },
|
|
[PSCustomObject]@{ Title = "Settings Catalog"; API = "/deviceManagement/configurationPolicies"; AssignmentsType = "assignments"; HasIntent = $false; NameProp = "name" },
|
|
[PSCustomObject]@{ Title = "Compliance Policies"; API = "/deviceManagement/deviceCompliancePolicies"; AssignmentsType = "assignments"; HasIntent = $false; NameProp = "displayName" },
|
|
[PSCustomObject]@{ Title = "Administrative Templates"; API = "/deviceManagement/groupPolicyConfigurations"; AssignmentsType = "assignments"; HasIntent = $false; NameProp = "displayName" },
|
|
[PSCustomObject]@{ Title = "Endpoint Security"; API = "/deviceManagement/intents"; AssignmentsType = "assignments"; HasIntent = $false; NameProp = "displayName" },
|
|
[PSCustomObject]@{ Title = "App Protection"; API = "/deviceAppManagement/managedAppPolicies"; AssignmentsType = "assignments"; HasIntent = $false; NameProp = "displayName" },
|
|
[PSCustomObject]@{ Title = "App Configuration (Device)"; API = "/deviceAppManagement/mobileAppConfigurations"; AssignmentsType = "assignments"; HasIntent = $false; NameProp = "displayName" },
|
|
[PSCustomObject]@{ Title = "Platform Scripts"; API = "/deviceManagement/deviceManagementScripts"; AssignmentsType = "deviceManagementScriptAssignments"; HasIntent = $false; NameProp = "displayName" },
|
|
[PSCustomObject]@{ Title = "macOS Scripts"; API = "/deviceManagement/deviceShellScripts"; AssignmentsType = "deviceManagementScriptAssignments"; HasIntent = $false; NameProp = "displayName" },
|
|
[PSCustomObject]@{ Title = "Device Health Scripts"; API = "/deviceManagement/deviceHealthScripts"; AssignmentsType = "deviceHealthScriptAssignments"; HasIntent = $false; NameProp = "displayName" },
|
|
[PSCustomObject]@{ Title = "macOS Custom Attributes"; API = "/deviceManagement/deviceCustomAttributeShellScripts"; AssignmentsType = "deviceManagementScriptAssignments"; HasIntent = $false; NameProp = "displayName" },
|
|
[PSCustomObject]@{ Title = "Enrollment Restrictions"; API = "/deviceManagement/deviceEnrollmentConfigurations"; AssignmentsType = "enrollmentConfigurationAssignments"; HasIntent = $false; NameProp = "displayName" },
|
|
[PSCustomObject]@{ Title = "Enrollment Status Page"; API = "/deviceManagement/deviceEnrollmentConfigurations"; AssignmentsType = "enrollmentConfigurationAssignments"; HasIntent = $false; NameProp = "displayName" },
|
|
[PSCustomObject]@{ Title = "Autopilot"; API = "/deviceManagement/windowsAutopilotDeploymentProfiles"; AssignmentsType = "assignments"; HasIntent = $false; NameProp = "displayName" },
|
|
[PSCustomObject]@{ Title = "Terms and Conditions"; API = "/deviceManagement/termsAndConditions"; AssignmentsType = "assignments"; HasIntent = $false; NameProp = "displayName" },
|
|
[PSCustomObject]@{ Title = "Policy Sets"; API = "/deviceAppManagement/policySets"; AssignmentsType = "assignments"; HasIntent = $false; NameProp = "displayName" },
|
|
[PSCustomObject]@{ Title = "Update Policies"; API = "/deviceManagement/windowsUpdateForBusinessConfigurations"; AssignmentsType = "assignments"; HasIntent = $false; NameProp = "displayName" },
|
|
[PSCustomObject]@{ Title = "Feature Updates"; API = "/deviceManagement/windowsFeatureUpdateProfiles"; AssignmentsType = "assignments"; HasIntent = $false; NameProp = "displayName" },
|
|
[PSCustomObject]@{ Title = "Quality Updates"; API = "/deviceManagement/windowsQualityUpdateProfiles"; AssignmentsType = "assignments"; HasIntent = $false; NameProp = "displayName" },
|
|
[PSCustomObject]@{ Title = "Device Management Intents"; API = "/deviceManagement/intents"; AssignmentsType = "assignments"; HasIntent = $false; NameProp = "displayName" }
|
|
)
|
|
#endregion
|
|
|
|
#region BACKUP
|
|
if($Mode -eq "Backup")
|
|
{
|
|
$typeTitles = $assignableTypes | ForEach-Object { $_.Title }
|
|
$selectedTypeTitles = Select-MenuItem -Items $typeTitles -Header "Select object types to back up (multi-select)" -Multi
|
|
if(-not $selectedTypeTitles)
|
|
{
|
|
Write-Host "No types selected. Exiting." -ForegroundColor Yellow
|
|
exit 0
|
|
}
|
|
|
|
# Preload groups for name resolution in backup
|
|
Write-Host "`nLoading groups for backup resolution..." -ForegroundColor Cyan
|
|
$backupGroupsResponse = Invoke-GraphRequest "/groups?`$select=id,displayName&`$top=999"
|
|
$backupGroups = @{}
|
|
foreach($g in $backupGroupsResponse.value)
|
|
{
|
|
$backupGroups[$g.id] = $g.displayName
|
|
}
|
|
|
|
$backupData = @{
|
|
TenantId = $org.value[0].id
|
|
TenantName = $org.value[0].displayName
|
|
Created = (Get-Date -Format "o")
|
|
Groups = $backupGroups
|
|
Objects = @()
|
|
}
|
|
|
|
foreach($typeTitle in $selectedTypeTitles)
|
|
{
|
|
$objectType = $assignableTypes | Where-Object { $_.Title -eq $typeTitle } | Select-Object -First 1
|
|
Write-Host "`nBacking up $($objectType.Title) assignments..." -ForegroundColor Cyan
|
|
|
|
try
|
|
{
|
|
$objectsResponse = Invoke-GraphRequest "$($objectType.API)?`$select=id,$($objectType.NameProp)&`$orderby=$($objectType.NameProp)"
|
|
$objects = $objectsResponse.value | Where-Object { $_ }
|
|
Write-Host " Found $($objects.Count) objects" -ForegroundColor Green
|
|
|
|
foreach($obj in $objects)
|
|
{
|
|
try
|
|
{
|
|
$assignmentsResponse = Invoke-GraphRequest "$($objectType.API)/$($obj.id)/assignments"
|
|
$assignments = $assignmentsResponse.value
|
|
if($assignments.Count -gt 0)
|
|
{
|
|
# Enrich assignments with group display names for cross-tenant restore
|
|
$enrichedAssignments = $assignments | ConvertTo-Json -Depth 50 | ConvertFrom-Json
|
|
foreach($ass in $enrichedAssignments)
|
|
{
|
|
if($ass.target.groupId -and $backupGroups.ContainsKey($ass.target.groupId))
|
|
{
|
|
$ass.target | Add-Member -NotePropertyName "_backupGroupName" -NotePropertyValue $backupGroups[$ass.target.groupId] -Force
|
|
}
|
|
}
|
|
$backupData.Objects += [PSCustomObject]@{
|
|
ObjectType = $objectType.Title
|
|
ObjectId = $obj.id
|
|
ObjectName = if($objectType.NameProp -eq "name") { $obj.name } else { $obj.displayName }
|
|
NameProp = $objectType.NameProp
|
|
API = $objectType.API
|
|
AssignmentsType = $objectType.AssignmentsType
|
|
Assignments = $enrichedAssignments
|
|
}
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
Write-Host " WARNING: Could not backup assignments for $($obj."$($objectType.NameProp)")" -ForegroundColor DarkYellow
|
|
}
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
Write-Host " WARNING: Could not load objects for $($objectType.Title)" -ForegroundColor DarkYellow
|
|
}
|
|
}
|
|
|
|
$backupJson = $backupData | ConvertTo-Json -Depth 50
|
|
$OutputPath = (Resolve-Path (Split-Path -Parent $OutputPath) -ErrorAction SilentlyContinue).Path + "/" + (Split-Path -Leaf $OutputPath)
|
|
$backupJson | Out-File -LiteralPath $OutputPath -Encoding utf8 -Force
|
|
|
|
$totalAssignments = 0
|
|
foreach($obj in $backupData.Objects) { $totalAssignments += $obj.Assignments.Count }
|
|
|
|
Write-Host "`n========================================" -ForegroundColor Cyan
|
|
Write-Host " Backup Complete" -ForegroundColor Cyan
|
|
Write-Host "========================================" -ForegroundColor Cyan
|
|
Write-Host " File : $OutputPath"
|
|
Write-Host " Objects : $($backupData.Objects.Count)"
|
|
Write-Host " Assignments : $totalAssignments"
|
|
}
|
|
#endregion
|
|
|
|
#region RESTORE
|
|
elseif($Mode -eq "Restore")
|
|
{
|
|
$backup = Get-Content $InputPath -Raw | ConvertFrom-Json
|
|
|
|
Write-Host "`nBackup info:" -ForegroundColor Cyan
|
|
Write-Host " Tenant : $($backup.TenantName) ($($backup.TenantId))"
|
|
Write-Host " Created: $($backup.Created)"
|
|
Write-Host " Objects: $($backup.Objects.Count)"
|
|
|
|
$currentTenantId = $org.value[0].id
|
|
if($backup.TenantId -ne $currentTenantId)
|
|
{
|
|
Write-Host "`nWARNING: Backup is from a different tenant!" -ForegroundColor Yellow
|
|
if(-not (Read-YesNo -Prompt "Continue anyway?" -Default $false))
|
|
{
|
|
Write-Host "Cancelled." -ForegroundColor Yellow
|
|
exit 0
|
|
}
|
|
}
|
|
|
|
# Resolve group names to IDs in current tenant if needed
|
|
Write-Host "`nLoading current tenant groups for name resolution..." -ForegroundColor Cyan
|
|
$currentGroupsResponse = Invoke-GraphRequest "/groups?`$select=id,displayName&`$top=999"
|
|
$currentGroups = $currentGroupsResponse.value
|
|
|
|
$success = 0
|
|
$skipped = 0
|
|
$failed = 0
|
|
|
|
foreach($entry in $backup.Objects)
|
|
{
|
|
Write-Host "`nRestoring: $($entry.ObjectName) ($($entry.ObjectType))" -ForegroundColor Cyan
|
|
|
|
# Try to find the object in current tenant by displayName
|
|
$nameProp = ?? $entry.NameProp "displayName"
|
|
$searchUrl = "$($entry.API)?`$filter=$nameProp eq '$([uri]::EscapeDataString($entry.ObjectName))'&`$select=id,$nameProp"
|
|
try
|
|
{
|
|
$searchResult = Invoke-GraphRequest $searchUrl
|
|
$targetObj = $searchResult.value | Select-Object -First 1
|
|
}
|
|
catch
|
|
{
|
|
$targetObj = $null
|
|
}
|
|
|
|
if(-not $targetObj)
|
|
{
|
|
Write-Host " SKIP: Object '$($entry.ObjectName)' not found in current tenant" -ForegroundColor DarkYellow
|
|
$failed++
|
|
continue
|
|
}
|
|
|
|
# Load existing assignments to avoid duplicates
|
|
try
|
|
{
|
|
$existing = Invoke-GraphRequest "$($entry.API)/$($targetObj.id)/assignments"
|
|
$existingTargets = $existing.value
|
|
}
|
|
catch
|
|
{
|
|
Write-Host " ERROR: Could not load existing assignments" -ForegroundColor Red
|
|
$failed++
|
|
continue
|
|
}
|
|
|
|
function Test-BackupAssignmentExists
|
|
{
|
|
param($assignment, $existingList)
|
|
$t = $assignment.target
|
|
foreach($ea in $existingList)
|
|
{
|
|
$et = $ea.target
|
|
if($t."@odata.type" -ne $et."@odata.type") { continue }
|
|
if($t.groupId -and $t.groupId -ne $et.groupId) { continue }
|
|
# Also match intent for apps
|
|
if($entry.AssignmentsType -eq "mobileAppAssignments" -and ($assignment.intent -ne $ea.intent)) { continue }
|
|
return $true
|
|
}
|
|
return $false
|
|
}
|
|
|
|
foreach($assignment in $entry.Assignments)
|
|
{
|
|
# Clone assignment to avoid modifying backup data
|
|
$restoredAssignment = $assignment | ConvertTo-Json -Depth 50 | ConvertFrom-Json
|
|
|
|
# Remove Id
|
|
if($restoredAssignment.PSObject.Properties["id"])
|
|
{
|
|
$restoredAssignment.PSObject.Properties.Remove("id")
|
|
}
|
|
|
|
# Map group IDs if cross-tenant
|
|
if($backup.TenantId -ne $currentTenantId -and $restoredAssignment.target.groupId)
|
|
{
|
|
$originalGroupName = $restoredAssignment.target."_backupGroupName"
|
|
if($originalGroupName)
|
|
{
|
|
$matchedGroup = $currentGroups | Where-Object { $_.displayName -eq $originalGroupName } | Select-Object -First 1
|
|
if($matchedGroup)
|
|
{
|
|
Write-Host " MAPPED: Group '$originalGroupName' -> $($matchedGroup.id)" -ForegroundColor Gray
|
|
$restoredAssignment.target.groupId = $matchedGroup.id
|
|
}
|
|
else
|
|
{
|
|
Write-Host " SKIP: Could not find group '$originalGroupName' in current tenant" -ForegroundColor DarkYellow
|
|
$skipped++
|
|
continue
|
|
}
|
|
}
|
|
else
|
|
{
|
|
Write-Host " SKIP: Cross-tenant restore cannot resolve group without name mapping" -ForegroundColor DarkYellow
|
|
$skipped++
|
|
continue
|
|
}
|
|
}
|
|
|
|
# Clean up internal property before sending
|
|
if($restoredAssignment.target.PSObject.Properties["_backupGroupName"])
|
|
{
|
|
$restoredAssignment.target.PSObject.Properties.Remove("_backupGroupName")
|
|
}
|
|
|
|
if(Test-BackupAssignmentExists -assignment $restoredAssignment -existingList $existingTargets)
|
|
{
|
|
Write-Host " SKIP: Assignment already exists" -ForegroundColor DarkYellow
|
|
$skipped++
|
|
continue
|
|
}
|
|
|
|
# Prepare payload
|
|
$payload = @{
|
|
$entry.AssignmentsType = @($restoredAssignment)
|
|
}
|
|
|
|
try
|
|
{
|
|
$body = $payload | ConvertTo-Json -Depth 50 -Compress
|
|
$null = Invoke-GraphRequest "$($entry.API)/$($targetObj.id)/assign" -HttpMethod POST -Content $body
|
|
Write-Host " OK: Restored assignment" -ForegroundColor Green
|
|
$success++
|
|
}
|
|
catch
|
|
{
|
|
Write-Host " ERROR: Failed to restore assignment. $($_.Exception.Message)" -ForegroundColor Red
|
|
$failed++
|
|
}
|
|
}
|
|
}
|
|
|
|
Write-Host "`n========================================" -ForegroundColor Cyan
|
|
Write-Host " Restore Complete" -ForegroundColor Cyan
|
|
Write-Host "========================================" -ForegroundColor Cyan
|
|
Write-Host " Success : $success"
|
|
Write-Host " Skipped : $skipped"
|
|
Write-Host " Failed : $failed"
|
|
}
|
|
#endregion
|