Featured image of post PowerShell oder Microsoft Graph? Die passende Schnittstelle wählen und Modulkonflikte beheben

PowerShell oder Microsoft Graph? Die passende Schnittstelle wählen und Modulkonflikte beheben

Wann Exchange-/PnP-PowerShell, wann Microsoft Graph, und wie du Assembly-Konflikte zwischen Graph SDK, PnP und EXO erkennst und vermeidest.

Hinter „Machen wir das mit PowerShell oder mit Graph?“ stecken zwei Fragen: Welche Schnittstelle bietet überhaupt, was ich brauche? und Wie läuft das Ding in sechs Monaten, wenn keiner mehr draufschaut? Und wer schon einmal das Graph-PowerShell-SDK mit PnP.PowerShell oder dem Exchange-Online-Modul in einem Skript kombiniert hat, kennt die dritte Frage, die erst gestellt wird, wenn es knallt: Laufen diese Module überhaupt zusammen?

Kurz gesagt

  • Exchange Online PowerShell für Exchange-Administration (Postfachberechtigungen, Transportregeln, Organisationseinstellungen). Graph deckt das größtenteils nicht ab.
  • Microsoft Graph für Identitäten, dienstübergreifende Daten und alles, was eine Anwendung tut: Integrationen, Mailversand, Reporting. Für den Postfachzugriff durch Anwendungen ist Graph die Schnittstelle, auf die du baust.
  • Die klassische Falle beim Kombinieren: Assembly-Konflikte (Microsoft.Identity.Client, Microsoft.Graph.Core, Azure.Core …) zwischen Microsoft.Graph, PnP.PowerShell, ExchangeOnlineManagement und Az. Die Verbindungsreihenfolge ist Glückssache; Prozess-Isolation und weniger Module sind die eigentliche Lösung.

Wofür du dich tatsächlich entscheidest

Das Microsoft-Graph-PowerShell-SDK ist PowerShell und ruft unter der Haube Graph auf. Gewählt wird also zwischen zwei Arten von Schnittstellen:

  1. Dienstspezifische Admin-Module: ExchangeOnlineManagement, MicrosoftTeams, PnP.PowerShell für SharePoint. Sie sprechen mit den Admin-Schnittstellen eines Workloads und bieten Dinge, die es in Graph schlicht nicht gibt.
  2. Microsoft Graph: eine REST-API für Entra ID, Mail, Kalender, Dateien, Teams, Planner und vieles mehr, aufrufbar von überall: Graph-PowerShell-SDK, Invoke-RestMethod, C#, Python, Logic Apps oder ein Ticketsystem.

Welches von beiden passt, hängt zuerst davon ab, welche Schnittstelle das überhaupt kann.

Wo was gewinnt

AufgabeMeine WahlWarum
Full Access / Send As, Transportregeln, organisationsweite Exchange-EinstellungenExchange Online PowerShellDafür gibt es keine Graph-Endpunkte. Die neue Exchange Online Admin API (Preview) deckt nur einen kleinen Ausschnitt ab, etwa Ordnerberechtigungen und Send on Behalf.
Benutzer, Gruppen, Lizenzen, App-Registrierungen in Entra IDGraph (SDK oder REST)Das ist Graphs Heimspiel.
Eine Anwendung sendet oder liest Mails (Ticketsystem, Workflows)GraphsendMail bzw. Message-Endpunkte mit OAuth, eingegrenzt über RBAC for Applications.
Site-Provisioning, Templates, SharePoint-BerechtigungenPnP.PowerShellFür Admin-Aufgaben deutlich mächtiger als Graphs SharePoint-Schnittstelle.
Teams-Richtlinien (Besprechungen, Telefonie, Messaging)MicrosoftTeams-ModulRichtlinien leben in der Teams-Admin-Schnittstelle, nicht in Graph.
Dienstübergreifendes Reporting (Benutzer + Lizenzen + Anmeldungen + Gruppen)GraphEine API, ein Auth-Modell, Paging und $select inklusive.

Meine Faustregel: Führt ein Mensch es aus, nimm das Modul, das am nächsten am Workload ist. Führt eine Anwendung es aus, nimm Graph – und gib dieser Anwendung genau die Rechte, die sie braucht, und keine mehr.

