Src/Private/Export-IntuneBackup.ps1
|
#region --- JSON Backup Helpers --- # Get-IntuneBackupSectionEnabled moved to Helpers.ps1 for deterministic load order function Remove-IntuneSystemFields { <# .SYNOPSIS Strips non-restorable system fields from a Graph API response object. Removes fields that are auto-generated by Graph and must be absent when POSTing to create a new resource: id, createdDateTime, lastModifiedDateTime, version, @odata.etag, supportsScopeTags, deviceCount, deviceStatusCount, userStatusCount, runSummary .PARAMETER InputObject A single PSCustomObject from a Graph API response. .PARAMETER KeepId Retain the 'id' field (useful for cross-referencing, not for restore). #> [CmdletBinding()] param( [Parameter(Mandatory, ValueFromPipeline)] [object]$InputObject, [switch]$KeepId ) process { # Deep clone via JSON round-trip to avoid mutating the original $obj = $InputObject | ConvertTo-Json -Depth 20 -Compress | ConvertFrom-Json $systemFields = @( 'createdDateTime' 'lastModifiedDateTime' 'version' '@odata.etag' 'supportsScopeTags' 'deviceCount' 'deviceStatusCount' 'userStatusCount' 'runSummary' ) if (-not $KeepId) { $systemFields += 'id' } foreach ($field in $systemFields) { if ($obj.PSObject.Properties[$field]) { $obj.PSObject.Properties.Remove($field) } } return $obj } } function Get-IntuneBackupSafeFileName { <# .SYNOPSIS Converts a policy display name to a safe filename by removing invalid characters. #> [CmdletBinding()] param([string]$Name) # Remove chars invalid in Windows/macOS/Linux filenames $safe = $Name -replace '[\\/:*?"<>|]', '_' # Collapse multiple spaces/underscores $safe = $safe -replace '\s+', ' ' $safe = $safe.Trim() # Limit length to avoid path length issues if ($safe.Length -gt 100) { $safe = $safe.Substring(0, 100).TrimEnd() } return $safe } function Export-IntuneBackupJson { <# .SYNOPSIS Exports collected Intune configuration as structured JSON backup files. .DESCRIPTION Creates a timestamped backup folder containing: - One subfolder per configuration category - One JSON file per policy/profile within each subfolder - A _manifest.json listing all exported items with counts and restore endpoints - A _metadata.json with export info and restore instructions Structure: Intune_Backup_<TenantDomain>_<DateTime>/ _metadata.json _manifest.json CompliancePolicies/ <PolicyName>.json ConfigurationProfiles/ <ProfileName>.json ... Each individual JSON file is a direct Graph API POST body -- no modification needed to restore (when ExcludeSystemFields = true). Format chosen: JSON (not XML or CSV) because: - Microsoft Graph API natively accepts/returns JSON - No conversion needed to restore via Graph - Compatible with IntuneBackupAndRestore and Microsoft365DSC tooling - Line-based format works well with git diff for change tracking - ConvertTo-Json / ConvertFrom-Json are PowerShell built-ins (no extra modules) .PARAMETER BasePath Parent output folder. A timestamped subfolder is created inside it. .PARAMETER TenantName Tenant display name (used in metadata). .PARAMETER TenantDomain Tenant domain (used in metadata and folder name). #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$BasePath, [string]$TenantName = '', [string]$TenantDomain = '' ) if (-not $script:BackupData -or $script:BackupData.Count -eq 0) { Write-Host " - JSON backup: no data collected (all sections disabled or no licensed features)." -ForegroundColor DarkGray return } $excludeSystemFields = ($script:Options.JsonBackup.ExcludeSystemFields -ne $false) $timestamp = Get-Date -Format 'yyyyMMdd_HHmmss' $backupFolderName = "Intune_Backup_${TenantDomain}_${timestamp}" $backupRootPath = Join-Path $BasePath $backupFolderName # Graph restore endpoints per category $RestoreEndpoints = @{ CompliancePolicies = 'POST https://graph.microsoft.com/v1.0/deviceManagement/deviceCompliancePolicies' ConfigurationProfiles = 'POST https://graph.microsoft.com/v1.0/deviceManagement/deviceConfigurations' SettingsCatalog = 'POST https://graph.microsoft.com/beta/deviceManagement/configurationPolicies' AdminTemplates = 'POST https://graph.microsoft.com/beta/deviceManagement/groupPolicyConfigurations' AppProtectionPolicies = 'POST https://graph.microsoft.com/v1.0/deviceAppManagement/iosManagedAppProtections (iOS) | POST .../androidManagedAppProtections (Android)' EnrollmentRestrictions = 'POST https://graph.microsoft.com/v1.0/deviceManagement/deviceEnrollmentConfigurations' AutopilotProfiles = 'POST https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeploymentProfiles' SecurityBaselines = 'POST https://graph.microsoft.com/beta/deviceManagement/intents' EndpointSecurity = 'POST https://graph.microsoft.com/beta/deviceManagement/configurationPolicies' PowerShellScripts = 'POST https://graph.microsoft.com/beta/deviceManagement/deviceManagementScripts' ShellScripts = 'POST https://graph.microsoft.com/beta/deviceManagement/deviceShellScripts' ProactiveRemediations = 'POST https://graph.microsoft.com/beta/deviceManagement/deviceHealthScripts' } Write-Host " - JSON backup: creating folder structure..." -ForegroundColor Cyan try { $null = New-Item -ItemType Directory -Path $backupRootPath -Force -ErrorAction Stop } catch { Write-Warning " - JSON backup: could not create folder '$backupRootPath': $($_.Exception.Message)" Write-AbrDebugLog "JSON backup folder creation failed: $($_.Exception.Message)" 'ERROR' 'BACKUP' return } $manifest = [System.Collections.ArrayList]::new() $totalFiles = 0 # Process each category foreach ($categoryKey in $script:BackupData.Keys) { $rawItems = @($script:BackupData[$categoryKey]) if (-not $rawItems -or $rawItems.Count -eq 0) { continue } # Create category subfolder $categoryPath = Join-Path $backupRootPath $categoryKey try { $null = New-Item -ItemType Directory -Path $categoryPath -Force -ErrorAction Stop } catch { Write-Warning " - JSON backup: could not create subfolder '$categoryKey': $($_.Exception.Message)" continue } $categoryFileCount = 0 foreach ($item in $rawItems) { # Determine display name for filename $displayName = if ($item.displayName) { $item.displayName } elseif ($item.name) { $item.name } else { "Item_$categoryFileCount" } $safeFileName = "$(Get-IntuneBackupSafeFileName -Name $displayName).json" $filePath = Join-Path $categoryPath $safeFileName # Handle duplicate filenames (multiple policies with same name) if (Test-Path $filePath) { $safeFileName = "$(Get-IntuneBackupSafeFileName -Name $displayName)_$categoryFileCount.json" $filePath = Join-Path $categoryPath $safeFileName } # Clean system fields if configured $exportObj = if ($excludeSystemFields) { Remove-IntuneSystemFields -InputObject $item } else { $item } try { $exportObj | ConvertTo-Json -Depth 30 | Set-Content -Path $filePath -Encoding UTF8 -ErrorAction Stop $categoryFileCount++ $totalFiles++ } catch { Write-Warning " - JSON backup: failed to write '$safeFileName': $($_.Exception.Message)" } } Write-Host " - ${categoryKey}: $categoryFileCount file(s)" -ForegroundColor DarkGray Write-AbrDebugLog "JSON backup category '${categoryKey}': $categoryFileCount files" 'INFO' 'BACKUP' # Add to manifest $null = $manifest.Add([pscustomobject]@{ Category = $categoryKey ItemCount = $categoryFileCount FolderPath = $categoryKey RestoreEndpoint = if ($RestoreEndpoints[$categoryKey]) { $RestoreEndpoints[$categoryKey] } else { 'See Microsoft Graph documentation' } }) } # Write _manifest.json $manifestPath = Join-Path $backupRootPath '_manifest.json' try { $manifest | ConvertTo-Json -Depth 5 | Set-Content -Path $manifestPath -Encoding UTF8 -ErrorAction Stop } catch { Write-Warning " - JSON backup: could not write manifest: $($_.Exception.Message)" } # Write _metadata.json $metadataPath = Join-Path $backupRootPath '_metadata.json' $metadata = [ordered]@{ exportedAt = (Get-Date -Format 'yyyy-MM-ddTHH:mm:ssZ') tenantName = $TenantName tenantDomain = $TenantDomain moduleId = 'AsBuiltReport.Microsoft.Intune' moduleVersion = '0.1.0' totalCategories = $script:BackupData.Count totalFiles = $totalFiles excludeSystemFields = $excludeSystemFields backupStructure = 'One JSON file per policy/profile. Each file is a direct Graph API POST body when ExcludeSystemFields = true.' restoreInstructions = [ordered]@{ step1 = 'Open each JSON file in the category subfolder.' step2 = 'POST the file content to the Graph API endpoint listed in _manifest.json for that category.' step3 = 'Verify assignments reference valid Azure AD Group Object IDs (groups must exist in the target tenant).' step4 = 'For App Protection Policies, check the @odata.type field to determine whether to POST to iosManagedAppProtections or androidManagedAppProtections.' note = 'Do NOT include the id field when restoring to a new tenant or creating new resources. It is stripped when ExcludeSystemFields = true.' } toolingCompatibility = @( 'IntuneBackupAndRestore (dcklassen) -- folder structure compatible' 'Microsoft365DSC -- JSON format compatible' 'Microsoft Graph API -- direct POST body format' 'Git -- line-based JSON diffs track per-policy changes over time' ) } try { $metadata | ConvertTo-Json -Depth 10 | Set-Content -Path $metadataPath -Encoding UTF8 -ErrorAction Stop } catch { Write-Warning " - JSON backup: could not write metadata: $($_.Exception.Message)" } Write-Host " - JSON backup complete: $totalFiles file(s) in $($script:BackupData.Count) category/categories" -ForegroundColor Green Write-Host " - JSON backup folder: $backupRootPath" -ForegroundColor Green Write-TranscriptLog "JSON backup complete: $totalFiles files -> $backupRootPath" 'SUCCESS' 'BACKUP' Write-AbrDebugLog "JSON backup complete: $totalFiles files, folder: $backupRootPath" 'SUCCESS' 'BACKUP' } #endregion |