v0.1.29 — Geraete-Offboarding (v1)
Build & Release MSI / build-msi (push) Canceled after 0s

Neu:
- Geraete-Offboarding: Geraete ueber Intune, Autopilot und Entra ID
  entfernen. Suche + ID-Aufloesung (via $batch), Recovery-Keys
  (BitLocker/FileVault/LAPS) vor dem Loeschen, Bestaetigungs-Dialog mit
  Dienst-Auswahl + 403/Multi-Admin-Approval-Handling, im Read-Only gesperrt.
  Neues Modul src/Offboard.ps1, Endpoints /api/offboard/{search,keys,execute}.
  Portiert aus Device Offboarding Manager (Ugur Koc, MIT).
- Offboarding-Berechtigungen dokumentiert; Setup-Skript -IncludeOffboarding.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 12:37:41 +02:00
co-authored by Claude Opus 4.8
parent 830dddd15a
commit 5c60eacc12
9 changed files with 571 additions and 3 deletions
+4
View File
@@ -60,6 +60,10 @@ function Invoke-ApiHandler {
"POST /api/policies/assign" { return Invoke-PolicyAssignEndpoint -Body $Body }
"POST /api/policies/git-snapshot" { return Invoke-PolicyGitSnapshotEndpoint }
"POST /api/pickfolder" { return Invoke-FolderPickerEndpoint -Body $Body }
"GET /api/offboard/search" { return Search-OffboardDevicesEndpoint -Query $Query }
"POST /api/offboard/keys" { return Get-OffboardKeysEndpoint -Body $Body }
"POST /api/offboard/execute" { return Invoke-OffboardExecuteEndpoint -Body $Body }
}
# 2-segment fallbacks (z.B. /api/groups/<id>/members)
+238
View File
@@ -0,0 +1,238 @@
# Geraete-Offboarding: ein Geraet ueber Intune, Autopilot und Entra ID hinweg
# entfernen (Decommissioning). Vor dem Loeschen koennen BitLocker-/FileVault-Keys
# und LAPS-Passwoerter ausgelesen werden, damit sie nicht verloren gehen.
#
# Portiert aus dem "Device Offboarding Manager" (Ugur Koc, MIT-Lizenz) — dort
# WPF, hier als Web-Endpoints in den Intune Manager integriert.
#
# Loeschen ist destruktiv -> im Read-Only-Modus gesperrt, harte Bestaetigung im UI.
# Generischer Microsoft-Graph-$batch-Helper. Nimmt Sub-Requests
# ( @{ id; method; url; body?; headers? } ) und liefert eine Map id -> Response.
function Invoke-GraphBatch {
param([Parameter(Mandatory=$true)][object[]]$Requests)
$map = @{}
for ($i = 0; $i -lt $Requests.Count; $i += 20) {
$chunk = $Requests[$i .. [Math]::Min($i + 19, $Requests.Count - 1)]
$body = @{ requests = @($chunk) } | ConvertTo-Json -Depth 10 -Compress
$resp = Invoke-MgGraphRequestRetry -Uri 'https://graph.microsoft.com/beta/$batch' -Method POST -Body $body -ContentType 'application/json'
foreach ($r in @($resp.responses)) { $map[[string]$r.id] = $r }
}
return $map
}
# Graph-Fehlermeldung aus einer Exception ziehen (Body statt generischer Text).
function Get-OffboardGraphErr {
param($ErrorRecord)
$m = $ErrorRecord.Exception.Message
try { if ($ErrorRecord.ErrorDetails.Message) { $m = $ErrorRecord.ErrorDetails.Message } } catch {}
return $m
}
# Geraete suchen und ueber die drei Dienste hinweg aufloesen.
# Query: query=<text>, type=name|serial
function Search-OffboardDevicesEndpoint {
param([hashtable]$Query)
$err = Test-Connected
if ($err) { return $err }
$q = [string]$Query['query']
$type = [string]$Query['type']
if ([string]::IsNullOrWhiteSpace($q)) { return @{ __status = 400; error = 'Suchbegriff fehlt' } }
$qEsc = $q -replace "'", "''"
# managedDevices unterstuetzt startswith(deviceName) bzw. serialNumber eq.
$filter = if ($type -eq 'serial') { "serialNumber eq '$qEsc'" } else { "startswith(deviceName,'$qEsc')" }
$sel = 'id,deviceName,serialNumber,operatingSystem,osVersion,userPrincipalName,lastSyncDateTime,azureADDeviceId,managedDeviceOwnerType,managementAgent'
$uri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$filter=$([Uri]::EscapeDataString($filter))&`$select=$sel&`$top=50&`$orderby=deviceName"
try {
$intune = @(Get-GraphPaged -Uri $uri)
} catch {
$m = Get-OffboardGraphErr $_
if ($m -match '403|Forbidden') {
return @{ __status = 403; error = 'Keine Berechtigung fuer die Geraete-Suche. Scope "DeviceManagementManagedDevices.Read.All" muss in den Read/Write-Scopes stehen UND per Admin-Consent zugestimmt sein (siehe docs/App-Registration.md). Nach dem Ergaenzen: ab- und neu anmelden.' }
}
return @{ __status = 500; error = "Graph-Fehler bei der Suche: $m" }
}
if ($intune.Count -eq 0) { return @{ items = @(); count = 0 } }
# Entra-Objekt (fuer Delete) + Autopilot-Identity (fuer Delete) per Batch nachladen.
$reqs = @()
$idx = 0
foreach ($d in $intune) {
$aad = [string]$d.azureADDeviceId
$ser = [string]$d.serialNumber
if ($aad -and $aad -ne '00000000-0000-0000-0000-000000000000') {
$f = [Uri]::EscapeDataString("deviceId eq '$aad'")
$reqs += @{ id = "e$idx"; method = 'GET'; url = "/devices?`$filter=$f&`$select=id,deviceId,displayName,accountEnabled&`$top=1" }
}
if ($ser) {
$sf = [Uri]::EscapeDataString("contains(serialNumber,'$($ser -replace "'","''")')")
$reqs += @{ id = "a$idx"; method = 'GET'; url = "/deviceManagement/windowsAutopilotDeviceIdentities?`$filter=$sf&`$top=1" }
}
$idx++
}
$batch = if ($reqs.Count -gt 0) { Invoke-GraphBatch -Requests $reqs } else { @{} }
$items = @()
$idx = 0
foreach ($d in $intune) {
$entra = $null; $autop = $null
$er = $batch["e$idx"]; if ($er -and [int]$er.status -eq 200) { $entra = @($er.body.value)[0] }
$ar = $batch["a$idx"]; if ($ar -and [int]$ar.status -eq 200) { $autop = @($ar.body.value)[0] }
$items += [pscustomobject]@{
deviceName = [string]$d.deviceName
serialNumber = [string]$d.serialNumber
operatingSystem = [string]$d.operatingSystem
osVersion = [string]$d.osVersion
primaryUser = [string]$d.userPrincipalName
lastSync = $d.lastSyncDateTime
ownership = [string]$d.managedDeviceOwnerType
coManaged = ([string]$d.managementAgent -match 'configurationManager')
intuneDeviceId = [string]$d.id
azureADDeviceId = [string]$d.azureADDeviceId
entraObjectId = if ($entra) { [string]$entra.id } else { '' }
entraEnabled = if ($entra) { [bool]$entra.accountEnabled } else { $null }
autopilotId = if ($autop) { [string]$autop.id } else { '' }
inIntune = $true
inEntra = [bool]$entra
inAutopilot = [bool]$autop
}
$idx++
}
return @{ items = @($items); count = $items.Count }
}
# Recovery-Keys eines Geraets holen (vor dem Loeschen). Body:
# { intuneDeviceId, azureADDeviceId, operatingSystem }
function Get-OffboardKeysEndpoint {
param($Body)
$err = Test-Connected
if ($err) { return $err }
$intuneId = [string](Get-PolicyProp $Body 'intuneDeviceId')
$aad = [string](Get-PolicyProp $Body 'azureADDeviceId')
$os = [string](Get-PolicyProp $Body 'operatingSystem')
$bitlocker = @()
$fileVault = $null
$laps = $null
$notes = @()
if ($os -eq 'Windows' -and $aad) {
try {
$keyIds = @(Get-GraphPaged -Uri "https://graph.microsoft.com/beta/informationProtection/bitlocker/recoveryKeys?`$filter=deviceId eq '$aad'")
foreach ($k in $keyIds) {
try {
$kd = Invoke-MgGraphRequestRetry -Uri "https://graph.microsoft.com/beta/informationProtection/bitlocker/recoveryKeys/$($k.id)?`$select=key,volumeType" -Method GET
if ($kd.key) { $bitlocker += @{ volumeType = [string]$kd.volumeType; key = [string]$kd.key } }
} catch {}
}
if ($bitlocker.Count -eq 0) { $notes += 'Kein BitLocker-Key gefunden.' }
} catch {
$m = Get-OffboardGraphErr $_
$notes += if ($m -match '403') { 'BitLocker: Zugriff verweigert (BitlockerKey.Read.All noetig).' } else { "BitLocker: $m" }
}
}
elseif ($os -eq 'macOS' -and $intuneId) {
try {
$fv = Invoke-MgGraphRequestRetry -Uri "https://graph.microsoft.com/beta/deviceManagement/managedDevices('$intuneId')/getFileVaultKey" -Method GET
if ($fv.value) { $fileVault = [string]$fv.value } else { $notes += 'Kein FileVault-Key gefunden.' }
} catch { $notes += "FileVault: $(Get-OffboardGraphErr $_)" }
}
# LAPS (jede Plattform, ueber die Entra-Device-Id).
if ($aad) {
try {
$resp = Invoke-MgGraphRequestRetry -Uri "https://graph.microsoft.com/beta/directory/deviceLocalCredentials/$aad`?`$select=credentials" -Method GET
$creds = @($resp.credentials)
if ($creds.Count -gt 0) {
$latest = $creds | Sort-Object -Property backupDateTime -Descending | Select-Object -First 1
$pw = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String([string]$latest.passwordBase64))
$laps = @{ account = [string]$latest.accountName; password = $pw }
}
} catch {
$m = Get-OffboardGraphErr $_
if ($m -notmatch '404') { $notes += "LAPS: $m" }
}
}
return @{ ok = $true; keys = @{ bitlocker = @($bitlocker); fileVault = $fileVault; laps = $laps; notes = @($notes) } }
}
# Offboarding ausfuehren. Body:
# devices = [ { deviceName, intuneDeviceId, entraObjectId, autopilotId } ]
# services = { entra: 'delete'|'disable'|'none', intune: bool, autopilot: bool }
function Invoke-OffboardExecuteEndpoint {
param($Body)
$err = Test-Connected
if ($err) { return $err }
if ($script:State.ReadOnly) { return @{ __status = 403; error = 'Read-Only-Modus: Offboarding ist deaktiviert.' } }
$devices = @(Get-PolicyProp $Body 'devices') | Where-Object { $_ }
$svc = Get-PolicyProp $Body 'services'
if (@($devices).Count -eq 0) { return @{ __status = 400; error = 'Keine Geraete uebergeben' } }
$entraAction = [string](Get-PolicyProp $svc 'entra')
$doIntune = [bool](Get-PolicyProp $svc 'intune')
$doAutopilot = [bool](Get-PolicyProp $svc 'autopilot')
$reqs = @()
$meta = @{}
$n = 0
foreach ($d in $devices) {
$name = [string](Get-PolicyProp $d 'deviceName')
$iid = [string](Get-PolicyProp $d 'intuneDeviceId')
$eid = [string](Get-PolicyProp $d 'entraObjectId')
$aid = [string](Get-PolicyProp $d 'autopilotId')
if ($entraAction -eq 'delete' -and $eid) {
$rid = "r$n"; $reqs += @{ id = $rid; method = 'DELETE'; url = "/devices/$eid" }
$meta[$rid] = @{ name = $name; svc = 'Entra ID'; action = 'geloescht' }; $n++
} elseif ($entraAction -eq 'disable' -and $eid) {
$rid = "r$n"; $reqs += @{ id = $rid; method = 'PATCH'; url = "/devices/$eid"; body = @{ accountEnabled = $false }; headers = @{ 'Content-Type' = 'application/json' } }
$meta[$rid] = @{ name = $name; svc = 'Entra ID'; action = 'deaktiviert' }; $n++
}
if ($doIntune -and $iid) {
$rid = "r$n"; $reqs += @{ id = $rid; method = 'DELETE'; url = "/deviceManagement/managedDevices/$iid" }
$meta[$rid] = @{ name = $name; svc = 'Intune'; action = 'geloescht' }; $n++
}
if ($doAutopilot -and $aid) {
$rid = "r$n"; $reqs += @{ id = $rid; method = 'DELETE'; url = "/deviceManagement/windowsAutopilotDeviceIdentities/$aid" }
$meta[$rid] = @{ name = $name; svc = 'Autopilot'; action = 'geloescht' }; $n++
}
}
if ($reqs.Count -eq 0) { return @{ __status = 400; error = 'Keine ausfuehrbaren Aktionen (fehlende IDs oder nichts ausgewaehlt).' } }
Write-Host "[OFFBOARD] Fuehre $($reqs.Count) Aktion(en) fuer $(@($devices).Count) Geraet(e) aus..." -ForegroundColor Yellow
$batch = Invoke-GraphBatch -Requests $reqs
$results = @()
foreach ($rid in ($meta.Keys | Sort-Object { [int]($_ -replace '\D','') })) {
$m = $meta[$rid]
$r = $batch[$rid]
$status = if ($r) { [int]$r.status } else { 0 }
$ok = $status -in @(200, 204)
$errMsg = ''
if (-not $ok) {
$code = ''
try { $code = [string]$r.body.error.code } catch {}
if ($status -eq 403 -and $code -match 'multipleAdminApproval|protectedOperation') {
$errMsg = 'Erfordert Multi-Admin-Approval'
} elseif ($status -eq 403) {
$errMsg = "403 - fehlende Rolle/Berechtigung fuer $($m.svc)"
} elseif ($status -eq 0) {
$errMsg = 'Keine Antwort'
} else {
$errMsg = "HTTP $status" + $(if ($code) { " ($code)" } else { '' })
}
}
$results += [pscustomobject]@{
deviceName = $m.name; service = $m.svc; action = $m.action
success = $ok; error = $errMsg
}
}
$okCount = @($results | Where-Object { $_.success }).Count
Write-Host " -> $okCount/$($results.Count) erfolgreich" -ForegroundColor DarkGray
return @{ ok = $true; successCount = $okCount; total = $results.Count; results = @($results) }
}