Zum Inhalt
Kaffeeundcode

Sektion 51: Graph API: Geräte und Benutzer gezielt abfragen

Nicht konforme Geräte, veraltete Syncs, Benutzer mit Abteilung: So fragst du Intune und Entra ID per Graph gezielt ab und baust einen CSV-Bericht.

Intune-Kurs · Teil 51 von 90 · Block G: Scripting & Automatisierung

Was du in dieser Sektion lernst

  • Intune-Geräte, Entra-Geräteobjekte und Benutzer sauber auseinanderhalten
  • Mit $select und $filter nur die Daten holen, die du brauchst
  • Erweiterte Abfragen mit ConsistencyLevel: eventual und $count
  • Einen Gerätebericht mit Benutzerdaten als CSV bauen und Anfragen bündeln

ca. 11 Min. Lesezeit · Alle Sektionen im Überblick

In Sektion 50 hast du dich bei Microsoft Graph angemeldet und deine erste Geräteliste gezogen. Jetzt wird es konkret: Welche Geräte sind nicht konform? Welche haben sich seit Wochen nicht gemeldet? Wem gehören sie, in welcher Abteilung sitzt die Person, und ist ihr Konto überhaupt noch aktiv?

Genau diese Fragen landen im Alltag ständig bei dir, meist kurz vor einem Audit oder wenn die Geschäftsführung eine Liste will. Im Portal klickst du dich dafür durch mehrere Ansichten. Mit Graph beantwortest du sie mit ein paar gezielten Abfragen und hast am Ende eine CSV-Datei, die du einfach wieder erzeugen kannst.

Grundlagen: Drei Arten von Objekten

Bevor du abfragst, musst du wissen, wo die Daten liegen. Für Geräte gibt es in Graph zwei verschiedene Sammlungen, und das sorgt am Anfang oft für Verwirrung. Den Hintergrund kennst du aus Sektion 02.

  • Intune-Geräte unter /deviceManagement/managedDevices: alles, was Intune verwaltet, mit Compliance-Status, letzter Synchronisierung, Seriennummer und primärem Benutzer. Laut Microsoft Learn brauchst du dafür DeviceManagementManagedDevices.Read.All.
  • Entra-Geräteobjekte unter /devices: die Identität des Geräts im Verzeichnis, mit Vertrauenstyp und ungefährer letzter Anmeldung. Hier reicht laut Microsoft Learn Device.Read.All.
  • Benutzer unter /users: Name, Abteilung, Kontostatus. Für Felder wie department brauchst du User.Read.All, User.ReadBasic.All liefert nur Basisdaten.

Die Brücke zwischen beiden Gerätewelten ist die Eigenschaft azureADDeviceId am Intune-Gerät. Sie entspricht laut Ressourcenbeschreibung der deviceId des Entra-Objekts, nicht dessen id. Wer hier die falsche ID nimmt, findet nichts und wundert sich.

Gezielt abfragen: $select und $filter

$select: nur die Felder, die du brauchst

Ein einzelnes Intune-Gerät liefert ohne Einschränkung weit über 50 Eigenschaften. Mit $select holst du nur das, was du wirklich auswertest. Das macht die Antwort kleiner, das Skript schneller und die Ausgabe lesbarer. Die Übersicht zu Abfrageparametern erklärt alle Optionen.

$filter: Was Intune-Geräte unterstützen

Nicht jede Eigenschaft lässt sich filtern. Die Ressourcenbeschreibung von managedDevice nennt es pro Feld. Zwei Beispiele, die du ständig brauchst:

  • complianceState unterstützt eq und or, zum Beispiel complianceState eq 'noncompliant'.
  • lastSyncDateTime und enrolledDateTime unterstützen lt und gt, zum Beispiel lastSyncDateTime lt 2026-10-01T00:00:00Z.

Für Felder ohne dokumentierte Filterunterstützung holst du die Daten mit $select und filterst lokal mit Where-Object. Das ist bei ein paar tausend Geräten kein Problem und zuverlässiger, als auf einen Filter zu hoffen, der still ignoriert wird.

