Policies-Tab: Export/Import (Settings Catalog, Compliance, Config)
Build & Release MSI / build-msi (push) Canceled after 0s
Build & Release MSI / build-msi (push) Canceled after 0s
Neuer Tab "Policies" zum Sichern und Wiederherstellen von Intune-Policies. - Backend src/PolicyIO.ps1: typ-getrieben fuer configurationPolicies (Settings Catalog), deviceCompliancePolicies und deviceConfigurations - Export als JSON-Download (einzeln/Bundle) + Server-Archiv unter %APPDATA% - Import per Datei-Upload, immer Neuanlage (nie ueberschreiben), robust fuer Hashtable (PS7) und PSCustomObject (PS5.1); im Read-Only-Modus ausgeblendet - Routen in src/Api.ps1; Config-Scope DeviceManagementConfiguration.* auch nach Settings-Save sichergestellt - Frontend: Tab + View, Suche/Typ-Filter/Sortierung, Auswahl, Download/Upload - Version 0.1.25 (Start.ps1, CHANGELOG, README) - build-local.ps1: x64-.NET-Runtime fuer wix auf ARM64-Windows + echter Desktop-Pfad Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,427 @@
|
||||
# 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).
|
||||
#
|
||||
# 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 $env:APPDATA 'IntuneAppManager-Web'
|
||||
$dir = Join-Path $dir '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'
|
||||
}
|
||||
}
|
||||
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')) {
|
||||
$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
|
||||
}
|
||||
|
||||
# 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
|
||||
}
|
||||
$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" }
|
||||
$raw = Get-GraphPaged -Uri "https://graph.microsoft.com/beta/deviceManagement/$($cfg.Collection)"
|
||||
$items = @()
|
||||
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' }
|
||||
$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'
|
||||
}
|
||||
}
|
||||
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" }
|
||||
$uri = "https://graph.microsoft.com/beta/deviceManagement/$($cfg.Collection)/$Id"
|
||||
if ($cfg.ExportExpand) { $uri += "?`$expand=$($cfg.ExportExpand)" }
|
||||
return Invoke-MgGraphRequestRetry -Uri $uri -Method GET
|
||||
}
|
||||
|
||||
# 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
|
||||
}
|
||||
|
||||
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.'
|
||||
}
|
||||
$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.
|
||||
if (-not $body.ContainsKey('settings')) { $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'
|
||||
}
|
||||
|
||||
# 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 }
|
||||
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') }
|
||||
}
|
||||
|
||||
# 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 = @()
|
||||
foreach ($id in $ids) {
|
||||
$policy = $null
|
||||
try { $policy = Get-GraphPolicyDetail -Type $type -Id ([string]$id) } catch {}
|
||||
if (-not $policy) { 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) }
|
||||
}
|
||||
|
||||
# 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' } }
|
||||
|
||||
$uploaded = @(Get-PolicyProp $Body 'policies')
|
||||
$fileNames = @(Get-PolicyProp $Body 'fileNames')
|
||||
|
||||
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 }
|
||||
$exportType = [string](Get-PolicyProp $env 'exportType')
|
||||
$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
|
||||
}
|
||||
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) {
|
||||
$safe = ($fileName -replace '[\\/]', '')
|
||||
$filePath = Join-Path $exportDir $safe
|
||||
if (-not (Test-Path $filePath)) {
|
||||
$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')
|
||||
$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) }
|
||||
}
|
||||
Reference in New Issue
Block a user