Praxisbeispiel: Mail-Integrationen

Der LinkedIn-Post, der diesen Artikel angestoßen hat, beschreibt die Modernisierung einer ServiceNow-Mailintegration, die Mails jetzt über Microsoft Graph sendet und empfängt. Hier arbeitet eine Anwendung mit Postfächern, kein Mensch, und genau da passt Graph: OAuth, granulare Berechtigungen und ein sauberer Audit-Trail. Eine Mail zu senden ist ein einziger Aufruf:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Microsoft Graph v1.0 – send mail as the service mailbox (app-only)
# Required application permission: Mail.Send (scoped via RBAC for Applications, see below)
$message = @{
    message = @{
        subject      = 'Ticket INC0012345 updated'
        body         = @{ contentType = 'Text'; content = 'Your ticket has been updated.' }
        toRecipients = @(@{ emailAddress = @{ address = 'adele.vance@contoso.com' } })
    }
    saveToSentItems = $true
}

Invoke-MgGraphRequest -Method POST `
    -Uri 'https://graph.microsoft.com/v1.0/users/servicedesk@contoso.com/sendMail' `
    -Body ($message | ConvertTo-Json -Depth 10) -ContentType 'application/json'

Was ich aus Sicht regulierter Umgebungen ergänzen würde: Eine App mit der Anwendungsberechtigung Mail.Send darf standardmäßig als jedes Postfach im Tenant senden. Das möchtest du keinem Auditor erklären. Mit RBAC for Applications in Exchange Online grenzt du den Service Principal auf die Postfächer ein, die er wirklich braucht:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Exchange Online PowerShell – scope a ticketing app to its service mailboxes only
# Prerequisite: the Entra app has NO tenant-wide Mail.Send consent (otherwise permissions add up)
# IDs from "Enterprise applications" in Entra, not from "App registrations"
New-ServicePrincipal -AppId '<app-id>' -ObjectId '<service-principal-object-id>' -DisplayName 'Ticketing'

New-ManagementScope -Name 'Ticketing-Mailboxes' `
    -RecipientRestrictionFilter "CustomAttribute10 -eq 'Ticketing'"

New-ManagementRoleAssignment -App '<service-principal-object-id>' `
    -Role 'Application Mail.Send' `
    -CustomResourceScope 'Ticketing-Mailboxes'

# Verify before you go live
Test-ServicePrincipalAuthorization -Identity '<service-principal-object-id>' -Resource 'servicedesk@contoso.com'

Die Ironie dabei: Graph-Berechtigungen konfigurierst du mit Exchange-PowerShell. Genau das meint der Original-Post mit „beides zusammen“.

Modulkonflikte zwischen Graph, PnP und EXO

Angenommen, du schreibst ein völlig vernünftiges Skript: Graph für Benutzerdaten, PnP für SharePoint, EXO für Postfächer. Auf deinem Rechner läuft es. In der nächsten Sitzung scheitert es dann mit etwas wie:

1
2
Could not load type 'Microsoft.Identity.Client.BaseAbstractApplicationBuilder`1'
from assembly 'Microsoft.Identity.Client, Version=4.50.0.0'

oder

1
Method not found: '!0 Microsoft.Identity.Client.BaseAbstractApplicationBuilder`1.WithLogging(...)'

Warum das passiert

Jedes dieser Module ist ein Binärmodul und bringt eigene Kopien gemeinsam genutzter .NET-Bibliotheken mit – allen voran MSAL (Microsoft.Identity.Client.dll), aber auch Azure.Core, Azure.Identity, Microsoft.Graph.Core oder System.Text.Json. PowerShell lädt Assemblies pro Prozess in einen gemeinsamen Kontext, und dort kann von einer Assembly mit gegebenem Namen nur eine Version geladen sein. Wer zuerst lädt, gewinnt; das andere Modul läuft dann gegen eine Version, für die es nie gebaut wurde.

Microsofts eigene PowerShell-Dokumentation beschreibt genau das: Das Ergebnis hängt davon ab, welches Modul zuerst geladen wurde, welcher Codepfad lief und ob die Abhängigkeit beim Import oder erst bei der ersten Nutzung geladen wird. Deshalb funktioniert dasselbe Skript mal, mal nicht, je nachdem, was du vorher in der Sitzung gemacht hast.