Praxis 1: Anmelden und eine Hilfsfunktion für alle Seiten

Alle Beispiele in dieser Sektion lesen nur. Du meldest dich deshalb ausschließlich mit Read-Berechtigungen an. Die Funktion Get-GraphAll folgt dem @odata.nextLink, bis alle Seiten da sind. Das Prinzip kennst du aus Sektion 50, laut Microsoft Learn solltest du den Link immer vollständig übernehmen und nicht selbst zusammenbauen.

CodeKlick markiert alles · Strg+C kopiert
# Nur lesende Berechtigungen für diese Sektion
Connect-MgGraph -Scopes 'DeviceManagementManagedDevices.Read.All',
                        'User.Read.All',
                        'Device.Read.All' -NoWelcome

# Hilfsfunktion: holt alle Seiten einer Abfrage
function Get-GraphAll {
    param(
        [Parameter(Mandatory)][string]$Uri,
        [hashtable]$Headers = @{}
    )
    $ergebnis = [System.Collections.Generic.List[object]]::new()
    while ($Uri) {
        $antwort = Invoke-MgGraphRequest -Method GET -Uri $Uri -Headers $Headers -OutputType PSObject
        foreach ($eintrag in $antwort.value) { $ergebnis.Add($eintrag) }
        $Uri = $antwort.'@odata.nextLink'
    }
    $ergebnis
}

Neu ist der Parameter -Headers. Den brauchst du gleich für erweiterte Abfragen auf Benutzer und Entra-Geräte.

Praxis 2: Nicht konforme und veraltete Intune-Geräte

Das folgende Skript beantwortet zwei typische Fragen: Welche Geräte sind gerade nicht konform, und welche haben sich seit 30 Tagen nicht mehr synchronisiert?

CodeKlick markiert alles · Strg+C kopiert
$basis  = 'https://graph.microsoft.com/v1.0/deviceManagement/managedDevices'
$felder = 'deviceName,userPrincipalName,operatingSystem,osVersion,complianceState,lastSyncDateTime'

# 1) Alle nicht konformen Geräte
$filter = [uri]::EscapeDataString("complianceState eq 'noncompliant'")
$nichtKonform = @(Get-GraphAll -Uri "$basis`?`$filter=$filter&`$select=$felder")

# 2) Geräte ohne Synchronisierung seit 30 Tagen
$stichtag = (Get-Date).ToUniversalTime().AddDays(-30).ToString('yyyy-MM-ddTHH:mm:ssZ')
$filter   = [uri]::EscapeDataString("lastSyncDateTime lt $stichtag")
$veraltet = @(Get-GraphAll -Uri "$basis`?`$filter=$filter&`$select=$felder")

"{0} nicht konform, {1} seit 30 Tagen ohne Sync" -f $nichtKonform.Count, $veraltet.Count
$veraltet | Sort-Object lastSyncDateTime |
    Format-Table deviceName, userPrincipalName, operatingSystem, lastSyncDateTime
  • [uri]::EscapeDataString() kodiert Leerzeichen und Hochkommas im Filter. So gibt es keine Überraschungen mit Sonderzeichen in der URL.
  • Das Backtick-Zeichen vor ? und $ verhindert, dass PowerShell $filter oder $select als eigene Variable auswertet.
  • Datumswerte gibst du in UTC im ISO-8601-Format an, ohne Anführungszeichen.
  • @() um den Aufruf sorgt dafür, dass .Count auch bei genau einem Treffer stimmt.

Die Zahlen aus diesem Skript passen gut zu den Compliance-Berichten aus Sektion 30. Der Unterschied: Hier kannst du sie jeden Morgen automatisch erzeugen und weiterverarbeiten.

Praxis 3: Benutzer und Entra-Geräte mit erweiterten Abfragen

