Public/Restore-DLPConfiguration.ps1
|
function Restore-DLPConfiguration { <# .SYNOPSIS Restores Microsoft Purview DLP policies from a backup JSON file. .DESCRIPTION Restores DLP policies and rules from a JSON backup created by Export-DLPConfiguration. Can restore individual policies or all policies from the backup. Supports: - Policy duplication (rename during restore) - Creating policies in disabled state for review - Cross-tenant migration with automatic location scope conversion - Skipping existing policies - Migration report generation (HTML) IMPORTANT: This is a RESTORE operation. Use with caution. Always use -WhatIf first. .PARAMETER BackupFile Required. Path to the JSON backup file created by Export-DLPConfiguration. .PARAMETER PolicyName Optional. Specific policy name to restore. If not provided, all policies are restored. .PARAMETER NewPolicyName Optional. Rename the policy during restore. Only works with -PolicyName. Useful for duplicating policies or avoiding naming conflicts. .PARAMETER CreateDisabled Create policies in disabled state (Enabled = $false) for inspection before activation. Recommended for cross-tenant migrations. .PARAMETER SkipExisting Skip policies that already exist instead of failing with an error. .PARAMETER CrossTenantMode Enable cross-tenant migration mode. This mode: - Converts specific user/group/site targeting to 'All' locations - Removes location exceptions (not portable across tenants) - Generates a migration report documenting all changes Use when migrating policies from one Microsoft 365 tenant to another. .PARAMETER MigrationReportPath Optional. Custom path for the migration report (HTML format). Default: [WorkspaceRoot]/Output/dlp-migration-report-[timestamp].html Report includes details of all location scope changes and warnings. .PARAMETER WhatIf Preview what would be restored without making any changes. Always recommended before running the actual restore. .PARAMETER Confirm Prompt for confirmation before restoring each policy. .OUTPUTS PSCustomObject with restoration statistics: - TotalPolicies: Number of policies in backup - RestoredPolicies: Number successfully restored - SkippedPolicies: Number skipped (already exist) - FailedPolicies: Number that failed .EXAMPLE Restore-DLPConfiguration -BackupFile ".\Output\backup.json" -WhatIf Preview restoration of all policies from backup without making changes. .EXAMPLE Restore-DLPConfiguration -BackupFile ".\Output\backup.json" -PolicyName "GDPR Enhanced" Restore a specific policy from backup. .EXAMPLE Restore-DLPConfiguration -BackupFile ".\Output\backup.json" -PolicyName "GDPR Enhanced" -NewPolicyName "GDPR Enhanced - Copy" Duplicate a policy with a new name. .EXAMPLE Restore-DLPConfiguration -BackupFile ".\Output\backup.json" -CreateDisabled Restore all policies in disabled state for inspection. .EXAMPLE Restore-DLPConfiguration -BackupFile ".\Output\backup.json" -SkipExisting Restore all policies, skipping any that already exist. .EXAMPLE Restore-DLPConfiguration -BackupFile ".\Output\backup.json" -CrossTenantMode -CreateDisabled Migrate policies from another tenant: - Converts specific targeting to 'All' locations - Removes exceptions - Creates policies disabled for review - Generates migration report .NOTES Requires: Active connection to Security & Compliance Center (use Connect-PurviewDLP first) Author: PurviewDLP Module Cross-Tenant Migration Workflow: 1. Connect to source tenant 2. Export backup: Export-DLPConfiguration 3. Connect to destination tenant 4. Restore with -CrossTenantMode -CreateDisabled -WhatIf (preview) 5. Restore with -CrossTenantMode -CreateDisabled (create disabled) 6. Review migration report for changes 7. Review policies in Microsoft Purview UI 8. Enable policies when ready using Set-DlpCompliancePolicy Location Scope Conversion (Cross-Tenant Mode): - Specific users/groups → 'All' - Specific sites → 'All' - Location exceptions → Removed - Adaptive scopes → Preserved (must exist in target tenant) .LINK https://github.com/uniQuk/PurviewDLP #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')] param( [Parameter(Mandatory = $true, Position = 0)] [ValidateScript({ Test-Path $_ -PathType Leaf })] [string]$BackupFile, [Parameter(Mandatory = $false)] [string]$PolicyName, [Parameter(Mandatory = $false)] [string]$NewPolicyName, [Parameter(Mandatory = $false)] [switch]$CreateDisabled, [Parameter(Mandatory = $false)] [switch]$SkipExisting, [Parameter(Mandatory = $false)] [switch]$CrossTenantMode, [Parameter(Mandatory = $false)] [string]$MigrationReportPath ) begin { Write-Verbose "Starting Restore-DLPConfiguration" # Validate parameter combinations if ($NewPolicyName -and -not $PolicyName) { throw "Parameter -NewPolicyName requires -PolicyName to be specified" } # Initialize statistics $script:stats = @{ TotalPolicies = 0 RestoredPolicies = 0 SkippedPolicies = 0 FailedPolicies = 0 } # Initialize migration report if needed $script:migrationReport = $null if ($CrossTenantMode) { $script:migrationReport = [PSCustomObject]@{ MigrationDate = Get-Date -Format "yyyy-MM-dd HH:mm:ss" SourceBackupFile = (Resolve-Path $BackupFile).Path CrossTenantMode = $true CreateDisabled = $CreateDisabled PoliciesProcessed = @() TotalPolicies = 0 PoliciesWithChanges = 0 Warnings = @() } } } process { try { # Display banner Write-Banner -Message "DLP Configuration Restoration" -Type "Info" # Verify connection Write-ColorOutput "Checking connection to Security & Compliance Center..." -Type Info Test-DLPConnection # Throws if not connected Write-ColorOutput "✓ Connected successfully`n" -Type Success # Load backup file Write-ColorOutput "Loading backup file: $BackupFile" -Type Info if (-not (Test-Path $BackupFile)) { throw "Backup file not found: $BackupFile" } try { $backupContent = Get-Content $BackupFile -Raw -Encoding UTF8 | ConvertFrom-Json } catch { throw "Failed to parse backup file as JSON: $($_.Exception.Message)" } # Ensure backup content is an array $allPolicies = if ($backupContent -is [array]) { $backupContent } else { @($backupContent) } Write-ColorOutput "✓ Loaded $($allPolicies.Count) policy/policies from backup`n" -Type Success # Filter policies if specific policy requested if ($PolicyName) { $policiesToRestore = $allPolicies | Where-Object { $_.PolicyName -eq $PolicyName } if ($policiesToRestore.Count -eq 0) { throw "Policy '$PolicyName' not found in backup file" } Write-ColorOutput "Filtered to policy: $PolicyName`n" -Type Info } else { $policiesToRestore = $allPolicies } $script:stats.TotalPolicies = $policiesToRestore.Count if ($script:migrationReport) { $script:migrationReport.TotalPolicies = $script:stats.TotalPolicies } # Display mode information if ($CrossTenantMode) { Write-ColorOutput "⚠️ CROSS-TENANT MIGRATION MODE ENABLED" -Type Warning Write-ColorOutput " - Specific user/group/site targeting will be converted to 'All'" -Type Warning Write-ColorOutput " - Location exceptions will be removed" -Type Warning Write-ColorOutput " - A migration report will be generated`n" -Type Warning } if ($CreateDisabled) { Write-ColorOutput "ℹ️ Policies will be created in DISABLED state for review`n" -Type Info } # Check WhatIf mode $whatIfMode = $WhatIfPreference.IsPresent if ($whatIfMode) { Write-ColorOutput "⚠️ PREVIEW MODE: No changes will be applied`n" -Type Warning } # Confirmation prompt (unless WhatIf) if (-not $whatIfMode) { $message = "This will restore $($script:stats.TotalPolicies) policy/policies from backup." if ($CrossTenantMode) { $message += "`n ⚠️ Cross-tenant mode will modify location scopes." } $question = "Do you want to continue?" $choices = @( [System.Management.Automation.Host.ChoiceDescription]::new("&Yes", "Restore policies") [System.Management.Automation.Host.ChoiceDescription]::new("&No", "Cancel operation") ) $decision = $Host.UI.PromptForChoice($message, $question, $choices, 1) if ($decision -ne 0) { Write-ColorOutput "`nOperation cancelled by user." -Type Warning return } Write-Host "" } Write-Banner -Message "Restoration Progress" -Type "Info" # Restore each policy $counter = 0 foreach ($policy in $policiesToRestore) { $counter++ $displayName = if ($NewPolicyName) { $NewPolicyName } else { $policy.PolicyName } Write-ColorOutput "`n[$counter/$($script:stats.TotalPolicies)] Restoring policy: $displayName" -Type Info # Track migration changes $policyChanges = @{} $restoreParams = @{ PolicyData = $policy OverrideName = $NewPolicyName WhatIf = $whatIfMode } if ($CrossTenantMode) { $restoreParams.MigrationChanges = [ref]$policyChanges } $success = Restore-PolicyInternal @restoreParams if ($success) { $script:stats.RestoredPolicies++ # Add to migration report if ($script:migrationReport) { Add-PolicyMigrationDetailsInternal -Report $script:migrationReport -PolicyData $policy -Changes $policyChanges } } elseif ($SkipExisting) { $script:stats.SkippedPolicies++ } else { $script:stats.FailedPolicies++ } } # Generate migration report if ($script:migrationReport -and -not $whatIfMode -and $script:stats.RestoredPolicies -gt 0) { Write-Host "" Write-ColorOutput "Generating migration report..." -Type Info # Resolve report path if (-not $MigrationReportPath) { $timestamp = Get-Date -Format "yyyy-MM-dd_HHmmss" $reportPath = Get-DefaultOutputPath -OutputPath (Join-Path "." "Output") $MigrationReportPath = Join-Path $reportPath "dlp-migration-report-$timestamp.html" } Export-MigrationReportInternal -Report $script:migrationReport -OutputPath $MigrationReportPath Write-ColorOutput "✓ Migration report saved: $MigrationReportPath" -Type Success } # Display summary Write-Banner -Message "Restoration Summary" -Type "Info" Write-ColorOutput "Statistics:" -Type Info Write-Host " Total policies in backup: $($script:stats.TotalPolicies)" if ($whatIfMode) { Write-ColorOutput " Policies that would be restored: $($script:stats.RestoredPolicies)" -Type Warning } else { Write-ColorOutput " Policies successfully restored: $($script:stats.RestoredPolicies)" -Type Success } if ($script:stats.SkippedPolicies -gt 0) { Write-Host " Policies skipped (already exist): $($script:stats.SkippedPolicies)" } if ($script:stats.FailedPolicies -gt 0) { Write-ColorOutput " Policies failed: $($script:stats.FailedPolicies)" -Type Error } Write-Host "" if ($whatIfMode) { Write-ColorOutput "✓ Preview completed. To restore, run without -WhatIf parameter." -Type Warning } else { if ($script:stats.FailedPolicies -eq 0 -and $script:stats.RestoredPolicies -gt 0) { Write-ColorOutput "✓ Restoration completed successfully!" -Type Success if ($CreateDisabled) { Write-Host "" Write-ColorOutput "ℹ️ Policies were created in DISABLED state." -Type Info Write-ColorOutput " Review them in Microsoft Purview and enable when ready." -Type Info } if ($CrossTenantMode) { Write-Host "" Write-ColorOutput "ℹ️ Cross-tenant migration completed. Review the migration report:" -Type Info Write-ColorOutput " $MigrationReportPath" -Type Info } } elseif ($script:stats.RestoredPolicies -eq 0) { Write-ColorOutput "⚠ No policies were restored. Check filters and existing policies." -Type Warning } else { Write-ColorOutput "⚠ Restoration completed with errors. Review messages above." -Type Warning } } Write-Host "" # Return statistics object return [PSCustomObject]$script:stats } catch { Write-Host "" Write-ColorOutput "✗ Error during restoration:" -Type Error Write-ColorOutput $_.Exception.Message -Type Error Write-Verbose "Stack Trace: $($_.ScriptStackTrace)" Write-Host "" Write-ColorOutput "Troubleshooting tips:" -Type Warning Write-ColorOutput " 1. Ensure you are connected: Connect-PurviewDLP" -Type Info Write-ColorOutput " 2. Verify the backup file path is correct and contains valid JSON" -Type Info Write-ColorOutput " 3. Check that you have permissions to create DLP policies" -Type Info Write-ColorOutput " 4. Use -WhatIf to preview restoration before applying" -Type Info Write-ColorOutput " 5. Use -SkipExisting to skip policies that already exist" -Type Info Write-ColorOutput " 6. Use -Verbose for detailed diagnostic output" -Type Info Write-Host "" throw } } end { Write-Verbose "Restore-DLPConfiguration completed" } } #region Internal Helper Functions function Restore-PolicyInternal { <# .SYNOPSIS Internal function to restore a single policy. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [object]$PolicyData, [Parameter(Mandatory = $false)] [string]$OverrideName, [Parameter(Mandatory = $false)] [bool]$WhatIf = $false, [Parameter(Mandatory = $false)] [ref]$MigrationChanges ) $policyName = if ($OverrideName) { $OverrideName } else { $PolicyData.PolicyName } try { # Check if policy already exists $existingPolicy = Get-DlpCompliancePolicy -Identity $policyName -ErrorAction SilentlyContinue if ($existingPolicy) { if ($SkipExisting) { Write-ColorOutput " ⚠️ Policy '$policyName' already exists - skipping" -Type Warning return $false } else { throw "Policy '$policyName' already exists. Use -SkipExisting to skip existing policies." } } if ($WhatIf) { Write-ColorOutput " [WHATIF] Would create policy: $policyName" -Type Warning Write-Verbose " Mode: $($PolicyData.Mode)" Write-Verbose " Enabled: $(if ($CreateDisabled) { '$false' } else { $PolicyData.Enabled })" Write-Verbose " Workload: $($PolicyData.Workload)" Write-Verbose " Rules: $($PolicyData.RuleCount)" if ($CrossTenantMode) { if ($PolicyData.HasSpecificTargeting) { Write-ColorOutput " ⚠️ Will convert specific targeting to 'All'" -Type Warning } if ($PolicyData.HasLocationExceptions) { Write-ColorOutput " ⚠️ Will remove location exceptions" -Type Warning } } return $true } # Build policy parameters $policyParams = @{ Name = $policyName Mode = $PolicyData.Mode Comment = if ($PolicyData.Comment) { $PolicyData.Comment } else { "" } } # Process locations based on cross-tenant mode $locationProperties = @{ Exchange = @{ Location = 'ExchangeLocation'; IsAll = 'ExchangeLocationIsAll'; Exception = 'ExchangeLocationException' } SharePoint = @{ Location = 'SharePointLocation'; IsAll = 'SharePointLocationIsAll'; Exception = 'SharePointLocationException' } OneDrive = @{ Location = 'OneDriveLocation'; IsAll = 'OneDriveLocationIsAll'; Exception = 'OneDriveLocationException' } Teams = @{ Location = 'TeamsLocation'; IsAll = 'TeamsLocationIsAll'; Exception = 'TeamsLocationException' } Endpoint = @{ Location = 'EndpointDlpLocation'; IsAll = 'EndpointDlpLocationIsAll'; Exception = $null } } foreach ($workload in $locationProperties.Keys) { $props = $locationProperties[$workload] $locationProp = $props.Location $isAllProp = $props.IsAll $exceptionProp = $props.Exception if ($PolicyData.$locationProp) { if ($CrossTenantMode) { # Cross-tenant mode: Always use 'All' if location is configured $policyParams[$locationProp] = "All" if ($PolicyData.$isAllProp -eq $false) { # Track change if ($MigrationChanges) { $MigrationChanges.Value[$workload] = @{ Original = $PolicyData.$locationProp New = "All" Reason = "Specific targeting not portable across tenants" ImpactLevel = "High" } } } } else { # Normal mode: Use actual values if ($PolicyData.$isAllProp) { $policyParams[$locationProp] = "All" } else { $locations = $PolicyData.$locationProp if ($locations -is [string]) { $policyParams[$locationProp] = $locations -split ", " | Where-Object { $_ } } elseif ($locations -is [array]) { $policyParams[$locationProp] = $locations } } } # Handle exceptions (only in non-cross-tenant mode) if ($exceptionProp -and $PolicyData.$exceptionProp -and -not $CrossTenantMode) { $exceptions = $PolicyData.$exceptionProp if ($exceptions -is [string]) { $policyParams[$exceptionProp] = $exceptions -split ", " | Where-Object { $_ } } elseif ($exceptions -is [array]) { $policyParams[$exceptionProp] = $exceptions } } elseif ($exceptionProp -and $PolicyData.$exceptionProp -and $CrossTenantMode) { # Track removed exceptions if ($MigrationChanges) { $MigrationChanges.Value["${workload}Exception"] = @{ Original = $PolicyData.$exceptionProp New = "(removed)" Reason = "Location exceptions not supported in cross-tenant migration" ImpactLevel = "Medium" } } } } } # Create the policy Write-Verbose " Creating policy with parameters: $($policyParams.Keys -join ', ')" $newPolicy = New-DlpCompliancePolicy @policyParams -ErrorAction Stop Write-ColorOutput " ✓ Created policy: $policyName" -Type Success # Set enabled state if ($CreateDisabled -and $newPolicy.Enabled) { Set-DlpCompliancePolicy -Identity $newPolicy.Name -Enabled $false -ErrorAction Stop Write-Verbose " Disabled policy for review" } # Restore rules if ($PolicyData.Rules -and $PolicyData.Rules.Count -gt 0) { Write-ColorOutput " Restoring $($PolicyData.Rules.Count) rule(s)..." -Type Info foreach ($rule in $PolicyData.Rules) { Restore-RuleInternal -RuleData $rule -PolicyName $policyName -WhatIf $false } } return $true } catch { Write-ColorOutput " ✗ Failed to restore policy '$policyName': $($_.Exception.Message)" -Type Error Write-Verbose " Stack trace: $($_.ScriptStackTrace)" return $false } } function Restore-RuleInternal { <# .SYNOPSIS Internal function to restore a single rule. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [object]$RuleData, [Parameter(Mandatory = $true)] [string]$PolicyName, [Parameter(Mandatory = $false)] [bool]$WhatIf = $false ) $ruleName = $RuleData.RuleName try { if ($WhatIf) { Write-Verbose " [WHATIF] Would create rule: $ruleName" return $true } # Build rule parameters (basic set - extend as needed) $ruleParams = @{ Name = $ruleName Policy = $PolicyName } # Add rule properties if ($RuleData.Disabled) { $ruleParams['Disabled'] = $RuleData.Disabled } if ($RuleData.Priority) { $ruleParams['Priority'] = $RuleData.Priority } if ($RuleData.Comment) { $ruleParams['Comment'] = $RuleData.Comment } # Sensitive information types (most critical property) if ($RuleData.ContentContainsSensitiveInformation) { try { $sitConfig = $RuleData.ContentContainsSensitiveInformation | ConvertFrom-Json -ErrorAction Stop $ruleParams['ContentContainsSensitiveInformation'] = $sitConfig } catch { Write-Verbose " Warning: Could not parse SIT configuration, skipping" } } # Advanced Rule (JSON-based conditions) if ($RuleData.AdvancedRule) { $ruleParams['AdvancedRule'] = $RuleData.AdvancedRule } # Notification settings if ($RuleData.NotifyUser) { $notifyArray = $RuleData.NotifyUser -split "; " | Where-Object { $_ -ne "" } if ($notifyArray.Count -gt 0) { $ruleParams['NotifyUser'] = $notifyArray } } if ($RuleData.NotifyPolicyTipCustomText) { $ruleParams['NotifyPolicyTipCustomText'] = $RuleData.NotifyPolicyTipCustomText } if ($RuleData.NotifyEmailCustomText) { $ruleParams['NotifyEmailCustomText'] = $RuleData.NotifyEmailCustomText } if ($RuleData.NotifyAllowOverride) { $overrideArray = $RuleData.NotifyAllowOverride -split "; " | Where-Object { $_ -ne "" } if ($overrideArray.Count -gt 0) { $ruleParams['NotifyAllowOverride'] = $overrideArray } } # Incident reporting if ($RuleData.GenerateIncidentReport) { $reportArray = $RuleData.GenerateIncidentReport -split "; " | Where-Object { $_ -ne "" } if ($reportArray.Count -gt 0) { $ruleParams['GenerateIncidentReport'] = $reportArray } } if ($RuleData.ReportSeverityLevel) { $ruleParams['ReportSeverityLevel'] = $RuleData.ReportSeverityLevel } # Actions if ($RuleData.BlockAccess) { $ruleParams['BlockAccess'] = $RuleData.BlockAccess } if ($RuleData.BlockAccessScope) { $ruleParams['BlockAccessScope'] = $RuleData.BlockAccessScope } if ($RuleData.EncryptRMSTemplate) { $ruleParams['EncryptRMSTemplate'] = $RuleData.EncryptRMSTemplate } # Create the rule $newRule = New-DlpComplianceRule @ruleParams -ErrorAction Stop Write-Verbose " ✓ Created rule: $ruleName" return $true } catch { Write-ColorOutput " ✗ Failed to restore rule '$ruleName': $($_.Exception.Message)" -Type Error Write-Verbose " Stack trace: $($_.ScriptStackTrace)" return $false } } function Add-PolicyMigrationDetailsInternal { <# .SYNOPSIS Internal function to add policy details to migration report. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [object]$Report, [Parameter(Mandatory = $true)] [object]$PolicyData, [Parameter(Mandatory = $true)] [hashtable]$Changes ) $policyReport = [PSCustomObject]@{ PolicyName = $PolicyData.PolicyName PolicyId = $PolicyData.PolicyId HadSpecificTargeting = $PolicyData.HasSpecificTargeting HadLocationExceptions = $PolicyData.HasLocationExceptions WasCrossTenantPortable = $PolicyData.IsCrossTenantPortable Changes = @() Warnings = @() } # Document location changes foreach ($location in $Changes.Keys) { $change = $Changes[$location] $policyReport.Changes += [PSCustomObject]@{ Location = $location OriginalValue = $change.Original NewValue = $change.New Reason = $change.Reason ImpactLevel = $change.ImpactLevel } } # Add warnings if ($PolicyData.HasSpecificTargeting) { $policyReport.Warnings += "Policy had specific user/group/site targeting that was converted to 'All'" } if ($PolicyData.HasLocationExceptions) { $policyReport.Warnings += "Policy had location exceptions that were removed (not supported in cross-tenant migration)" } $Report.PoliciesProcessed += $policyReport if ($policyReport.Changes.Count -gt 0 -or $policyReport.Warnings.Count -gt 0) { $Report.PoliciesWithChanges++ } } function Export-MigrationReportInternal { <# .SYNOPSIS Internal function to export migration report as HTML. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [object]$Report, [Parameter(Mandatory = $true)] [string]$OutputPath ) # Ensure output directory exists $outputDir = Split-Path $OutputPath -Parent if ($outputDir -and -not (Test-Path $outputDir)) { New-Item -ItemType Directory -Path $outputDir -Force | Out-Null } # Build simple HTML report $html = @" <!DOCTYPE html> <html> <head> <title>DLP Cross-Tenant Migration Report</title> <style> body { font-family: 'Segoe UI', Arial, sans-serif; margin: 20px; background: #f5f5f5; } .container { max-width: 1200px; margin: 0 auto; background: white; padding: 30px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); } h1 { color: #0078d4; border-bottom: 3px solid #0078d4; padding-bottom: 10px; } h2 { color: #106ebe; margin-top: 30px; } .summary { background: #e7f3ff; padding: 15px; border-radius: 5px; margin: 20px 0; } .policy { border: 1px solid #ddd; margin: 15px 0; padding: 15px; border-radius: 5px; } .policy-name { font-size: 18px; font-weight: bold; color: #333; } .warning { background: #fff3cd; border-left: 4px solid #ffc107; padding: 10px; margin: 10px 0; } .change { background: #f8f9fa; border-left: 4px solid #17a2b8; padding: 10px; margin: 10px 0; } .footer { margin-top: 40px; padding-top: 20px; border-top: 2px solid #ddd; color: #666; font-size: 12px; text-align: center; } </style> </head> <body> <div class="container"> <h1>🔄 DLP Cross-Tenant Migration Report</h1> <div class="summary"> <h2>Migration Summary</h2> <p><strong>Migration Date:</strong> $($Report.MigrationDate)</p> <p><strong>Source Backup:</strong> $($Report.SourceBackupFile)</p> <p><strong>Total Policies:</strong> $($Report.TotalPolicies)</p> <p><strong>Policies with Changes:</strong> $($Report.PoliciesWithChanges)</p> </div> <h2>Policy Details</h2> "@ foreach ($policy in $Report.PoliciesProcessed) { $html += @" <div class="policy"> <div class="policy-name">$($policy.PolicyName)</div> <p><strong>Original Portability:</strong> $(if ($policy.WasCrossTenantPortable) { "✅ Portable" } else { "⚠️ Not Portable" })</p> "@ if ($policy.Warnings.Count -gt 0) { foreach ($warning in $policy.Warnings) { $html += "<div class='warning'>⚠️ $warning</div>" } } if ($policy.Changes.Count -gt 0) { $html += "<h3>Location Changes:</h3>" foreach ($change in $policy.Changes) { $html += @" <div class='change'> <strong>$($change.Location):</strong> $($change.OriginalValue) → $($change.NewValue)<br/> <em>Reason: $($change.Reason)</em> </div> "@ } } $html += "</div>" } $html += @" <div class="footer"> Generated by PurviewDLP Module | $(Get-Date -Format "yyyy-MM-dd HH:mm:ss") </div> </div> </body> </html> "@ # Save report $html | Out-File -FilePath $OutputPath -Encoding UTF8 -Force } #endregion |