# Import/Export von Intune-Policies. # Unterstuetzte Typen: Compliance Policies, Configuration Profiles (Templates), # Settings Catalog und Administrative Vorlagen (ADMX / groupPolicyConfigurations). # Nutzt die bestehende Microsoft-Graph-Verbindung (Connect-MgGraph via Api.ps1). # # Zwei Export-Wege, beide aus derselben Aktion: # 1. Browser-Download — der Endpoint liefert die Export-Objekte im Response, # das Frontend laedt sie als JSON-Datei(en) herunter (einzeln oder Bundle). # 2. Server-Archiv — zusaetzlich als JSON unter # %APPDATA%\IntuneAppManager-Web\policy-exports abgelegt. # # Import erfolgt immer als *Neuanlage* im aktuell verbundenen Tenant — es wird # nie eine bestehende Policy ueberschrieben. Quelle ist entweder hochgeladener # JSON-Inhalt (Frontend-Upload) oder eine Datei aus dem Server-Archiv. function Get-PolicyExportDir { $dir = Join-Path (Get-AppDataDir) 'policy-exports' if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } return $dir } # Beim Import zu entfernende, schreibgeschuetzte / instanzgebundene Felder. # Gilt fuer alle Typen; Felder die ein Typ gar nicht besitzt werden ignoriert. $script:PolicyReadOnlyProps = @( 'id', 'createdDateTime', 'lastModifiedDateTime', 'version', 'assignments', 'deviceStatuses', 'userStatuses', 'deviceStatusOverview', 'userStatusOverview', 'deviceSettingStateSummaries', 'supportsScopeTags', 'roleScopeTagIds', # Settings-Catalog-spezifische Read-Only-/Zaehl-Felder: 'settingCount', 'creationSource', 'isAssigned', 'priorityMetaData' ) # Zentrale Typ-Konfiguration: alles Typ-Spezifische an einer Stelle. function Get-PolicyTypeConfig { param([string]$Type) switch (([string]$Type).ToLower()) { 'compliance' { return @{ Key = 'compliance' Collection = 'deviceCompliancePolicies' NameField = 'displayName' # scheduledActionConfigurations muss mit-exportiert werden, sonst # laesst sich die Policy spaeter nicht wieder anlegen. ExportExpand = 'scheduledActionsForRule($expand=scheduledActionConfigurations)' ExportType = 'CompliancePolicy' FilePrefix = 'CompliancePolicy' Label = 'Compliance' } } 'configuration' { return @{ Key = 'configuration' Collection = 'deviceConfigurations' NameField = 'displayName' ExportExpand = $null ExportType = 'ConfigurationProfile' FilePrefix = 'ConfigProfile' Label = 'Konfigurationsprofil' } } 'settingscatalog' { return @{ Key = 'settingscatalog' Collection = 'configurationPolicies' # Settings Catalog nutzt 'name' statt 'displayName'. NameField = 'name' # Die eigentliche Konfiguration steckt in der 'settings'-Nav-Property. ExportExpand = 'settings' ExportType = 'SettingsCatalog' FilePrefix = 'SettingsCatalog' Label = 'Settings Catalog' } } 'administrativetemplate' { return @{ Key = 'administrativetemplate' Collection = 'groupPolicyConfigurations' NameField = 'displayName' # Sonderfall: die konfigurierten Werte liegen nicht inline in der # Policy, sondern in der definitionValues-Subcollection. Deshalb # kein simples $expand -> eigene Behandlung in Get-GraphPolicyDetail. ExportExpand = $null ExportType = 'AdministrativeTemplate' FilePrefix = 'AdminTemplate' Label = 'Administrative Vorlage' } } default { return $null } } } # ExportType (aus Datei/Envelope) -> Typ-Config. Reverse-Lookup fuer den Import. function Get-PolicyTypeConfigByExportType { param([string]$ExportType) foreach ($key in @('compliance','configuration','settingscatalog','administrativetemplate')) { $cfg = Get-PolicyTypeConfig $key if ($cfg.ExportType -eq $ExportType) { return $cfg } } return $null } function Assert-GraphConnected { if (-not $script:State.Connected) { throw 'NOT_CONNECTED' } } # Property-Zugriff, der sowohl Hashtable (PS7 -AsHashtable) als auch # PSCustomObject (PS5.1) korrekt bedient. function Get-PolicyProp { param($Obj, [string]$Name) if ($null -eq $Obj) { return $null } if ($Obj -is [System.Collections.IDictionary]) { if ($Obj.Contains($Name)) { return $Obj[$Name] } return $null } $p = $Obj.PSObject.Properties[$Name] if ($p) { return $p.Value } return $null } # Property setzen — Hashtable ODER PSCustomObject (fuer Import-Umbenennung). function Set-PolicyProp { param($Obj, [string]$Name, $Value) if ($null -eq $Obj) { return } if ($Obj -is [System.Collections.IDictionary]) { $Obj[$Name] = $Value; return } if ($Obj.PSObject.Properties[$Name]) { $Obj.$Name = $Value } else { $Obj | Add-Member -NotePropertyName $Name -NotePropertyValue $Value -Force } } # Grobe Plattform-Bezeichnung fuer die Listenanzeige. function Get-PolicyPlatformLabel { param($Raw, [string]$Type) if (([string]$Type).ToLower() -eq 'settingscatalog') { $p = Get-PolicyProp $Raw 'platforms' return [string]$p } # Administrative Vorlagen (groupPolicyConfigurations) sind reine Windows-Policies. if (([string]$Type).ToLower() -eq 'administrativetemplate') { return 'Windows' } $t = [string](Get-PolicyProp $Raw '@odata.type') switch -Regex ($t) { 'windows' { return 'Windows' } 'macOS|mac' { return 'macOS' } 'ios' { return 'iOS' } 'android' { return 'Android' } default { return '' } } } # ── Graph-Zugriffe ── # Liste (nur Metadaten) eines Typs, normalisiert fuer das Frontend. function Get-GraphPolicyList { param([Parameter(Mandatory=$true)][string]$Type) $cfg = Get-PolicyTypeConfig $Type if (-not $cfg) { throw "Unbekannter Policy-Typ: $Type" } # Zuweisungen gleich mitladen ($expand=assignments), damit die Liste je Policy # Anzahl + aufgeloeste Ziele zeigen kann. Nicht jede deviceManagement-Collection # unterstuetzt $expand=assignments (z.B. groupPolicyConfigurations lehnt es teils # mit 400 ab) -> im Fehlerfall ohne Zuweisungen laden, damit der Typ ueberhaupt # erscheint (und exportierbar bleibt). $listBase = "https://graph.microsoft.com/beta/deviceManagement/$($cfg.Collection)" try { $raw = Get-GraphPaged -Uri ($listBase + '?$expand=assignments') } catch { Write-Host " [POLICIES] $($cfg.Key): `$expand=assignments nicht unterstuetzt -> lade ohne Zuweisungen ($($_.Exception.Message))" -ForegroundColor DarkYellow $raw = Get-GraphPaged -Uri $listBase } $items = @() $needGroups = @{} # eindeutige Gruppen-Ids ueber alle Policies (fuer 1 Bulk-Lookup) foreach ($r in $raw) { $id = Get-PolicyProp $r 'id' if (-not $id) { continue } $name = Get-PolicyProp $r $cfg.NameField if (-not $name) { $name = Get-PolicyProp $r 'displayName' } if (-not $name) { $name = Get-PolicyProp $r 'name' } $asg = @() foreach ($a in @(Get-PolicyProp $r 'assignments')) { $t = Get-PolicyProp $a 'target' if (-not $t) { continue } $ot = [string](Get-PolicyProp $t '@odata.type') $gid = [string](Get-PolicyProp $t 'groupId') $entry = $null if ($ot -like '*exclusionGroupAssignmentTarget') { $entry = [ordered]@{ mode = 'exclude'; kind = 'group'; groupId = $gid } } elseif ($ot -like '*groupAssignmentTarget') { $entry = [ordered]@{ mode = 'include'; kind = 'group'; groupId = $gid } } elseif ($ot -like '*allDevicesAssignmentTarget') { $entry = [ordered]@{ mode = 'include'; kind = 'allDevices'; groupId = '' } } elseif ($ot -like '*allLicensedUsersAssignmentTarget') { $entry = [ordered]@{ mode = 'include'; kind = 'allUsers'; groupId = '' } } if ($entry) { if ($gid) { $needGroups[$gid] = $true } $asg += $entry } } $items += [ordered]@{ id = [string]$id name = [string]$name type = $cfg.Key typeLabel = $cfg.Label platform = Get-PolicyPlatformLabel -Raw $r -Type $cfg.Key odataType = [string](Get-PolicyProp $r '@odata.type') lastModifiedDateTime = Get-PolicyProp $r 'lastModifiedDateTime' assignments = $asg assignmentCount = @($asg).Count } } # Gruppennamen in EINEM Bulk-Lookup aufloesen (bereits bekannte aus dem Cache). $lookup = @{} try { foreach ($g in @($script:State.Groups)) { if ($g.Id) { $lookup[[string]$g.Id] = [string]$g.DisplayName } } foreach ($g in @($script:State.RpaGroups)) { if ($g.Id) { $lookup[[string]$g.Id] = [string]$g.DisplayName } } } catch {} $unknown = [string[]]@($needGroups.Keys | ForEach-Object { [string]$_ } | Where-Object { $_ -and -not $lookup.ContainsKey($_) }) if ($unknown.Count -gt 0) { try { Resolve-GroupNamesBulk -Ids $unknown -Lookup $lookup } catch {} } foreach ($it in $items) { foreach ($a in @($it.assignments)) { if ($a.kind -eq 'group') { $gid = [string]$a.groupId $a.groupName = if ($lookup.ContainsKey($gid)) { $lookup[$gid] } else { $gid } } } } return $items } # Vollstaendige Policy inkl. Settings/Detail — Grundlage fuer den Export. function Get-GraphPolicyDetail { param( [Parameter(Mandatory=$true)][string]$Type, [Parameter(Mandatory=$true)][string]$Id ) $cfg = Get-PolicyTypeConfig $Type if (-not $cfg) { throw "Unbekannter Policy-Typ: $Type" } # Administrative Vorlagen: Basis-Objekt holen und die konfigurierten Werte # (definitionValues inkl. Definition + Presentation-Werten) separat expandieren. # -AsRawJson durchgaengig: bewahrt Ein-Element-Collections als Array (PS-5.1- # Hashtable-Modus wuerde sie skalarisieren -> 400 beim Re-Import). # WICHTIG: presentationValues($expand=presentation) waere ein VERSCHACHTELTER # Expand (Tiefe 2) -> Graph lehnt mit 400 ab ("$expand path too deep, max 1"). # Deshalb definitionValues nur mit 'definition' expandieren (Tiefe 1) und die # presentationValues je definitionValue separat mit 'presentation' nachladen. if ($cfg.Key -eq 'administrativetemplate') { $cfgRoot = "https://graph.microsoft.com/beta/deviceManagement/groupPolicyConfigurations/$Id" $base = Invoke-MgGraphRequestRetry -Uri $cfgRoot -Method GET -AsRawJson $dvUri = "$cfgRoot/definitionValues?`$expand=definition(`$select=id,classType,displayName,categoryPath,policyType,version)" $dvs = @(Get-GraphPaged -Uri $dvUri -AsRawJson) foreach ($dv in $dvs) { if (-not $dv) { continue } $dvId = [string](Get-PolicyProp $dv 'id') if (-not $dvId) { continue } $pvUri = "$cfgRoot/definitionValues/$dvId/presentationValues?`$expand=presentation" $pvs = @(Get-GraphPaged -Uri $pvUri -AsRawJson) if ($dv -is [System.Collections.IDictionary]) { $dv['presentationValues'] = $pvs } else { $dv | Add-Member -NotePropertyName presentationValues -NotePropertyValue $pvs -Force } } if ($base -is [System.Collections.IDictionary]) { $base['definitionValues'] = $dvs } else { $base | Add-Member -NotePropertyName definitionValues -NotePropertyValue $dvs -Force } return $base } $uri = "https://graph.microsoft.com/beta/deviceManagement/$($cfg.Collection)/$Id" if ($cfg.ExportExpand) { $uri += "?`$expand=$($cfg.ExportExpand)" } return Invoke-MgGraphRequestRetry -Uri $uri -Method GET -AsRawJson } # PSCustomObject/Hashtable -> bereinigte Hashtable ohne Read-Only-Felder. function ConvertTo-ImportBody { param([Parameter(Mandatory=$true)]$Policy) $body = @{} $props = if ($Policy -is [System.Collections.IDictionary]) { $Policy.Keys | ForEach-Object { [pscustomobject]@{ Name = $_; Value = $Policy[$_] } } } else { $Policy.PSObject.Properties } foreach ($p in $props) { if ($p.Name -in $script:PolicyReadOnlyProps) { continue } # OData-Metadaten aus dem Export nie mitschicken. if ($p.Name -like '*@odata.context') { continue } # scheduledActionsForRule enthaelt selbst Read-Only-Ids -> separat saeubern. if ($p.Name -eq 'scheduledActionsForRule') { continue } $body[$p.Name] = $p.Value } return $body } # Settings-Catalog-Payloads haben verschachtelte Properties, die laut Graph- # Schema Arrays sein MUESSEN (settings, children, *SettingCollectionValue, values). # Ein JSON-Roundtrip unter Windows PowerShell 5.1 entpackt Ein-Element-Arrays zu # Einzelobjekten -> Graph antwortet mit 400 "... does not match schema". Diese # Funktion baut den Baum rekursiv neu auf und packt betroffene Felder wieder in # Arrays. Idempotent: bereits korrekte Arrays bleiben unveraendert. function Repair-SettingsCatalogArrays { param($Node) $arrayKeys = @('settings','children','groupSettingCollectionValue','simpleSettingCollectionValue','choiceSettingCollectionValue','values') # *TemplateReference-Keys tragen ein Objekt ODER null. Ein alter Export mit zu # geringer ConvertTo-Json-Tiefe hat solche (tief liegenden) Objekte zu ".ToString()" # stringifiziert -> "System.Collections.Hashtable". Graph lehnt das ab # ('Property settingValueTemplateReference ... does not match schema'). Der bloße # String ist eindeutig korrupt und nicht rekonstruierbar -> auf null setzen; die # (optionale) Template-Bindung entfaellt, die eigentlichen Werte bleiben erhalten. $refKeys = @('settingInstanceTemplateReference','settingValueTemplateReference') # Array-Property normalisieren — WICHTIG inline (Zuweisung, KEIN Funktions- # Return): ein leeres Array aus einer Funktion zurueckzugeben entpackt PS zu # $null, was zu 'children: {}' statt '[]' fuehrt. Als Zuweisung bleibt @() ein @(). # $null -> @() (Graph-Schema: Collections sind Nullable=False) # Einzelobjekt -> @(obj) (Ein-Element-Array wurde vom Roundtrip skalarisiert) # leere/Whitespace-STRING-Elemente entfernen: ein PS-JSON-Roundtrip macht aus # einer leeren Collection [] teils ""/[""] -> Graph lehnt das als # 'Property children ... does not match schema' ab. Diese Keys tragen nie bare # Strings -> gefahrlos filtern; wird die Collection dadurch leer, bleibt []. if ($Node -is [System.Collections.IDictionary]) { $out = [ordered]@{} foreach ($k in @($Node.Keys)) { $fixed = Repair-SettingsCatalogArrays $Node[$k] if ($k -in $arrayKeys) { if ($null -eq $fixed) { $fixed = @() } elseif (-not ($fixed -is [System.Collections.IList])) { $fixed = @($fixed) } $fixed = @($fixed | Where-Object { -not (($_ -is [string]) -and [string]::IsNullOrWhiteSpace($_)) }) } elseif ($k -in $refKeys -and ($fixed -is [string])) { $fixed = $null } $out[$k] = $fixed } return $out } if ($Node -is [System.Management.Automation.PSCustomObject]) { $out = [ordered]@{} foreach ($p in $Node.PSObject.Properties) { $fixed = Repair-SettingsCatalogArrays $p.Value if ($p.Name -in $arrayKeys) { if ($null -eq $fixed) { $fixed = @() } elseif (-not ($fixed -is [System.Collections.IList])) { $fixed = @($fixed) } $fixed = @($fixed | Where-Object { -not (($_ -is [string]) -and [string]::IsNullOrWhiteSpace($_)) }) } elseif ($p.Name -in $refKeys -and ($fixed -is [string])) { $fixed = $null } $out[$p.Name] = $fixed } return $out } if (($Node -is [System.Collections.IEnumerable]) -and -not ($Node -is [string])) { # Kein Komma-Operator: ein Ein-Element-Array wird beim Return zwar zum # Skalar entpackt, aber jede Array-Property wird vom Parent ohnehin wieder # in @(...) gewrappt. Ein fuehrendes ',' wuerde das Top-Level-settings- # Array faelschlich in ein Extra-Array verschachteln. return @($Node | ForEach-Object { Repair-SettingsCatalogArrays $_ }) } return $Node } # Entfernt rekursiv alle Properties mit $null-Wert. Configuration Profiles # exportieren nicht genutzte Collection-Properties als null; beim POST lehnt Graph # null fuer 'Collection(...)[Nullable=False]' ab (400 ModelValidationFailure, z.B. # 'defenderAdditionalGuardedFolders'). Weggelassene Properties belegt Graph mit # Defaults -> sicheres Strippen. Array-Typen werden am Parent wieder in @() # gewrappt, damit ein Ein-Element-Array beim Return nicht zum Skalar entpackt wird. function Remove-PolicyNullProps { param($Node) if ($Node -is [System.Collections.IDictionary]) { $out = @{} foreach ($k in @($Node.Keys)) { $v = $Node[$k] if ($null -eq $v) { continue } $fixed = Remove-PolicyNullProps $v if (($v -is [System.Collections.IEnumerable]) -and -not ($v -is [string]) -and -not ($v -is [System.Collections.IDictionary])) { $out[$k] = @($fixed) } else { $out[$k] = $fixed } } return $out } if ($Node -is [System.Management.Automation.PSCustomObject]) { $out = @{} foreach ($p in $Node.PSObject.Properties) { if ($null -eq $p.Value) { continue } $fixed = Remove-PolicyNullProps $p.Value if (($p.Value -is [System.Collections.IEnumerable]) -and -not ($p.Value -is [string]) -and -not ($p.Value -is [System.Collections.IDictionary])) { $out[$p.Name] = @($fixed) } else { $out[$p.Name] = $fixed } } return $out } if (($Node -is [System.Collections.IEnumerable]) -and -not ($Node -is [string])) { return @($Node | ForEach-Object { Remove-PolicyNullProps $_ }) } return $Node } function New-DefaultComplianceScheduledActions { # Compliance Policies verlangen beim Anlegen mindestens einen # scheduledActionsForRule-Block, sonst antwortet Graph mit 400. return @( @{ ruleName = 'PasswordRequired' scheduledActionConfigurations = @( @{ actionType = 'block' gracePeriodHours = 0 notificationTemplateId = '00000000-0000-0000-0000-000000000000' notificationMessageCCList = @() } ) } ) } function Import-GraphCompliancePolicy { param([Parameter(Mandatory=$true)]$Policy) $body = ConvertTo-ImportBody -Policy $Policy # scheduledActionsForRule aus dem Export uebernehmen (Ids strippen) oder Default. $sched = Get-PolicyProp $Policy 'scheduledActionsForRule' $cleanSched = @() foreach ($rule in @($sched)) { if (-not $rule) { continue } $cfgs = @() foreach ($c in @(Get-PolicyProp $rule 'scheduledActionConfigurations')) { if (-not $c) { continue } $tpl = Get-PolicyProp $c 'notificationTemplateId' $cfgs += @{ actionType = [string](Get-PolicyProp $c 'actionType') gracePeriodHours = [int](Get-PolicyProp $c 'gracePeriodHours') notificationTemplateId = if ($tpl) { [string]$tpl } else { '00000000-0000-0000-0000-000000000000' } notificationMessageCCList = @(Get-PolicyProp $c 'notificationMessageCCList') } } $cleanSched += @{ ruleName = [string](Get-PolicyProp $rule 'ruleName'); scheduledActionConfigurations = $cfgs } } if ($cleanSched.Count -eq 0) { $cleanSched = New-DefaultComplianceScheduledActions } $body['scheduledActionsForRule'] = $cleanSched $json = $body | ConvertTo-Json -Depth 50 return Invoke-MgGraphRequestRetry -Uri 'https://graph.microsoft.com/beta/deviceManagement/deviceCompliancePolicies' -Method POST -Body $json -ContentType 'application/json' } function Import-GraphConfigurationProfile { param([Parameter(Mandatory=$true)]$Policy) $body = ConvertTo-ImportBody -Policy $Policy if (-not $body['@odata.type']) { throw 'Configuration Profile benoetigt @odata.type fuer den Import.' } # null-Properties strippen: Graph lehnt null fuer nicht-nullbare Collections ab. $body = Remove-PolicyNullProps $body $json = $body | ConvertTo-Json -Depth 50 return Invoke-MgGraphRequestRetry -Uri 'https://graph.microsoft.com/beta/deviceManagement/deviceConfigurations' -Method POST -Body $json -ContentType 'application/json' } function Import-GraphSettingsCatalogPolicy { param([Parameter(Mandatory=$true)]$Policy) $body = ConvertTo-ImportBody -Policy $Policy if (-not $body['name']) { throw 'Settings-Catalog-Policy benoetigt ein "name"-Feld fuer den Import.' } # 'settings' MUSS mitgeschickt werden — kommt aus dem $expand=settings-Export. # Zusaetzlich Array-Properties reparieren (PS-5.1-Roundtrip-Schaden), sonst # 400 "Property children ... does not match schema". if ($body.ContainsKey('settings') -and $null -ne $body['settings']) { $body['settings'] = @(Repair-SettingsCatalogArrays $body['settings']) # Beim GET liefert Graph die Setting-Wrapper ohne '@odata.type' und mit # read-only 'id'. Der POST verlangt aber den Wrapper-Typ; die 'id' muss # weg -> sonst 400 "Property settings ... does not match schema". foreach ($s in $body['settings']) { if ($s -is [System.Collections.IDictionary]) { if ($s.Contains('id')) { [void]$s.Remove('id') } if (-not $s.Contains('@odata.type')) { $s['@odata.type'] = '#microsoft.graph.deviceManagementConfigurationSetting' } } } } else { $body['settings'] = @() } $json = $body | ConvertTo-Json -Depth 50 # Korruptions-Check: enthaelt der Payload noch stringifizierte .NET-Objekte # ("System.Collections.Hashtable" / "System.Object[]"), stammt die Quelldatei aus # einem alten Export mit zu geringer ConvertTo-Json-Tiefe. *TemplateReference # (optionale Metadaten) wurde oben bereits gerettet; verbleibende Marker sitzen in # WERT-tragenden Feldern (z.B. groupSettingCollectionValue) -> echte Konfiguration # ist verloren und nicht rekonstruierbar. Klar abbrechen statt kaputt zu importieren. if ($json -match 'System\.Collections\.Hashtable|System\.Object\[\]') { throw 'Quelldatei beschaedigt: Teile der Konfiguration wurden von einem alten Export (zu geringe JSON-Tiefe) zu ".ToString()" verstuemmelt und sind nicht wiederherstellbar. Bitte die Policy neu aus Graph exportieren und die frische Datei importieren.' } return Invoke-MgGraphRequestRetry -Uri 'https://graph.microsoft.com/beta/deviceManagement/configurationPolicies' -Method POST -Body $json -ContentType 'application/json' } # Loest eine ingestete (custom) ADMX-Definition im AKTUELLEN Ziel-Tenant auf. # Custom-ADMX-IDs sind tenant-spezifisch -> Binding per Export-Id scheitert (404). # Match ueber stabile Merkmale: displayName + classType (+ categoryPath). # Voraussetzung: die ADMX ist im Ziel-Tenant importiert. Sonst $null. function Resolve-TargetGpDefinition { param($SrcDefinition) $dn = [string](Get-PolicyProp $SrcDefinition 'displayName') $ct = [string](Get-PolicyProp $SrcDefinition 'classType') $cat = [string](Get-PolicyProp $SrcDefinition 'categoryPath') if (-not $dn) { return $null } $dnEsc = $dn -replace "'", "''" $base = 'https://graph.microsoft.com/beta/deviceManagement/groupPolicyDefinitions' $cands = @() try { $uri = "$base`?`$filter=" + [uri]::EscapeDataString("displayName eq '$dnEsc'") $cands = @(Get-GraphPaged -Uri $uri -AsRawJson) } catch { $cands = @() } # Fallback, falls $filter auf displayName nicht unterstuetzt wird: ueber categoryPath. if ($cands.Count -eq 0 -and $cat) { try { $catEsc = $cat -replace "'", "''" $uri2 = "$base`?`$filter=" + [uri]::EscapeDataString("categoryPath eq '$catEsc'") $cands = @(Get-GraphPaged -Uri $uri2 -AsRawJson) | Where-Object { [string](Get-PolicyProp $_ 'displayName') -eq $dn } } catch { $cands = @() } } if (@($cands).Count -eq 0) { return $null } # classType zuerst eingrenzen (User- vs. Device-Variante nie verwechseln), # dann innerhalb dessen categoryPath bevorzugen; sonst erster Treffer. $pool = @($cands) | Where-Object { (-not $ct) -or ([string](Get-PolicyProp $_ 'classType') -eq $ct) } if (@($pool).Count -eq 0) { $pool = @($cands) } $match = @($pool) | Where-Object { $cat -and ([string](Get-PolicyProp $_ 'categoryPath') -eq $cat) } | Select-Object -First 1 if (-not $match) { $match = @($pool) | Select-Object -First 1 } return $match } # Findet zu einer Quell-Presentation die passende Ziel-Presentation-Id: # 1) per label + @odata.type, 2) Fallback: i-te Ziel-Presentation gleichen Typs # (in Definitions-Reihenfolge; $Counter zaehlt je Typ mit). function Resolve-TargetPresentationId { param($TargetPres, $SrcPresentation, [hashtable]$Counter) # @odata.type-Format normalisieren: Graph liefert mal '#microsoft.graph.X', # mal 'microsoft.graph.X' -> fuehrendes '#' weg, klein. Sonst schlaegt der # String-Vergleich fehl und Text-Presentations werden nicht zugeordnet. $norm = { param($t) ([string]$t).TrimStart('#').ToLower() } $srcLabel = [string](Get-PolicyProp $SrcPresentation 'label') $srcType = & $norm (Get-PolicyProp $SrcPresentation '@odata.type') # 1) label + Typ if ($srcLabel) { $m = @($TargetPres) | Where-Object { ([string](Get-PolicyProp $_ 'label') -eq $srcLabel) -and ((& $norm (Get-PolicyProp $_ '@odata.type')) -eq $srcType) } | Select-Object -First 1 if ($m) { return [string](Get-PolicyProp $m 'id') } } # 2) i-te Ziel-Presentation gleichen Typs (in Reihenfolge). # WICHTIG: Ergebnis in @() zwingen - bei EINEM Treffer liefert Where-Object ein # Einzelobjekt; ist das eine Hashtable (PS7 -AsHashtable), waere $x[0] eine # Schluessel-Suche nach Key 0 (=> $null) statt Array-Index -> Fehlmatch. $idx = 0; if ($Counter.ContainsKey($srcType)) { $idx = [int]$Counter[$srcType] } $sameType = @(@($TargetPres) | Where-Object { (& $norm (Get-PolicyProp $_ '@odata.type')) -eq $srcType }) $Counter[$srcType] = $idx + 1 if ($idx -lt $sameType.Count) { return [string](Get-PolicyProp $sameType[$idx] 'id') } # 3) Letzter Fallback: hat die Ziel-Definition genau EINE Presentation, nimm sie # (deckt Ein-Feld-Settings wie Textboxen zuverlaessig ab). $allTgt = @($TargetPres) if ($allTgt.Count -eq 1) { return [string](Get-PolicyProp $allTgt[0] 'id') } return $null } function Import-GraphAdministrativeTemplate { param([Parameter(Mandatory=$true)]$Policy) $displayName = [string](Get-PolicyProp $Policy 'displayName') if (-not $displayName) { throw 'Administrative Vorlage benoetigt "displayName" fuer den Import.' } $defRoot = 'https://graph.microsoft.com/beta/deviceManagement/groupPolicyDefinitions' $cfgRoot = 'https://graph.microsoft.com/beta/deviceManagement/groupPolicyConfigurations' # 1) Leere Konfigurations-Huelle anlegen (definitionValues folgen einzeln). $shell = @{ displayName = $displayName description = [string](Get-PolicyProp $Policy 'description') roleScopeTagIds = @('0') } $created = Invoke-MgGraphRequestRetry -Uri $cfgRoot -Method POST -Body ($shell | ConvertTo-Json -Depth 10) -ContentType 'application/json' $newId = [string](Get-PolicyProp $created 'id') if (-not $newId) { throw 'Anlegen der Administrative-Vorlage-Huelle lieferte keine Id.' } # 2) Jeden definitionValue einzeln anhaengen. Definition + Presentations werden # per @odata.bind referenziert. EINGEBAUTE ADMX-Vorlagen (policyType # 'admxBacked') haben tenantuebergreifend identische IDs -> Export-Id direkt # verwendbar. INGESTETE/CUSTOM ADMX ('admxIngested') hat tenant-spezifische # IDs -> im Ziel-Tenant ueber displayName/classType/categoryPath neu aufloesen # (setzt voraus, dass dieselbe ADMX im Ziel importiert ist; sonst 404). $errors = @() foreach ($dv in @(Get-PolicyProp $Policy 'definitionValues')) { if (-not $dv) { continue } $def = Get-PolicyProp $dv 'definition' $srcDefId = [string](Get-PolicyProp $def 'id') $defName = [string](Get-PolicyProp $def 'displayName'); if (-not $defName) { $defName = $srcDefId } if (-not $srcDefId) { $errors += 'definitionValue ohne Definition-Id uebersprungen'; continue } $ingested = ([string](Get-PolicyProp $def 'policyType') -eq 'admxIngested') $defId = $srcDefId $tgtPres = $null if ($ingested) { $tgtDef = Resolve-TargetGpDefinition $def if (-not $tgtDef) { $errors += "${defName}: ingestete ADMX-Definition im Ziel-Tenant nicht gefunden - die passende ADMX muss dort importiert sein" continue } $defId = [string](Get-PolicyProp $tgtDef 'id') try { $tgtPres = @(Get-GraphPaged -Uri "$defRoot/$defId/presentations" -AsRawJson) } catch { $tgtPres = @() } } $presVals = @() $typeCounter = @{} $presFailed = $false $presDiag = '' foreach ($pv in @(Get-PolicyProp $dv 'presentationValues')) { if (-not $pv) { continue } $pres = Get-PolicyProp $pv 'presentation' if ($ingested) { $presId = Resolve-TargetPresentationId -TargetPres $tgtPres -SrcPresentation $pres -Counter $typeCounter # Presentation nicht zuordenbar (andere ADMX-Version im Ziel): die GANZE # Einstellung ueberspringen, statt mit unvollstaendigem Body ein 400 zu # provozieren. Zur Diagnose Quelle + Ziel-Presentations anhaengen. if (-not $presId) { $srcI = "$([string](Get-PolicyProp $pres '@odata.type'))|label='$([string](Get-PolicyProp $pres 'label'))'" $tgtI = (@($tgtPres) | ForEach-Object { "$([string](Get-PolicyProp $_ '@odata.type'))|label='$([string](Get-PolicyProp $_ 'label'))'" }) -join ' ; ' $presDiag = "Quelle[$srcI] Ziel[$tgtI]" $presFailed = $true; break } } else { $presId = [string](Get-PolicyProp $pres 'id') } $entry = [ordered]@{ '@odata.type' = [string](Get-PolicyProp $pv '@odata.type') 'presentation@odata.bind' = "$defRoot('$defId')/presentations('$presId')" } # Je nach Presentation-Typ traegt der Wert in 'value' ODER 'values'. foreach ($vk in @('value','values')) { $vv = Get-PolicyProp $pv $vk if ($null -ne $vv) { $entry[$vk] = $vv } } $presVals += $entry } if ($presFailed) { $errors += "${defName}: Presentation nicht zuordenbar - uebersprungen. $presDiag" continue } $body = [ordered]@{ enabled = [bool](Get-PolicyProp $dv 'enabled') 'definition@odata.bind' = "$defRoot('$defId')" presentationValues = @($presVals) } try { Invoke-MgGraphRequestRetry -Uri "$cfgRoot/$newId/definitionValues" -Method POST -Body ($body | ConvertTo-Json -Depth 50) -ContentType 'application/json' | Out-Null } catch { $m = $_.Exception.Message try { if ($_.ErrorDetails.Message) { $m = $_.ErrorDetails.Message } } catch {} $errors += "${defName}: $m" } } if ($errors.Count -gt 0) { throw ("Huelle angelegt (Id $newId), aber $($errors.Count) Einstellung(en) fehlgeschlagen: " + ($errors -join ' | ')) } return $created } # Dispatcht anhand des ExportType auf den passenden Import. function Import-GraphPolicyByExportType { param([string]$ExportType, $Policy) switch ($ExportType) { 'CompliancePolicy' { return Import-GraphCompliancePolicy -Policy $Policy } 'ConfigurationProfile' { return Import-GraphConfigurationProfile -Policy $Policy } 'SettingsCatalog' { return Import-GraphSettingsCatalogPolicy -Policy $Policy } 'AdministrativeTemplate' { return Import-GraphAdministrativeTemplate -Policy $Policy } default { throw "Unbekannter exportType: $ExportType" } } } # Anzeigename einer (evtl. Settings-Catalog-)Policy ermitteln. function Get-PolicyDisplayName { param($Policy) $n = Get-PolicyProp $Policy 'displayName' if (-not $n) { $n = Get-PolicyProp $Policy 'name' } return [string]$n } # ── Endpoints ── function Get-CompliancePoliciesEndpoint { try { Assert-GraphConnected } catch { return @{ __status = 401; error = 'Nicht mit Microsoft Graph verbunden' } } return @{ ok = $true; items = @(Get-GraphPolicyList -Type 'compliance') } } function Get-ConfigurationProfilesEndpoint { try { Assert-GraphConnected } catch { return @{ __status = 401; error = 'Nicht mit Microsoft Graph verbunden' } } return @{ ok = $true; items = @(Get-GraphPolicyList -Type 'configuration') } } function Get-SettingsCatalogPoliciesEndpoint { try { Assert-GraphConnected } catch { return @{ __status = 401; error = 'Nicht mit Microsoft Graph verbunden' } } return @{ ok = $true; items = @(Get-GraphPolicyList -Type 'settingscatalog') } } function Get-AdministrativeTemplatesEndpoint { try { Assert-GraphConnected } catch { return @{ __status = 401; error = 'Nicht mit Microsoft Graph verbunden' } } return @{ ok = $true; items = @(Get-GraphPolicyList -Type 'administrativetemplate') } } # Export: liefert die Export-Objekte im Response (Browser-Download) UND legt sie # zusaetzlich als JSON im Server-Archiv ab. Body: { type, ids }. function Export-PoliciesEndpoint { param($Body) try { Assert-GraphConnected } catch { return @{ __status = 401; error = 'Nicht mit Microsoft Graph verbunden' } } $type = [string](Get-PolicyProp $Body 'type') $cfg = Get-PolicyTypeConfig $type if (-not $cfg) { return @{ __status = 400; error = "Unbekannter Typ: $type" } } $ids = @(Get-PolicyProp $Body 'ids') if ($ids.Count -eq 0) { return @{ __status = 400; error = 'Keine Policies ausgewaehlt' } } $exportDir = Get-PolicyExportDir $sourceTenant = if ($script:State.TenantId) { [string]$script:State.TenantId } else { 'unknown' } $stamp = Get-Date -Format 'yyyyMMdd_HHmmss' $files = @() $exports = @() $errors = @() foreach ($id in $ids) { $policy = $null try { $policy = Get-GraphPolicyDetail -Type $type -Id ([string]$id) } catch { $errors += @{ id = [string]$id; error = $_.Exception.Message } Write-Host " [EXPORT] $type/$id fehlgeschlagen: $($_.Exception.Message)" -ForegroundColor DarkYellow continue } if (-not $policy) { $errors += @{ id = [string]$id; error = 'Leere Antwort von Graph' }; continue } $displayName = Get-PolicyDisplayName $policy $exportData = [ordered]@{ exportType = $cfg.ExportType exportDate = (Get-Date).ToString('o') sourceTenant = $sourceTenant policyName = $displayName policy = $policy } $safeName = ($displayName) -replace '[^\w\-\.]', '_' if (-not $safeName) { $safeName = [string]$id } $fileName = "$($cfg.FilePrefix)_${safeName}_$stamp.json" $filePath = Join-Path $exportDir $fileName try { $exportData | ConvertTo-Json -Depth 50 | Set-Content -Path $filePath -Encoding UTF8 $files += $fileName } catch {} # Fuer den Browser-Download inkl. Dateinamens-Vorschlag. $exports += @{ fileName = $fileName policyName = $displayName data = $exportData } } return @{ ok = $true; exportedCount = $exports.Count; files = @($files); exports = @($exports); errors = @($errors) } } # Liste des Server-Archivs (fuer optionalen Server-seitigen Re-Import). function Get-PolicyExportsEndpoint { $exportDir = Get-PolicyExportDir $files = @() if (Test-Path $exportDir) { $files = Get-ChildItem -Path $exportDir -Filter '*.json' | Sort-Object LastWriteTime -Descending | ForEach-Object { $content = $null try { $content = Get-Content $_.FullName -Raw | ConvertFrom-Json } catch {} @{ fileName = $_.Name exportType = if ($content) { [string]$content.exportType } else { '' } exportDate = if ($content) { $content.exportDate } else { $null } sourceTenant = if ($content) { [string]$content.sourceTenant } else { '' } policyName = if ($content) { [string](Get-PolicyProp $content 'policyName') } else { $_.Name } } } } return @{ ok = $true; items = @($files) } } # Import: legt Policies als Neuanlage an. Quelle: # Body.policies = [ { exportType, policy }, ... ] (Frontend-Upload) ODER # Body.fileNames = [ ".json", ... ] (Server-Archiv) function Import-PoliciesEndpoint { param($Body) try { Assert-GraphConnected } catch { return @{ __status = 401; error = 'Nicht mit Microsoft Graph verbunden' } } # @(Get-PolicyProp ...) auf ein fehlendes Feld liefert $null -> @($null) hat # Count 1 (ein Null-Element), kein leeres Array. Ohne Filter wuerde die # Archiv-Schleife einmal mit $fileName = $null laufen und auf das Verzeichnis # selbst zugreifen (DirectoryNotFoundException). Daher Null/Leer rausfiltern. $uploaded = @(Get-PolicyProp $Body 'policies') | Where-Object { $_ } $fileNames = @(Get-PolicyProp $Body 'fileNames') | Where-Object { $_ } if (@($uploaded).Count -eq 0 -and @($fileNames).Count -eq 0) { return @{ __status = 400; error = 'Keine Policies zum Importieren uebergeben' } } $results = @() # 1) Hochgeladene Envelopes foreach ($env in $uploaded) { if (-not $env) { continue } # Envelope-Formate akzeptieren beide Typ-Felder: 'exportType' (Export- # Download) und 'policyType' (Git-Snapshot). $exportType = [string](Get-PolicyProp $env 'exportType') if (-not $exportType) { $exportType = [string](Get-PolicyProp $env 'policyType') } $policy = Get-PolicyProp $env 'policy' $policyName = Get-PolicyProp $env 'policyName' if (-not $policyName) { $policyName = Get-PolicyDisplayName $policy } if (-not $policy) { $results += @{ policyName = [string]$policyName; success = $false; error = 'Envelope ohne "policy"-Feld' } continue } # Optionale Umbenennung: das richtige Namensfeld je Typ setzen. $newName = [string](Get-PolicyProp $env 'newName') if ($newName -and $newName.Trim()) { $cfg = Get-PolicyTypeConfigByExportType $exportType $nameField = if ($cfg) { $cfg.NameField } else { 'displayName' } Set-PolicyProp $policy $nameField $newName.Trim() $policyName = $newName.Trim() } try { $imported = Import-GraphPolicyByExportType -ExportType $exportType -Policy $policy $results += @{ policyName = [string]$policyName; exportType = $exportType; success = $true; newId = [string](Get-PolicyProp $imported 'id') } } catch { $msg = $_.Exception.Message try { if ($_.ErrorDetails.Message) { $msg = $_.ErrorDetails.Message } } catch {} $results += @{ policyName = [string]$policyName; exportType = $exportType; success = $false; error = $msg } } } # 2) Dateien aus dem Server-Archiv $exportDir = Get-PolicyExportDir foreach ($fileName in $fileNames) { if ([string]::IsNullOrWhiteSpace([string]$fileName)) { continue } $safe = ([string]$fileName -replace '[\\/]', '') $filePath = Join-Path $exportDir $safe if ([string]::IsNullOrWhiteSpace($safe) -or -not (Test-Path $filePath -PathType Leaf)) { $results += @{ fileName = $fileName; success = $false; error = 'Datei nicht gefunden' } continue } $policyName = $fileName try { $content = Get-Content $filePath -Raw | ConvertFrom-Json $exportType = [string](Get-PolicyProp $content 'exportType') if (-not $exportType) { $exportType = [string](Get-PolicyProp $content 'policyType') } $policy = Get-PolicyProp $content 'policy' $policyName = Get-PolicyDisplayName $policy $imported = Import-GraphPolicyByExportType -ExportType $exportType -Policy $policy $results += @{ fileName = $fileName; policyName = [string]$policyName; exportType = $exportType; success = $true; newId = [string](Get-PolicyProp $imported 'id') } } catch { $msg = $_.Exception.Message try { if ($_.ErrorDetails.Message) { $msg = $_.ErrorDetails.Message } } catch {} $results += @{ fileName = $fileName; policyName = [string]$policyName; success = $false; error = $msg } } } $ok = @($results | Where-Object { $_.success }).Count return @{ ok = $true; importedCount = $ok; results = @($results) } } # Weist importierten Policies Gruppen zu (Include + Exclude). Body: # items = [ { exportType, id, policyName, include:[groupId], exclude:[groupId] } ] # Nutzt die typ-spezifische /assign-Action. Zuweisungen sind pro Policy. function Invoke-PolicyAssignEndpoint { param($Body) try { Assert-GraphConnected } catch { return @{ __status = 401; error = 'Nicht mit Microsoft Graph verbunden' } } $items = @(Get-PolicyProp $Body 'items') | Where-Object { $_ } if (@($items).Count -eq 0) { return @{ __status = 400; error = 'Keine Zuweisungen uebergeben' } } $results = @() foreach ($it in $items) { $exportType = [string](Get-PolicyProp $it 'exportType') $id = [string](Get-PolicyProp $it 'id') $name = [string](Get-PolicyProp $it 'policyName') $include = @(Get-PolicyProp $it 'include') | Where-Object { $_ } $exclude = @(Get-PolicyProp $it 'exclude') | Where-Object { $_ } # Integrierte (virtuelle) Include-Ziele: Alle Geraete / Alle Benutzer. $allDevices = [bool](Get-PolicyProp $it 'allDevices') $allUsers = [bool](Get-PolicyProp $it 'allUsers') # Modus: 'replace' (Default, Full-Replace wie bisher) oder 'add' (bestehende # Zuweisungen erhalten und die neuen Gruppen dazu mergen). Die Graph-/assign- # Action ersetzt IMMER die komplette Liste -> fuer 'add' muessen die # bestehenden Zuweisungen vorher gelesen und mitgeschickt werden. $mode = ([string](Get-PolicyProp $it 'mode')).ToLower() if ($mode -ne 'add') { $mode = 'replace' } $cfg = Get-PolicyTypeConfigByExportType $exportType if (-not $cfg) { $results += @{ id = $id; policyName = $name; success = $false; error = "Unbekannter exportType: $exportType" }; continue } if (-not $id) { $results += @{ policyName = $name; success = $false; error = 'Policy-Id fehlt' }; continue } if (@($include).Count -eq 0 -and @($exclude).Count -eq 0 -and -not $allDevices -and -not $allUsers) { $results += @{ id = $id; policyName = $name; success = $true; skipped = $true } continue } $assignments = @() $seen = @{} # Dedup-Key "odataType|groupId" -> $true if ($mode -eq 'add') { # Bestehende Zuweisungen lesen und 1:1 uebernehmen (inkl. evtl. Filter/ # allDevices/allLicensedUsers). Schlaegt das Lesen fehl, brechen wir fuer # diese Policy ab, statt versehentlich bestehende Zuweisungen zu loeschen. try { $existing = Invoke-MgGraphRequestRetry -Uri "https://graph.microsoft.com/beta/deviceManagement/$($cfg.Collection)/$id/assignments" -Method GET foreach ($a in @($existing.value)) { $t = $a.target if (-not $t) { continue } $ot = [string]$t.'@odata.type' $gid = [string]$t.groupId $key = "$ot|$gid" if ($seen[$key]) { continue } $seen[$key] = $true $tgt = @{ '@odata.type' = $ot } if ($gid) { $tgt['groupId'] = $gid } foreach ($fld in @('deviceAndAppManagementAssignmentFilterId','deviceAndAppManagementAssignmentFilterType')) { $v = $t.$fld if ($null -ne $v -and [string]$v -ne '') { $tgt[$fld] = $v } } $assignments += @{ target = $tgt } } } catch { $em = $_.Exception.Message try { if ($_.ErrorDetails.Message) { $em = $_.ErrorDetails.Message } } catch {} $results += @{ id = $id; policyName = $name; success = $false; error = "Bestehende Zuweisungen nicht lesbar (Hinzufuegen-Modus): $em" } continue } } foreach ($g in $include) { $key = "#microsoft.graph.groupAssignmentTarget|$g" if ($seen[$key]) { continue } $seen[$key] = $true $assignments += @{ target = @{ '@odata.type' = '#microsoft.graph.groupAssignmentTarget'; groupId = [string]$g } } } foreach ($g in $exclude) { $key = "#microsoft.graph.exclusionGroupAssignmentTarget|$g" if ($seen[$key]) { continue } $seen[$key] = $true $assignments += @{ target = @{ '@odata.type' = '#microsoft.graph.exclusionGroupAssignmentTarget'; groupId = [string]$g } } } if ($allDevices) { $key = '#microsoft.graph.allDevicesAssignmentTarget|' if (-not $seen[$key]) { $seen[$key] = $true; $assignments += @{ target = @{ '@odata.type' = '#microsoft.graph.allDevicesAssignmentTarget' } } } } if ($allUsers) { $key = '#microsoft.graph.allLicensedUsersAssignmentTarget|' if (-not $seen[$key]) { $seen[$key] = $true; $assignments += @{ target = @{ '@odata.type' = '#microsoft.graph.allLicensedUsersAssignmentTarget' } } } } $json = @{ assignments = @($assignments) } | ConvertTo-Json -Depth 10 $uri = "https://graph.microsoft.com/beta/deviceManagement/$($cfg.Collection)/$id/assign" try { Invoke-MgGraphRequestRetry -Uri $uri -Method POST -Body $json -ContentType 'application/json' | Out-Null $results += @{ id = $id; policyName = $name; success = $true; mode = $mode; includeCount = @($include).Count; excludeCount = @($exclude).Count } } catch { $m = $_.Exception.Message try { if ($_.ErrorDetails.Message) { $m = $_.ErrorDetails.Message } } catch {} $results += @{ id = $id; policyName = $name; success = $false; error = $m } } } $ok = @($results | Where-Object { $_.success -and -not $_.skipped }).Count return @{ ok = $true; assignedCount = $ok; results = @($results) } } # ============================================================ # Settings-Catalog-Policies zu EINER neuen Policy zusammenfuehren # ============================================================ # settingDefinitionId eines Settings-Elements (Wrapper { settingInstance, id }). function Get-SettingDefinitionId { param($SettingElement) $si = Get-PolicyProp $SettingElement 'settingInstance' if (-not $si) { $si = $SettingElement } return [string](Get-PolicyProp $si 'settingDefinitionId') } # Kanonische, stabil sortierte JSON-Darstellung der settingInstance -> Wert-Vergleich # fuer die Konflikt-Erkennung (gleiche Definition, unterschiedlicher Wert = Konflikt). function Get-SettingCanonicalJson { param($SettingElement) $si = Get-PolicyProp $SettingElement 'settingInstance' if (-not $si) { $si = $SettingElement } return ((ConvertTo-StableObject $si) | ConvertTo-Json -Depth 50 -Compress) } # Body: { ids:[...], mode:"preview"|"create", name?, description?, resolutions?:{ : } } # Nur Settings Catalog. Fuehrt die 'settings' mehrerer Policies zu einer neuen zusammen. # Gleiche settingDefinitionId + gleicher Wert -> einmal uebernommen. Gleiche Definition, # anderer Wert -> Konflikt: Default gewinnt die zuerst gewaehlte Policy, per 'resolutions' # ueberschreibbar. Import erfolgt als Neuanlage (nie Ueberschreiben). function Invoke-PolicyConsolidateEndpoint { param($Body) try { Assert-GraphConnected } catch { return @{ __status = 401; error = 'Nicht mit Microsoft Graph verbunden' } } $ids = @(Get-PolicyProp $Body 'ids') | Where-Object { $_ } if (@($ids).Count -lt 2) { return @{ __status = 400; error = 'Bitte mindestens zwei Settings-Catalog-Policies auswaehlen.' } } $mode = [string](Get-PolicyProp $Body 'mode'); if (-not $mode) { $mode = 'preview' } $resolutions = Get-PolicyProp $Body 'resolutions' # Details laden (Reihenfolge = Auswahlreihenfolge = Default-Konfliktgewinner) $sources = @() foreach ($id in $ids) { $detail = $null try { $detail = Get-GraphPolicyDetail -Type 'settingscatalog' -Id ([string]$id) } catch {} if (-not $detail) { return @{ __status = 400; error = "Policy $id konnte nicht geladen werden." } } $sources += @{ id = [string]$id name = [string](Get-PolicyProp $detail 'name') platforms = [string](Get-PolicyProp $detail 'platforms') technologies = [string](Get-PolicyProp $detail 'technologies') settings = @(Get-PolicyProp $detail 'settings') } } # Plattform muss uebereinstimmen - sonst kein sinnvoller Merge. $platforms = @($sources | ForEach-Object { $_.platforms } | Where-Object { $_ } | Sort-Object -Unique) if ($platforms.Count -gt 1) { return @{ __status = 400; error = "Unterschiedliche Plattformen ($($platforms -join ', ')) - Zusammenfuehren nicht moeglich." } } $mergedPlatform = if ($platforms.Count -ge 1) { $platforms[0] } else { 'windows10' } # Technologies vereinen (Union). $techSet = [ordered]@{} foreach ($s in $sources) { foreach ($t in ($s.technologies -split ',')) { $tt = $t.Trim(); if ($tt) { $techSet[$tt] = $true } } } $mergedTech = (@($techSet.Keys) -join ','); if (-not $mergedTech) { $mergedTech = 'mdm' } # Nach settingDefinitionId gruppieren (Reihenfolge der Definitionen beibehalten). $groups = [ordered]@{} foreach ($s in $sources) { foreach ($el in @($s.settings)) { if (-not $el) { continue } $defId = Get-SettingDefinitionId $el if (-not $defId) { continue } if (-not $groups.Contains($defId)) { $groups[$defId] = @() } $groups[$defId] += @{ sourceId = $s.id; sourceName = $s.name; element = $el; canon = (Get-SettingCanonicalJson $el) } } } $mergedSettings = @() $conflicts = @() foreach ($defId in @($groups.Keys)) { $entries = @($groups[$defId]) $distinct = @($entries | Group-Object -Property { $_.canon }) if ($distinct.Count -eq 1) { $mergedSettings += $entries[0].element continue } # Konflikt: Gewinner bestimmen (resolutions[defId] = sourceId, sonst erste Quelle). $resSource = if ($resolutions) { [string](Get-PolicyProp $resolutions $defId) } else { '' } $chosen = $null if ($resSource) { $chosen = @($entries | Where-Object { $_.sourceId -eq $resSource })[0] } if (-not $chosen) { $chosen = $entries[0] } $mergedSettings += $chosen.element $conflicts += @{ settingDefinitionId = $defId chosenSourceId = $chosen.sourceId variants = @($distinct | ForEach-Object { @{ sourceIds = @($_.Group | ForEach-Object { $_.sourceId }) sourceNames = @($_.Group | ForEach-Object { $_.sourceName } | Select-Object -Unique) } }) } } if ($mode -ne 'create') { return @{ ok = $true mode = 'preview' platform = $mergedPlatform technologies = $mergedTech totalSettings = @($mergedSettings).Count conflictCount = @($conflicts).Count conflicts = @($conflicts) sources = @($sources | ForEach-Object { @{ id = $_.id; name = $_.name; settingCount = @($_.settings).Count } }) } } # --- create --- if ($script:State.ReadOnly) { return @{ __status = 403; error = 'Read-Only-Modus: Zusammenfuehren ist deaktiviert.' } } $name = [string](Get-PolicyProp $Body 'name') if ([string]::IsNullOrWhiteSpace($name)) { return @{ __status = 400; error = 'Name fuer die neue Policy fehlt.' } } $desc = [string](Get-PolicyProp $Body 'description') # settings fuer den POST vorbereiten: Wrapper mit @odata.type, read-only 'id' weg, # Arrays reparieren (gleiche Behandlung wie beim Import). $outSettings = @() foreach ($el in $mergedSettings) { $si = Get-PolicyProp $el 'settingInstance' if (-not $si) { $si = $el } $outSettings += [ordered]@{ '@odata.type' = '#microsoft.graph.deviceManagementConfigurationSetting' settingInstance = $si } } $outSettings = @(Repair-SettingsCatalogArrays $outSettings) $newBody = [ordered]@{ name = $name description = $desc platforms = $mergedPlatform technologies = $mergedTech templateReference = @{ templateFamily = 'none'; templateId = '' } settings = $outSettings } $json = $newBody | ConvertTo-Json -Depth 50 if ($json -match 'System\.Collections\.Hashtable|System\.Object\[\]') { return @{ __status = 500; error = 'Interner Serialisierungsfehler beim Zusammenfuehren (stringifizierte Objekte).' } } try { $created = Invoke-MgGraphRequestRetry -Uri 'https://graph.microsoft.com/beta/deviceManagement/configurationPolicies' -Method POST -Body $json -ContentType 'application/json' return @{ ok = $true; mode = 'create'; newId = [string](Get-PolicyProp $created 'id'); name = $name; settingsCount = @($outSettings).Count; conflictCount = @($conflicts).Count } } catch { $msg = $_.Exception.Message try { if ($_.ErrorDetails.Message) { $msg = $_.ErrorDetails.Message } } catch {} return @{ __status = 500; error = $msg } } } # ============================================================ # Policy-Snapshot nach Git (voller Export, stabile Dateinamen) # ============================================================ # Rekursiv nach Schluesseln sortieren -> deterministische JSON-Ausgabe, damit # unveraenderte Policies keine Diff-Noise durch wechselnde Key-Reihenfolge # erzeugen (Invoke-MgGraphRequest liefert ungeordnete Hashtables). function ConvertTo-StableObject { param($InputObject) if ($InputObject -is [System.Collections.IDictionary]) { $ordered = [ordered]@{} foreach ($k in ($InputObject.Keys | Sort-Object)) { $ordered[$k] = ConvertTo-StableObject $InputObject[$k] } return $ordered } if (($InputObject -is [System.Collections.IEnumerable]) -and -not ($InputObject -is [string])) { # Array-Reihenfolge bleibt erhalten (ist bei Policies bedeutungstragend). return @($InputObject | ForEach-Object { ConvertTo-StableObject $_ }) } return $InputObject } # git muss nicht im PATH des Server-Prozesses liegen (haeufig, wenn der Server vor # der Git-Installation gestartet wurde oder mit eingefrorener Umgebung laeuft). # Erst PATH probieren, dann bekannte Installationsorte. Ergebnis wird gecacht. function Get-GitExe { if ($script:GitExe -and (Test-Path $script:GitExe)) { return $script:GitExe } $cmd = Get-Command git -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 if ($cmd) { $script:GitExe = $cmd.Source; return $script:GitExe } $cands = @() if ($env:ProgramFiles) { $cands += (Join-Path $env:ProgramFiles 'Git\cmd\git.exe') } if (${env:ProgramFiles(x86)}) { $cands += (Join-Path ${env:ProgramFiles(x86)} 'Git\cmd\git.exe') } if ($env:LOCALAPPDATA) { $cands += (Join-Path $env:LOCALAPPDATA 'Programs\Git\cmd\git.exe') } if ($env:LOCALAPPDATA) { $cands += (Join-Path $env:LOCALAPPDATA 'Microsoft\WinGet\Links\git.exe') } foreach ($c in $cands) { if ($c -and (Test-Path $c)) { $script:GitExe = $c; return $c } } return $null } function Invoke-Git { param([Parameter(Mandatory=$true)][string]$RepoPath, [Parameter(Mandatory=$true)][string[]]$GitArgs) $exe = Get-GitExe if (-not $exe) { return @{ exit = 9009; out = 'git nicht gefunden (weder im PATH noch an bekannten Installationsorten).' } } $out = & $exe -C $RepoPath @GitArgs 2>&1 return @{ exit = $LASTEXITCODE; out = (@($out) -join "`n").Trim() } } function Get-PolicySnapshotFolderName { # Tenant-Unterordner im Repo: Label (Multi-Tenant) sonst TenantId sonst 'default'. $active = Get-ActiveConnection -Settings $script:Settings $name = if ($active.label) { $active.label } elseif ($script:State.TenantId) { [string]$script:State.TenantId } else { 'default' } $safe = ($name -replace '[^\w\-\.]', '_') if ([string]::IsNullOrWhiteSpace($safe)) { $safe = 'default' } return $safe } function Invoke-PolicyGitSnapshotEndpoint { try { Assert-GraphConnected } catch { return @{ __status = 401; error = 'Nicht mit Microsoft Graph verbunden' } } $cfg = $script:Settings.policyBackup $repo = if ($cfg -and $cfg.gitRepoPath) { [string]$cfg.gitRepoPath } else { '' } $doPush = if ($cfg -and $cfg.push) { [bool]$cfg.push } else { $false } if ([string]::IsNullOrWhiteSpace($repo)) { return @{ __status = 400; error = 'Kein Git-Repo-Pfad konfiguriert (Einstellungen -> Policy-Backup).' } } # git verfuegbar? (PATH + bekannte Installationsorte) if (-not (Get-GitExe)) { return @{ __status = 500; error = 'git ist nicht auffindbar (weder im PATH des Server-Prozesses noch an den Standard-Installationsorten). Ggf. Server nach der Git-Installation neu starten.' } } # Repo-Ordner + .git sicherstellen if (-not (Test-Path $repo)) { New-Item -ItemType Directory -Path $repo -Force | Out-Null } if (-not (Test-Path (Join-Path $repo '.git'))) { $r = Invoke-Git -RepoPath $repo -GitArgs @('init') if ($r.exit -ne 0) { return @{ __status = 500; error = "git init fehlgeschlagen: $($r.out)" } } } # Commit-Identitaet sicherstellen (frisches Repo hat evtl. keine). if ([string]::IsNullOrWhiteSpace((Invoke-Git -RepoPath $repo -GitArgs @('config','user.email')).out)) { Invoke-Git -RepoPath $repo -GitArgs @('config','user.email','intune-manager@localhost') | Out-Null Invoke-Git -RepoPath $repo -GitArgs @('config','user.name','Intune Manager') | Out-Null } $tenantFolder = Get-PolicySnapshotFolderName $tenantDir = Join-Path $repo $tenantFolder # Tenant-Ordner komplett neu aufbauen -> entfernte Policies verschwinden (Diff). if (Test-Path $tenantDir) { Remove-Item $tenantDir -Recurse -Force } New-Item -ItemType Directory -Path $tenantDir -Force | Out-Null $types = @('settingscatalog','compliance','configuration','administrativetemplate') $total = 0 $perType = [ordered]@{} foreach ($type in $types) { $tcfg = Get-PolicyTypeConfig $type $list = @(Get-GraphPolicyList -Type $type) $perType[$tcfg.ExportType] = $list.Count if ($list.Count -eq 0) { continue } $typeDir = Join-Path $tenantDir $type New-Item -ItemType Directory -Path $typeDir -Force | Out-Null foreach ($item in $list) { $id = [string]$item.id $detail = $null try { $detail = Get-GraphPolicyDetail -Type $type -Id $id } catch { continue } $name = [string]$item.name $safe = ($name -replace '[^\w\-\.]', '_'); if (-not $safe) { $safe = $id } $short = if ($id.Length -ge 8) { $id.Substring(0,8) } else { $id } $fname = "$($safe)__$($short).json" $envelope = [ordered]@{ policyType = $tcfg.ExportType policyName = $name policy = $detail } # stabile (sortierte) Ausgabe, ohne Zeitstempel -> saubere Diffs (ConvertTo-StableObject $envelope) | ConvertTo-Json -Depth 50 | Set-Content -Path (Join-Path $typeDir $fname) -Encoding UTF8 $total++ } } $add = Invoke-Git -RepoPath $repo -GitArgs @('add','-A') if ($add.exit -ne 0) { return @{ __status = 500; error = "git add fehlgeschlagen: $($add.out)" } } if ([string]::IsNullOrWhiteSpace((Invoke-Git -RepoPath $repo -GitArgs @('status','--porcelain')).out)) { return @{ ok = $true; changed = $false; committed = $false; total = $total; perType = $perType; tenant = $tenantFolder; message = 'Keine Aenderungen seit dem letzten Snapshot.' } } $msg = "Policy-Snapshot $tenantFolder $(Get-Date -Format 'yyyy-MM-dd HH:mm')" $commit = Invoke-Git -RepoPath $repo -GitArgs @('commit','-m',$msg) if ($commit.exit -ne 0) { return @{ __status = 500; error = "git commit fehlgeschlagen: $($commit.out)" } } $hash = (Invoke-Git -RepoPath $repo -GitArgs @('rev-parse','--short','HEAD')).out $pushed = $false; $pushError = $null if ($doPush) { $push = Invoke-Git -RepoPath $repo -GitArgs @('push') if ($push.exit -eq 0) { $pushed = $true } else { $pushError = $push.out } } $summary = "Snapshot committet: $total Policies ($hash)" if ($doPush) { $summary += if ($pushed) { ', gepusht' } else { ', Push fehlgeschlagen' } } return @{ ok = $true; changed = $true; committed = $true total = $total; perType = $perType; tenant = $tenantFolder commit = $hash; pushed = $pushed; pushError = $pushError message = $summary } }