Bei Verzeichnisobjekten wie Benutzern und Entra-Geräten funktionieren manche Filter nur als erweiterte Abfrage. Dazu gehören laut Microsoft Learn unter anderem endsWith, ne, not sowie $filter und $orderby in derselben Anfrage. Dafür setzt du den Header ConsistencyLevel: eventual und hängst $count=true an.

CodeKlick markiert alles · Strg+C kopiert
# Erweiterte Abfragen brauchen diesen Header UND $count=true
$erweitert = @{ ConsistencyLevel = 'eventual' }

# Alle aktiven Benutzer einer Domäne
$filter   = [uri]::EscapeDataString("endsWith(userPrincipalName,'@contoso.com') and accountEnabled eq true")
$felder   = 'id,displayName,userPrincipalName,department,jobTitle'
$benutzer = @(Get-GraphAll -Uri "https://graph.microsoft.com/v1.0/users?`$filter=$filter&`$select=$felder&`$count=true&`$top=999" -Headers $erweitert)
"{0} aktive Benutzer in contoso.com" -f $benutzer.Count

# Entra-Geräteobjekte, die sich seit 90 Tagen nicht angemeldet haben
$stichtag = (Get-Date).ToUniversalTime().AddDays(-90).ToString('yyyy-MM-ddTHH:mm:ssZ')
$filter   = [uri]::EscapeDataString("approximateLastSignInDateTime le $stichtag")
$felder   = 'displayName,deviceId,operatingSystem,trustType,approximateLastSignInDateTime'
$alt      = @(Get-GraphAll -Uri "https://graph.microsoft.com/v1.0/devices?`$filter=$filter&`$select=$felder&`$count=true" -Headers $erweitert)
$alt | Sort-Object approximateLastSignInDateTime |
    Format-Table displayName, operatingSystem, trustType, approximateLastSignInDateTime
  • endsWith funktioniert laut Dokumentation nur bei mail, otherMails, userPrincipalName und proxyAddresses.
  • $top=999 ist die maximale Seitengröße bei Benutzern, Standard sind 100. Fragst du signInActivity ab, sind es höchstens 500. Dafür brauchst du laut Microsoft Learn zusätzlich AuditLog.Read.All und eine Lizenz für Entra ID P1 oder P2.
  • approximateLastSignInDateTime ist, wie der Name sagt, ungefähr. Für eine Bereinigungsliste reicht das, als exakter Zeitstempel taugt es nicht.
  • eventual bedeutet: Der Index kann wenige Momente hinter dem aktuellen Stand liegen. Ein gerade angelegtes Konto taucht eventuell erst kurz später auf.

Vorsicht beim Aufräumen: Ein Entra-Gerät ohne Anmeldung seit 90 Tagen ist ein Kandidat zum Prüfen, kein automatischer Löschfall. Für Gruppen, die du darauf aufbaust, sind dynamische Regeln aus Sektion 07 oft die bessere Wahl.

Praxis 4: Gerätebericht mit Benutzerdaten als CSV

Jetzt verbindest du beide Welten. Das Skript lädt alle Intune-Geräte und alle Benutzer jeweils einmal und verknüpft sie lokal über den Benutzerprinzipalnamen. Das ist deutlich schneller, als für jedes Gerät einzeln nach dem Benutzer zu fragen.

CodeKlick markiert alles · Strg+C kopiert
# Intune-Geräte und Benutzerdaten zu einem Bericht verbinden
$geraete = @(Get-GraphAll -Uri ('https://graph.microsoft.com/v1.0/deviceManagement/managedDevices' +
    '?$select=deviceName,serialNumber,userPrincipalName,operatingSystem,osVersion,complianceState,lastSyncDateTime,azureADDeviceId'))

$benutzer = @(Get-GraphAll -Uri 'https://graph.microsoft.com/v1.0/users?$select=userPrincipalName,displayName,department,accountEnabled&$top=999')

# Nachschlagetabelle: UPN -> Benutzer (Hashtable vergleicht Schlüssel ohne Groß-/Kleinschreibung)
$nachUpn = @{}
foreach ($b in $benutzer) { if ($b.userPrincipalName) { $nachUpn[$b.userPrincipalName] = $b } }

