v0.1.31 — Policy-Konsolidierung, Geraete-Export nach Gruppe, Gruppen-in-Gruppen
Build & Release MSI / build-msi (push) Canceled after 0s
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:
+119
-4
@@ -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
|
||||
|
||||
@@ -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)
|
||||
# ============================================================
|
||||
|
||||
Reference in New Issue
Block a user