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:
@@ -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)
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
Reference in New Issue
Block a user