$bericht = foreach ($g in $geraete) {
    $b = if ($g.userPrincipalName) { $nachUpn[$g.userPrincipalName] } else { $null }
    [pscustomobject]@{
        Geraet        = $g.deviceName
        Seriennummer  = $g.serialNumber
        Betriebssystem = '{0} {1}' -f $g.operatingSystem, $g.osVersion
        Compliance    = $g.complianceState
        LetzterSync   = $g.lastSyncDateTime
        Benutzer      = if ($b) { $b.displayName } else { '(kein Benutzer)' }
        Abteilung     = if ($b) { $b.department } else { '' }
        KontoAktiv    = if ($b) { $b.accountEnabled } else { '' }
        EntraGeraeteId = $g.azureADDeviceId
    }
}

$datei = Join-Path $env:TEMP ('Intune-Geraetebericht_{0:yyyy-MM-dd}.csv' -f (Get-Date))
$bericht | Export-Csv -Path $datei -Delimiter ';' -Encoding utf8 -NoTypeInformation
"{0} Geräte exportiert nach {1}" -f @($bericht).Count, $datei

# Auffällig: Gerät gehört zu einem deaktivierten Konto
$bericht | Where-Object { $_.KontoAktiv -eq $false } | Format-Table Geraet, Benutzer, LetzterSync
  • Hashtable statt Schleife in der Schleife: Das Nachschlagen per UPN dauert konstant kurz, egal wie viele Benutzer du hast. PowerShell-Hashtables vergleichen Schlüssel standardmäßig ohne Groß- und Kleinschreibung, unterschiedlich geschriebene UPNs passen also trotzdem zusammen.
  • Geräte ohne Benutzer wie Kiosk-Systeme oder mit Self-Deploying bereitgestellte Geräte bekommen einen eigenen Eintrag statt eines Fehlers.
  • Semikolon und UTF-8: -Delimiter ';' sorgt dafür, dass ein deutsches Excel die Spalten direkt trennt, -Encoding utf8 hält die Umlaute intakt.
  • Der Abschluss zeigt Geräte, deren Benutzerkonto deaktiviert ist. Das sind oft Rechner ausgeschiedener Kollegen, die noch eingesammelt werden müssen.

Praxis 5: Viele Einzelabfragen bündeln mit $batch

Manchmal brauchst du nur ein paar bestimmte Benutzer, zum Beispiel aus einer Ticketliste. Statt für jeden eine eigene Anfrage zu schicken, bündelst du laut Microsoft Learn bis zu 20 Anfragen in einem einzigen Aufruf an /$batch.

CodeKlick markiert alles · Strg+C kopiert
# Bis zu 20 Anfragen in einem einzigen HTTP-Aufruf
$upns = 'adele.vance@contoso.com', 'alex.wilber@contoso.com', 'megan.bowen@contoso.com'

$anfragen = for ($i = 0; $i -lt $upns.Count; $i++) {
    @{
        id     = "$($i + 1)"
        method = 'GET'
        url    = "/users/$($upns[$i])?`$select=displayName,department,accountEnabled"
    }
}
$koerper = @{ requests = @($anfragen) } | ConvertTo-Json -Depth 5

