Public/Export-IntuneConfiguration.ps1

# Auto-generated by module/Build-Module.ps1 from en/scripts/Export-IntuneConfig_FraisComplet_v1.ps1
# Edit the source script, then re-run Build-Module.ps1. Do not edit this file directly.

function Export-IntuneConfiguration {
<#
.SYNOPSIS
    FRESH and COMPLETE Intune export from PROD (PowerShell 7 + Microsoft Graph SDK, beta endpoint).
    Produces an Export_<date> folder that is already REHYDRATED, directly importable by the v3 engine.

.DESCRIPTION
    Fixes the 2 pitfalls of the legacy Microsoft.Graph.Intune module:
      - NO "$expand=settings" on the configurationPolicies collection (source of the 400):
        settings are retrieved PER ITEM (GET configurationPolicies/{id}/settings).
      - base64 content of scripts/remediations retrieved PER ITEM (GET .../{id}),
        and scheduledActionsForRule / localizedNotificationMessages retrieved via $expand per item.
    Each family is isolated in try/catch: a failing family does not interrupt the export.
    Writes a manifest.json (TenantId, date, account, counts) at the root of the export.

.PARAMETER SourceTenantId
    GUID of the PROD tenant to export. Safeguard: refuses if the current Graph context != this value.

.PARAMETER OutputPath
    Target folder of the export (e.g. C:\...\input\Export_2026-07-15_0930). Created if missing.

.NOTES
    PREREQUISITE: Connect-MgGraph -TenantId <PROD> -Scopes DeviceManagement*.Read.All (already done before the call).
    READ-ONLY: this script writes NOTHING to the tenant.
.EXAMPLE
    Connect-MgGraph -TenantId <SOURCE_TENANT_ID> -Scopes 'DeviceManagementConfiguration.Read.All','DeviceManagementApps.Read.All','DeviceManagementServiceConfig.Read.All','DeviceManagementRBAC.Read.All'
    .\Export-IntuneConfig_FraisComplet_v1.ps1 -SourceTenantId <SOURCE_TENANT_ID> -OutputPath .\input\Export_2026-07-15_0930
#>

[CmdletBinding()]
param(
    [Parameter(Mandatory)][string]$SourceTenantId,
    [Parameter(Mandatory)][string]$OutputPath
)

$ErrorActionPreference = 'Stop'
$B = 'https://graph.microsoft.com/beta'
# Known exclusions (NOT exported - see LIMITATIONS.md): Device Inventory policies (not exportable with a
# standard Microsoft Graph token), ADMX Administrative Templates, Endpoint Security intents, Enrollment
# configurations, encrypted secrets, and LOB/Win32/VPP app binaries.
$warn = New-Object System.Collections.Generic.List[string]
$counts = @()

# --- Safeguard: we must be connected to the SOURCE tenant (PROD) ---
$ctx = Get-MgContext
if (-not $ctx) { throw "No Graph connection. Run Connect-MgGraph -TenantId $SourceTenantId ... (PROD account) first." }
if ($ctx.TenantId -ne $SourceTenantId) { throw "SAFEGUARD: current context $($ctx.TenantId) != expected source $SourceTenantId. Connect to the PROD tenant." }

$org = $null
try { $org = (Invoke-MgGraphRequest -Method GET -Uri "$B/organization").value | Select-Object -First 1 } catch {}
Write-Host ""
Write-Host ("EXPORT from : {0} (TenantId {1})" -f $org.displayName, $ctx.TenantId) -ForegroundColor Cyan
Write-Host ("Account : {0}" -f $ctx.Account) -ForegroundColor Cyan
Write-Host ("Destination : {0}" -f $OutputPath) -ForegroundColor Cyan
Write-Host ""

New-Item -ItemType Directory -Force -Path $OutputPath | Out-Null

function Get-All {
    param([string]$RelPath)
    $all = @(); $u = "$B/$RelPath"
    do {
        $r = Invoke-MgGraphRequest -Method GET -Uri $u
        if ($r.value) { $all += @($r.value) } elseif ($r.id) { $all += $r }
        $u = $r.'@odata.nextLink'
    } while ($u)
    # IMPORTANT: stream the elements ($all), NOT ",$all". With ",$all" the caller's "@(Get-All ...)"
    # re-wraps the collection into a SINGLE element, so the foreach iterates only once over the whole
    # collection ($o = every object), $o.id becomes a space-joined list, and the per-item URL becomes
    # "endpoint/<id1> <id2> ..." => "The provided URL is not valid" and 0 objects exported.
    return $all
}

function Save-Obj {
    param([string]$Dir,[string]$Name,[string]$Id,$Obj)
    if ([string]::IsNullOrWhiteSpace($Name)) { $Name = 'object' }
    $safe = ($Name -replace '[^\w\.\- ]','_').Trim()
    if ($safe.Length -gt 80) { $safe = $safe.Substring(0,80) }
    $file = Join-Path $Dir ("{0}_{1}.json" -f $safe, $Id)
    ($Obj | ConvertTo-Json -Depth 100) | Set-Content -LiteralPath $file -Encoding UTF8
}

# Family -> endpoint. Enrichment mode:
# Item = re-GET the full entity (base64 content of scripts/remediations)
# Settings = attach settings per item (Settings Catalog) -> AVOIDS the 400 of the $expand collection
# Expand = re-GET with $expand (compliance: actions ; notifications: messages)
$fam = @(
  @{ F='01_DeviceConfigurations';  P='deviceManagement/deviceConfigurations';               N='displayName'; Item=$true }
  @{ F='02_ConfigurationPolicies'; P='deviceManagement/configurationPolicies';              N='name';        Settings=$true }
  @{ F='03_CompliancePolicies';    P='deviceManagement/deviceCompliancePolicies';           N='displayName'; Expand='scheduledActionsForRule($expand=scheduledActionConfigurations)' }
  @{ F='04_ScriptsPowerShell';     P='deviceManagement/deviceManagementScripts';            N='displayName'; Item=$true }
  @{ F='05_ScriptsShell';          P='deviceManagement/deviceShellScripts';                 N='displayName'; Item=$true }
  @{ F='06_Remediations';          P='deviceManagement/deviceHealthScripts';                N='displayName'; Item=$true }
  @{ F='07_Filters';               P='deviceManagement/assignmentFilters';                  N='displayName' }
  @{ F='08_ScopeTags';             P='deviceManagement/roleScopeTags';                      N='displayName' }
  @{ F='09_Apps';                  P='deviceAppManagement/mobileApps';                      N='displayName' }
  @{ F='10_AppConfigurations';     P='deviceAppManagement/mobileAppConfigurations';         N='displayName' }
  @{ F='11_AppProtection';         P='deviceAppManagement/managedAppPolicies';              N='displayName' }
  @{ F='12_AutopilotProfiles';     P='deviceManagement/windowsAutopilotDeploymentProfiles'; N='displayName' }
  @{ F='13_NotificationTemplates'; P='deviceManagement/notificationMessageTemplates';       N='displayName'; Expand='localizedNotificationMessages' }
  @{ F='14_AdminTemplates';        P='deviceManagement/groupPolicyConfigurations';          N='displayName' }
  @{ F='15_EndpointSecurity';      P='deviceManagement/intents';                            N='displayName' }
  @{ F='16_Enrollment';            P='deviceManagement/deviceEnrollmentConfigurations';     N='displayName' }
  @{ F='17_FeatureUpdateProfiles'; P='deviceManagement/windowsFeatureUpdateProfiles'; N='displayName' }
  @{ F='18_QualityUpdateProfiles'; P='deviceManagement/windowsQualityUpdateProfiles'; N='displayName' }
  @{ F='19_DriverUpdateProfiles';  P='deviceManagement/windowsDriverUpdateProfiles';  N='displayName' }
  @{ F='20_TermsAndConditions';    P='deviceManagement/termsAndConditions';            N='displayName' }
  @{ F='21_DeviceCategories';      P='deviceManagement/deviceCategories';              N='displayName' }
  @{ F='22_RoleDefinitions';       P='deviceManagement/roleDefinitions';               N='displayName' }
  @{ F='23_ConditionalAccess';    P='identity/conditionalAccess/policies';               N='displayName' }
)

Write-Host "Families :" -ForegroundColor Yellow
foreach ($cat in $fam) {
    $dir = Join-Path $OutputPath $cat.F
    New-Item -ItemType Directory -Force -Path $dir | Out-Null

    $items = @()
    try { $items = @(Get-All $cat.P) }
    catch { $warn.Add("[$($cat.F)] list FAILED : $($_.Exception.Message)"); }

    $n = 0
    foreach ($o in $items) {
        try {
            $obj = $o
            if     ($cat.Item)     { $obj = Invoke-MgGraphRequest -Method GET -Uri "$B/$($cat.P)/$($o.id)" }
            elseif ($cat.Expand)   { $obj = Invoke-MgGraphRequest -Method GET -Uri "$B/$($cat.P)/$($o.id)?`$expand=$($cat.Expand)" }
            elseif ($cat.Settings) { $obj = $o; $obj['settings'] = @(Get-All "$($cat.P)/$($o.id)/settings") }

            $name = [string]$obj.($cat.N)
            if ([string]::IsNullOrWhiteSpace($name)) { $name = [string]$obj.displayName }
            if ([string]::IsNullOrWhiteSpace($name)) { $name = [string]$obj.name }
            # Warn when a script/remediation body is empty (=> SKIP_EMPTY at import: rehydrate first).
            if ($cat.F -in @('04_ScriptsPowerShell','05_ScriptsShell') -and [string]::IsNullOrEmpty([string]$obj.scriptContent)) {
                $warn.Add("[$($cat.F)] empty scriptContent : $($obj.id) / $name (SKIP_EMPTY at import)")
            }
            if ($cat.F -eq '06_Remediations') {
                if ([string]::IsNullOrEmpty([string]$obj.detectionScriptContent))   { $warn.Add("[$($cat.F)] empty detectionScriptContent : $($obj.id) / $name") }
                if ([string]::IsNullOrEmpty([string]$obj.remediationScriptContent)) { $warn.Add("[$($cat.F)] empty remediationScriptContent : $($obj.id) / $name") }
            }
            Save-Obj -Dir $dir -Name $name -Id ([string]$obj.id) -Obj $obj
            $n++
        } catch { $warn.Add("[$($cat.F)] item $($o.id) FAILED : $($_.Exception.Message)") }
    }
    $counts += [pscustomobject]@{ Family = $cat.F; Count = $n }
    Write-Host (" {0,-26} {1}" -f $cat.F, $n) -ForegroundColor Green
}

# --- Manifest ---
$manifest = [ordered]@{
    Version    = 'FraisComplet-v1'
    ExportedAt = (Get-Date).ToString('o')
    TenantId   = $ctx.TenantId
    TenantName = $org.displayName
    Account    = $ctx.Account
    Families   = $counts
}
($manifest | ConvertTo-Json -Depth 10) | Set-Content -LiteralPath (Join-Path $OutputPath 'manifest.json') -Encoding UTF8

Write-Host ""
Write-Host ("Export finished : {0}" -f $OutputPath) -ForegroundColor Magenta
Write-Host ("Total objects : {0}" -f (($counts | Measure-Object Count -Sum).Sum)) -ForegroundColor Magenta
if ($warn.Count -gt 0) {
    $wf = Join-Path $OutputPath 'export_warnings.txt'
    $warn | Set-Content -LiteralPath $wf -Encoding UTF8
    Write-Host ("Warnings : {0} (see {1})" -f $warn.Count, $wf) -ForegroundColor Yellow
}


# --- Integrity: SHA-256 checksums of every exported file (Verify-IntuneExport.ps1) ---
$sums = [ordered]@{}
Get-ChildItem $OutputPath -Recurse -File | Where-Object { $_.Name -ne 'checksums.json' } | Sort-Object FullName | ForEach-Object {
    $rel = ($_.FullName.Substring($OutputPath.Length).TrimStart('','/')) -replace '\','/'
    $sums[$rel] = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash
}
($sums | ConvertTo-Json -Depth 5) | Set-Content -LiteralPath (Join-Path $OutputPath 'checksums.json') -Encoding UTF8
Write-Host ("Checksums : {0} file(s) (checksums.json)" -f $sums.Count) -ForegroundColor Cyan
}