Policy-Import haerten, Admin-Vorlagen, Zuweisung nach Import, Git-Snapshot
Neu: - Administrative Vorlagen (ADMX / groupPolicyConfigurations) als vierter Policy-Typ fuer Export/Import (GET /api/policies/administrativetemplate) - Zuweisung direkt nach Import: pro Policy Include-/Exclude-Gruppen (POST /api/policies/assign, typ-spezifische /assign-Action) - Git-Snapshot: versioniertes Policy-Backup in lokalen Git-Ordner (Settings-Sektion policyBackup) - Import akzeptiert Git-Snapshot-Dateien (policyType neben exportType) Behoben: - Settings-Catalog-Import schema-konform: Collection-Properties immer als Array (repariert PS-5.1-Roundtrip), null -> [], Wrapper-@odata.type, read-only id entfernt - Import-Phantom-Fehler (@($null)-Geisterdurchlauf im Archiv-Pfad) - Frontend-Cache-Reset beim Tenant-Wechsel (resetClientState) - WAM-Anmeldefenster zuverlaessig im Vordergrund (AttachThreadInput) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+366
-13
@@ -1,7 +1,7 @@
|
||||
# Import/Export von Intune-Policies.
|
||||
# Unterstuetzte Typen: Compliance Policies, Configuration Profiles (Templates)
|
||||
# und Settings Catalog. Nutzt die bestehende Microsoft-Graph-Verbindung
|
||||
# (Connect-MgGraph via Api.ps1).
|
||||
# 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,
|
||||
@@ -73,6 +73,20 @@ function Get-PolicyTypeConfig {
|
||||
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 }
|
||||
}
|
||||
}
|
||||
@@ -80,7 +94,7 @@ function Get-PolicyTypeConfig {
|
||||
# ExportType (aus Datei/Envelope) -> Typ-Config. Reverse-Lookup fuer den Import.
|
||||
function Get-PolicyTypeConfigByExportType {
|
||||
param([string]$ExportType)
|
||||
foreach ($key in @('compliance','configuration','settingscatalog')) {
|
||||
foreach ($key in @('compliance','configuration','settingscatalog','administrativetemplate')) {
|
||||
$cfg = Get-PolicyTypeConfig $key
|
||||
if ($cfg.ExportType -eq $ExportType) { return $cfg }
|
||||
}
|
||||
@@ -112,6 +126,8 @@ function Get-PolicyPlatformLabel {
|
||||
$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' }
|
||||
@@ -158,6 +174,18 @@ function Get-GraphPolicyDetail {
|
||||
)
|
||||
$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.
|
||||
if ($cfg.Key -eq 'administrativetemplate') {
|
||||
$base = Invoke-MgGraphRequestRetry -Uri "https://graph.microsoft.com/beta/deviceManagement/groupPolicyConfigurations/$Id" -Method GET
|
||||
$dvUri = "https://graph.microsoft.com/beta/deviceManagement/groupPolicyConfigurations/$Id/definitionValues?`$expand=definition(`$select=id,classType,displayName,policyType,version),presentationValues(`$expand=presentation)"
|
||||
$dvs = @(Get-GraphPaged -Uri $dvUri)
|
||||
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
|
||||
@@ -183,6 +211,53 @@ function ConvertTo-ImportBody {
|
||||
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','values')
|
||||
|
||||
# Array-Property normalisieren: $null -> @() (Graph-Schema: Collections sind
|
||||
# Nullable=False, ein 'children': null wird abgelehnt), Einzelobjekt -> @(obj).
|
||||
# Inline (nicht als Funktion), sonst entpackt der Return ein Ein-Element-Array.
|
||||
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) }
|
||||
}
|
||||
$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) }
|
||||
}
|
||||
$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
|
||||
}
|
||||
|
||||
function New-DefaultComplianceScheduledActions {
|
||||
# Compliance Policies verlangen beim Anlegen mindestens einen
|
||||
# scheduledActionsForRule-Block, sonst antwortet Graph mit 400.
|
||||
@@ -247,18 +322,106 @@ function Import-GraphSettingsCatalogPolicy {
|
||||
throw 'Settings-Catalog-Policy benoetigt ein "name"-Feld fuer den Import.'
|
||||
}
|
||||
# 'settings' MUSS mitgeschickt werden — kommt aus dem $expand=settings-Export.
|
||||
if (-not $body.ContainsKey('settings')) { $body['settings'] = @() }
|
||||
# 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
|
||||
return Invoke-MgGraphRequestRetry -Uri 'https://graph.microsoft.com/beta/deviceManagement/configurationPolicies' -Method POST -Body $json -ContentType 'application/json'
|
||||
}
|
||||
|
||||
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 — die IDs eingebauter ADMX-Vorlagen sind
|
||||
# tenantuebergreifend identisch, daher tenantunabhaengig einsetzbar.
|
||||
$errors = @()
|
||||
foreach ($dv in @(Get-PolicyProp $Policy 'definitionValues')) {
|
||||
if (-not $dv) { continue }
|
||||
$def = Get-PolicyProp $dv 'definition'
|
||||
$defId = [string](Get-PolicyProp $def 'id')
|
||||
if (-not $defId) {
|
||||
$errors += 'definitionValue ohne Definition-Id uebersprungen'
|
||||
continue
|
||||
}
|
||||
|
||||
$presVals = @()
|
||||
foreach ($pv in @(Get-PolicyProp $dv 'presentationValues')) {
|
||||
if (-not $pv) { continue }
|
||||
$pres = Get-PolicyProp $pv 'presentation'
|
||||
$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
|
||||
}
|
||||
|
||||
$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 {}
|
||||
$dn = [string](Get-PolicyProp $def 'displayName'); if (-not $dn) { $dn = $defId }
|
||||
$errors += "${dn}: $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 }
|
||||
'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" }
|
||||
}
|
||||
}
|
||||
@@ -288,6 +451,11 @@ function Get-SettingsCatalogPoliciesEndpoint {
|
||||
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 {
|
||||
@@ -368,10 +536,14 @@ function Import-PoliciesEndpoint {
|
||||
param($Body)
|
||||
try { Assert-GraphConnected } catch { return @{ __status = 401; error = 'Nicht mit Microsoft Graph verbunden' } }
|
||||
|
||||
$uploaded = @(Get-PolicyProp $Body 'policies')
|
||||
$fileNames = @(Get-PolicyProp $Body 'fileNames')
|
||||
# @(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) {
|
||||
if (@($uploaded).Count -eq 0 -and @($fileNames).Count -eq 0) {
|
||||
return @{ __status = 400; error = 'Keine Policies zum Importieren uebergeben' }
|
||||
}
|
||||
|
||||
@@ -380,7 +552,10 @@ function Import-PoliciesEndpoint {
|
||||
# 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 }
|
||||
@@ -401,9 +576,10 @@ function Import-PoliciesEndpoint {
|
||||
# 2) Dateien aus dem Server-Archiv
|
||||
$exportDir = Get-PolicyExportDir
|
||||
foreach ($fileName in $fileNames) {
|
||||
$safe = ($fileName -replace '[\\/]', '')
|
||||
if ([string]::IsNullOrWhiteSpace([string]$fileName)) { continue }
|
||||
$safe = ([string]$fileName -replace '[\\/]', '')
|
||||
$filePath = Join-Path $exportDir $safe
|
||||
if (-not (Test-Path $filePath)) {
|
||||
if ([string]::IsNullOrWhiteSpace($safe) -or -not (Test-Path $filePath -PathType Leaf)) {
|
||||
$results += @{ fileName = $fileName; success = $false; error = 'Datei nicht gefunden' }
|
||||
continue
|
||||
}
|
||||
@@ -411,6 +587,7 @@ function Import-PoliciesEndpoint {
|
||||
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
|
||||
@@ -425,3 +602,179 @@ function Import-PoliciesEndpoint {
|
||||
$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 { $_ }
|
||||
$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) {
|
||||
$results += @{ id = $id; policyName = $name; success = $true; skipped = $true }
|
||||
continue
|
||||
}
|
||||
|
||||
$assignments = @()
|
||||
foreach ($g in $include) {
|
||||
$assignments += @{ target = @{ '@odata.type' = '#microsoft.graph.groupAssignmentTarget'; groupId = [string]$g } }
|
||||
}
|
||||
foreach ($g in $exclude) {
|
||||
$assignments += @{ target = @{ '@odata.type' = '#microsoft.graph.exclusionGroupAssignmentTarget'; groupId = [string]$g } }
|
||||
}
|
||||
$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; 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) }
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# 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
|
||||
}
|
||||
|
||||
function Invoke-Git {
|
||||
param([Parameter(Mandatory=$true)][string]$RepoPath, [Parameter(Mandatory=$true)][string[]]$GitArgs)
|
||||
$out = & git -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?
|
||||
try { $null = & git --version 2>&1; if ($LASTEXITCODE -ne 0) { throw 'x' } }
|
||||
catch { return @{ __status = 500; error = 'git ist nicht installiert oder nicht im PATH.' } }
|
||||
|
||||
# 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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user