v0.1.31 — Policy-Konsolidierung, Geraete-Export nach Gruppe, Gruppen-in-Gruppen
Build & Release MSI / build-msi (push) Canceled after 0s

- Policies zusammenfuehren (Settings Catalog -> eine neue Policy, Konfliktaufloesung)
- Geraete-Tab: Gruppen-Filter (Live-Suche) + CSV-Export der angezeigten Liste
- Group Management: bestehende Gruppen als Mitglied hinzufuegen (verschachtelt)
- Fix: Geraete "Invalid Date" (ISO-Normalisierung) + Datums-/Spalten-Sortierung
- Doku (README/CHANGELOG) + Version-Bump 0.1.31

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 09:01:02 +02:00
co-authored by Claude Opus 4.8
parent 446c53f409
commit bd73a9c3b2
9 changed files with 774 additions and 49 deletions
+119 -4
View File
@@ -58,6 +58,7 @@ function Invoke-ApiHandler {
"GET /api/policies/exports" { return Get-PolicyExportsEndpoint }
"POST /api/policies/import" { return Import-PoliciesEndpoint -Body $Body }
"POST /api/policies/assign" { return Invoke-PolicyAssignEndpoint -Body $Body }
"POST /api/policies/consolidate" { return Invoke-PolicyConsolidateEndpoint -Body $Body }
"POST /api/policies/git-snapshot" { return Invoke-PolicyGitSnapshotEndpoint }
"POST /api/pickfolder" { return Invoke-FolderPickerEndpoint -Body $Body }
@@ -71,6 +72,9 @@ function Invoke-ApiHandler {
if ($Method -eq "GET" -and $Path -match "^/api/groups/([^/]+)/members/export$") {
return Get-GroupMembersExportEndpoint -GroupId $matches[1]
}
if ($Method -eq "GET" -and $Path -match "^/api/groups/([^/]+)/devices/export$") {
return Get-GroupDevicesExportEndpoint -GroupId $matches[1]
}
if ($Method -eq "GET" -and $Path -match "^/api/groups/([^/]+)/members$") {
return Get-GroupMembersEndpoint -GroupId $matches[1]
@@ -1608,6 +1612,117 @@ function Get-GroupMembersExportEndpoint {
}
}
function Get-GroupDevicesExportEndpoint {
# Loest die User einer Gruppe (inkl. verschachtelter Untergruppen) auf und liefert
# deren Intune-Geraete (Geraete, deren PRIMAERER Benutzer in der Gruppe ist) fuer
# einen CSV-Export. Geraete werden per $batch ueber userId eq '<id>' geholt.
param([string]$GroupId)
$err = Test-Connected
if ($err) { return $err }
if ([string]::IsNullOrWhiteSpace($GroupId)) { return @{ __status = 400; error = "GroupId fehlt" } }
$groupName = $GroupId
try {
$g = Get-GraphGroupById -Id $GroupId -Property @("id","displayName")
if ($g.displayName) { $groupName = [string]$g.displayName }
} catch {
return @{ __status = 404; error = "Gruppe nicht gefunden: $($_.Exception.Message)" }
}
Write-Host "[GRP-DEV-EXPORT] Geraete-Export fuer Gruppe '$groupName' ($GroupId)" -ForegroundColor Cyan
$sw = [System.Diagnostics.Stopwatch]::StartNew()
# User aufloesen + deduplizieren (nur eindeutige Entra-User-Ids)
$allUsers = @(Resolve-GroupMembersWithNesting -GroupId $GroupId -GroupName $groupName)
$seen = @{}
$userIds = [System.Collections.Generic.List[string]]::new()
$userMap = @{} # userId -> @{ Upn; DisplayName } (aus den Gruppen-Mitgliedern)
foreach ($u in $allUsers) {
$uid = [string]$u.Id
if ($uid -and -not $seen.ContainsKey($uid)) {
$seen[$uid] = $true
$userIds.Add($uid)
$userMap[$uid] = @{ Upn = [string]$u.UserPrincipalName; DisplayName = [string]$u.DisplayName }
}
}
if ($userIds.Count -eq 0) {
return @{ ok = $true; groupId = $GroupId; groupName = $groupName; userCount = 0; items = @(); count = 0; resolvedMs = [int]$sw.ElapsedMilliseconds }
}
$select = 'id,deviceName,userDisplayName,userPrincipalName,operatingSystem,osVersion,complianceState,serialNumber,model,manufacturer,lastSyncDateTime,enrolledDateTime'
$batchSize = 20
$devSeen = @{}
$items = [System.Collections.Generic.List[object]]::new()
$ids = $userIds.ToArray()
$script:GrpDevErrors = @()
for ($i = 0; $i -lt $ids.Count; $i += $batchSize) {
$chunk = $ids[$i .. [Math]::Min($i + $batchSize - 1, $ids.Count - 1)]
$requests = @()
for ($j = 0; $j -lt $chunk.Count; $j++) {
# Kanonischer Weg: die managedDevices-Navigation des Users. Zuverlaessiger
# als $filter=userId eq '..' auf /deviceManagement/managedDevices.
$requests += @{ id = [string]($i + $j); method = 'GET'; url = "/users/$($chunk[$j])/managedDevices?`$select=$select" }
}
$body = @{ requests = $requests } | ConvertTo-Json -Depth 5 -Compress
try {
$resp = Invoke-MgGraphRequestRetry -Uri 'https://graph.microsoft.com/beta/$batch' -Method POST -Body $body -ContentType 'application/json'
foreach ($r in @($resp.responses)) {
if ([int]$r.status -ne 200) {
if (@($script:GrpDevErrors).Count -lt 3) {
$em = $null
try { $em = [string]$r.body.error.message } catch {}
$script:GrpDevErrors += "HTTP $($r.status)$(if ($em) { ": $em" })"
}
continue
}
# Batch-Request-Id -> Index in $ids -> Gruppen-User (fuer UPN/Name-Fallback,
# da managedDevice.userPrincipalName bei Graph oft leer ist).
$owner = $null
$reqIdx = -1; if ([int]::TryParse([string]$r.id, [ref]$reqIdx)) {
if ($reqIdx -ge 0 -and $reqIdx -lt $ids.Count) { $owner = $userMap[$ids[$reqIdx]] }
}
foreach ($d in @($r.body.value)) {
$did = [string]$d.id
if (-not $did -or $devSeen.ContainsKey($did)) { continue }
$devSeen[$did] = $true
$upn = if ($d.userPrincipalName) { [string]$d.userPrincipalName } elseif ($owner) { $owner.Upn } else { '' }
$udn = if ($d.userDisplayName) { [string]$d.userDisplayName } elseif ($owner) { $owner.DisplayName } else { '' }
$items.Add([pscustomobject]@{
Id = $did
DeviceName = [string]$d.deviceName
UserDisplayName = $udn
UserPrincipalName = $upn
OS = [string]$d.operatingSystem
OSVersion = [string]$d.osVersion
ComplianceState = [string]$d.complianceState
SerialNumber = [string]$d.serialNumber
Model = [string]$d.model
Manufacturer = [string]$d.manufacturer
LastSync = ConvertTo-IsoDate $d.lastSyncDateTime
EnrolledDateTime = ConvertTo-IsoDate $d.enrolledDateTime
})
}
}
} catch {
Write-Host " [GRP-DEV-EXPORT] Batch-Fehler: $($_.Exception.Message)" -ForegroundColor DarkYellow
}
}
$sw.Stop()
Write-Host " -> $($items.Count) Geraete fuer $($userIds.Count) User, $($sw.ElapsedMilliseconds)ms" -ForegroundColor Green
return @{
ok = $true
groupId = $GroupId
groupName = $groupName
userCount = $userIds.Count
items = @($items.ToArray())
count = $items.Count
resolvedMs = [int]$sw.ElapsedMilliseconds
errors = @($script:GrpDevErrors)
}
}
# ============================================================
# Users
# ============================================================
@@ -1728,12 +1843,12 @@ function Search-DevicesEndpoint {
OS = $d.operatingSystem
OSVersion = $d.osVersion
ComplianceState = $d.complianceState
LastSync = $d.lastSyncDateTime
LastSync = ConvertTo-IsoDate $d.lastSyncDateTime
ManagementState = $d.managementState
SerialNumber = $d.serialNumber
Model = $d.model
Manufacturer = $d.manufacturer
EnrolledDateTime = $d.enrolledDateTime
EnrolledDateTime = ConvertTo-IsoDate $d.enrolledDateTime
}
}
return @{ items = $items; count = $items.Count }
@@ -1758,12 +1873,12 @@ function Get-DeviceEndpoint {
OS = $d.operatingSystem
OSVersion = $d.osVersion
ComplianceState = $d.complianceState
LastSync = $d.lastSyncDateTime
LastSync = ConvertTo-IsoDate $d.lastSyncDateTime
ManagementState = $d.managementState
SerialNumber = $d.serialNumber
Model = $d.model
Manufacturer = $d.manufacturer
EnrolledDateTime = $d.enrolledDateTime
EnrolledDateTime = ConvertTo-IsoDate $d.enrolledDateTime
Imei = $d.imei
WiFiMac = $d.wiFiMacAddress
AzureADDeviceId = $d.azureADDeviceId
+155
View File
@@ -738,6 +738,161 @@ function Invoke-PolicyAssignEndpoint {
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)
# ============================================================