Bekannte Kombinationen, die Ärger gemacht haben:

  • PnP.PowerShell + Microsoft.Graph: Ältere PnP-Versionen brachten ein veraltetes Microsoft.Graph.Core (1.25.x) und ältere MSAL-Versionen mit, wodurch Connect-MgGraph und Invoke-MgGraphRequest nach dem Laden von PnP scheiterten.
  • ExchangeOnlineManagement + Microsoft.Graph: eine lange Geschichte. EXO 3.5.1 brachte einen Konflikt mit System.Text.Json; im Juni 2026 berichtete Tony Redmond, dass mit Graph SDK 2.38 und EXO 3.10 in PowerShell 7 keine Verbindungsreihenfolge zuverlässig funktionierte: Die eine scheitert mit dem Method not found oben, die andere mit einer NullReferenceException beim Token-Abruf.
  • Az.Accounts + Microsoft.Graph: dasselbe Muster mit Azure.Core / Azure.Identity.

So erkennst du es

Erste Prüfung: Was ist in der aktuellen Sitzung tatsächlich geladen?

1
2
3
4
5
6
7
8
# Which version of the usual suspects is loaded in this session – and from which module folder?
$suspects = 'Microsoft.Identity.Client', 'Microsoft.Graph.Core', 'Azure.Core', 'Azure.Identity', 'System.Text.Json'

[System.AppDomain]::CurrentDomain.GetAssemblies() |
    Where-Object { $_.GetName().Name -in $suspects } |
    Select-Object @{ n = 'Assembly'; e = { $_.GetName().Name } },
                  @{ n = 'Version';  e = { $_.GetName().Version } },
                  Location

Zeigt Location in den PnP-Ordner, während du Connect-MgGraph aufrufst, hast du den Übeltäter.

Zweite Prüfung: Welche Versionen bringen deine installierten Module mit? Diese Inventur macht Konflikte sichtbar, bevor sie in Produktion zuschlagen:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
$modules = 'Microsoft.Graph.Authentication', 'PnP.PowerShell', 'ExchangeOnlineManagement', 'Az.Accounts', 'MicrosoftTeams'
$dlls    = 'Microsoft.Identity.Client.dll', 'Microsoft.Graph.Core.dll', 'Azure.Core.dll', 'Azure.Identity.dll'

Get-Module -ListAvailable -Name $modules | ForEach-Object {
    $module = $_
    Get-ChildItem -Path $module.ModuleBase -Recurse -Include $dlls -File -ErrorAction SilentlyContinue |
        ForEach-Object {
            [pscustomobject]@{
                Assembly = $_.BaseName
                Version  = [System.Reflection.AssemblyName]::GetAssemblyName($_.FullName).Version
                Module   = "$($module.Name) $($module.Version)"
                Path     = $_.FullName
            }
        }
} | Sort-Object Assembly, Version | Format-Table Assembly, Version, Module -AutoSize

Manche Module enthalten getrennte Builds für .NET Framework und .NET, du siehst also eventuell zwei Einträge pro Modul. Spannend wird es, wenn dieselbe Assembly modulübergreifend in unterschiedlichen Versionen auftaucht.

So löst du es

In dieser Reihenfolge:

1. Lade nicht, was du nicht brauchst. Ist PnP.PowerShell ohnehin verbunden, brauchst du das Graph-SDK oft gar nicht. Invoke-PnPGraphMethod ruft Graph über die PnP-Verbindung auf (sofern deine PnP-App-Registrierung die nötigen Graph-Berechtigungen hat). Und wenn du das SDK brauchst: Microsoft.Graph.Authentication plus die ein, zwei Submodule installieren, die du wirklich nutzt (oder nur Invoke-MgGraphRequest), statt des kompletten Metamoduls Microsoft.Graph.

2. Pro Prozess isolieren. Assemblies gelten pro Prozess, getrennte Prozesse können also nicht kollidieren. In PowerShell 7 läuft Start-Job in einem Kindprozess:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Graph work in its own process – app-only auth with a certificate
# Required application permission: User.Read.All
$auth = @{ ClientId = '<app-id>'; TenantId = 'contoso.onmicrosoft.com'; Thumbprint = '<cert-thumbprint>' }