$antwort = Invoke-MgGraphRequest -Method POST -Uri 'https://graph.microsoft.com/v1.0/$batch' `
    -Body $koerper -ContentType 'application/json' -OutputType PSObject

# Jede Teilantwort hat ihren eigenen Statuscode, die Reihenfolge ist nicht garantiert
foreach ($r in $antwort.responses | Sort-Object { [int]$_.id }) {
    $upn = $upns[[int]$r.id - 1]
    if ($r.status -eq 200) {
        '{0}: {1}, {2}' -f $upn, $r.body.displayName, $r.body.department
    } else {
        '{0}: Fehler {1}' -f $upn, $r.status
    }
}
  • Die id jeder Anfrage verbindet sie mit ihrer Antwort. Die Antworten können in anderer Reihenfolge zurückkommen, deshalb sortiert das Skript nach id.
  • Ein 200 für den Batch heißt nicht, dass jede Teilanfrage geklappt hat. Prüfe immer den status jeder einzelnen Antwort, im Beispiel liefert ein unbekannter Benutzer 404.
  • Drosselung gilt pro Teilanfrage: Jede wird einzeln gegen die Limits gezählt und kann mit 429 abgelehnt werden. Wie du darauf reagierst, steht in der Dokumentation zur Drosselung.
  • Mehr als 20 Einträge teilst du in Pakete zu je 20 auf.

Troubleshooting: Typische Fehler

Request_UnsupportedQuery

  • Du nutzt endsWith, ne oder not ohne ConsistencyLevel: eventual und $count=true. Beides gehört zusammen.
  • Die Eigenschaft ist schlicht nicht filterbar. Dann hilft auch die erweiterte Abfrage nicht, also lokal filtern.

Leere Ergebnisse, obwohl Geräte da sind

  • Du suchst ein Intune-Gerät unter /devices oder umgekehrt.
  • Du verknüpfst azureADDeviceId mit der id des Entra-Objekts statt mit dessen deviceId.
  • Der Filterwert ist falsch geschrieben, etwa 'NonCompliant' statt 'noncompliant'. Nimm die Werte genau so, wie die Ressourcenbeschreibung sie auflistet.

Felder sind leer

  • department bleibt leer, wenn du nur User.ReadBasic.All hast oder das Feld im Verzeichnis nicht gepflegt ist.
  • Die Eigenschaft fehlt in deinem $select. Graph liefert nur, was du anforderst.

$count ist nicht in der Antwort

  • Ohne ConsistencyLevel: eventual ignoriert Graph $count=true bei Verzeichnisobjekten laut Microsoft Learn einfach, statt einen Fehler zu melden. Prüfe also, ob deine Parameter wirklich gewirkt haben.

Best Practices

  • Nur lesende Berechtigungen für Berichte. Ein Berichtsskript braucht nie ReadWrite.
  • Immer $select setzen und Paging einbauen, auch wenn dein Testmandant nur zehn Geräte hat.
  • Große Datenmengen einmal laden und lokal verknüpfen, statt in einer Schleife pro Gerät nachzufragen.
  • Filterwerte und Operatoren aus der Ressourcenbeschreibung übernehmen. Was dort nicht steht, lokal mit Where-Object filtern.
  • Berichte mit Datum im Dateinamen ablegen. So kannst du Entwicklungen über Wochen vergleichen.
  • Personenbezogene Daten im Bericht sparsam halten und die CSV nicht offen auf einem Netzlaufwerk liegen lassen.

Fazit

Mit $select, $filter, den erweiterten Abfragen und etwas lokaler Logik wird aus Graph ein Werkzeug, das dir in Sekunden die Listen liefert, für die du im Portal Minuten brauchst. Der wichtigste Schritt ist, die drei Objektarten auseinanderzuhalten und sauber zu verknüpfen.

Zusammenfassend für deinen Workflow:

  • Klären, ob du Intune-Geräte, Entra-Geräte oder Benutzer brauchst, und die passende Read-Berechtigung wählen.
  • Filterbare Felder in der Ressourcenbeschreibung nachsehen, alles andere lokal filtern.
  • Für endsWith, ne und not den Header ConsistencyLevel: eventual plus $count=true setzen.
  • Daten einmal laden, per Hashtable verknüpfen und als CSV mit Datum exportieren.

In der nächsten Sektion gehst du vom Lesen zum Schreiben: Du erstellst Richtlinien automatisiert per Graph API und weist sie zu. Einen Überblick über alle Sektionen findest du auf der Seite Intune-Kurs.

Quellen


↑ Zur Kursübersicht: Intune-Kurs (alle Sektionen)