Where-Object liefert bei einem Treffer ein Einzelobjekt. Ist das eine Hashtable (PS7 ConvertFrom-Json -AsHashtable, wie im Docker-Container), war $sameType[0] eine Schluessel-Suche nach Key 0 (=> $null) statt Array-Index -> Presentation wurde als "nicht zuordenbar" gewertet und die Einstellung uebersprungen. Unter Windows PowerShell 5.1 (PSCustomObjects) trat der Fehler nicht auf. Fix: Where-Object-Ergebnis vor dem Indexieren in @() zwingen. Verifiziert per Simulation gegen die realen Firefox-ADMX-Exportdaten (Default Search Engine, URL for Home page). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1266 lines
62 KiB
PowerShell
1266 lines
62 KiB
PowerShell
# 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 = [ "<datei>.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?:{ <defId>:<sourceId> } }
|
|
# 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
|
|
}
|
|
}
|