$users = Start-Job -ArgumentList $auth -ScriptBlock {
    param($a)
    Connect-MgGraph -ClientId $a.ClientId -TenantId $a.TenantId -CertificateThumbprint $a.Thumbprint -NoWelcome
    Get-MgUser -Filter 'accountEnabled eq true' -All -Property id, userPrincipalName, department |
        Select-Object Id, UserPrincipalName, Department
} | Receive-Job -Wait -AutoRemoveJob

# Main session: PnP only – Graph's assemblies were never loaded here
Connect-PnPOnline -Url 'https://contoso.sharepoint.com/sites/hr' `
    -ClientId $auth.ClientId -Tenant $auth.TenantId -Thumbprint $auth.Thumbprint

Der Job liefert deserialisierte Objekte zurück (Eigenschaften ja, Methoden nein), genau richtig, um Daten zwischen Schritten weiterzureichen. Wichtig: Start-ThreadJob und ForEach-Object -Parallel isolieren nicht. Sie laufen im selben Prozess und teilen sich die geladenen Assemblies.

3. Runbooks und Pipelines aufteilen. In Azure Automation getrennte Runtime Environments (PowerShell 7.4) pro Modulkombination statt einer „Alles drin“-Umgebung. In CI/CD ein Job pro Workload. Das bringt nebenbei sauberere Berechtigungen pro Identität, also Least Privilege gleich mit.

4. Versionen bewusst pinnen. #Requires -Modules @{ ModuleName = 'ExchangeOnlineManagement'; RequiredVersion = '<tested-version>' } am Skriptanfang macht aus „nach dem Update kaputt“ ein „startet gar nicht erst“ – der deutlich bessere Fehlerfall. Module gezielt aktualisieren, nach einem Smoke-Test, der jede genutzte Modulkombination importiert und verbindet.

5. Notlösung: Verbindungsreihenfolge und DLL-Tausch. Eine bestimmte Reihenfolge beim Verbinden oder eine neuere Microsoft.Identity.Client.dll in den Ordner eines anderen Moduls kopieren: Beides kursiert in der Community. Das hilft vielleicht über einen Tag, ist aber nicht supportet, wird beim nächsten Update-Module überschrieben und hört, wie der EXO/Graph-Fall von 2026 zeigt, ohne Vorwarnung auf zu funktionieren. Produktive Automatisierung würde ich darauf nicht bauen.

Weitere Fallstricke

  • Windows PowerShell 5.1 und PowerShell 7 verhalten sich unterschiedlich. .NET Framework und .NET laden Assemblies anders; eine Kombination, die in 7 scheitert, kann in 5.1 funktionieren und umgekehrt. PnP.PowerShell 3.x setzt ohnehin PowerShell 7.4 oder neuer voraus – „dann eben 5.1“ ist für SharePoint keine Option.
  • -DisableWAM ist keine Lösung. Seit EXO 3.7.0 ist der Web Account Manager der Standard-Broker für interaktive Anmeldungen. Connect-ExchangeOnline -DisableWAM umgeht Konflikte manchmal, Microsoft beschreibt es aber ausdrücklich als vorübergehende Maßnahme.
  • App-only schlägt interaktiv. Zertifikatsbasierte Anmeldung umgeht die meisten WAM-/Broker-Probleme und ist für geplante Automatisierung ohnehin das Mittel der Wahl.
  • Berechtigungen addieren sich. Bei RBAC for Applications wirken Entra-Consent und Exchange-Rollenzuweisung additiv. Ein tenantweiter Mail.Send-Consent in Entra ID hebelt deine eingegrenzte Zuweisung stillschweigend aus.
  • Die Exchange Online Admin API ist Preview und bewusst schmal. Sie ergänzt EXO PowerShell, ersetzt es aber nicht.

Fazit

Die Admin-Module liefern die Tiefe des jeweiligen Workloads, Graph gibt Anwendungen einen einheitlichen, nachvollziehbaren Zugang und ist damit der natürliche Ort für jede Postfach-Integration. Wenn du Module kombinierst, behandle sie wie Komponenten mit Abhängigkeiten: weniger laden, pro Prozess isolieren, Versionen pinnen und Updates testen, bevor deine Runbooks es für dich tun.

Quellen

Inspiriert von einem LinkedIn-Post von Anthony Samuel.