はじめに
こんな業務を毎月手作業でやっていませんか。
- ライセンス未割り当てユーザーをGUIで1件ずつ確認している
- 外部ゲストの最終サインイン確認をスプレッドシートに手転記している
- メッセージセンターの通知を毎回GUIで読んで重要度を判断している
- 条件付きアクセスポリシーをExcelに手作業でまとめている
外資系ITコンサルとしてModern Workplace案件を複数担当している筆者は、これらの作業をすべてMicrosoft Graph PowerShell SDKで自動化しています。数千ユーザー規模のテナントではGUI中心の運用は限界があり、属人化・ミス・工数増加の温床になります。
本記事では現場でそのまま使っているスクリプトを6本紹介します。Graph APIの概念的な解説はしがないblogに書いているので、ここはコードと実務知見に集中します。
前提:環境と接続
本記事のスクリプトは PowerShell 7.4以上 での実行を推奨します。
# Microsoft Graph PowerShell SDK v2(2026年5月時点)
Install-Module Microsoft.Graph -Scope CurrentUser -Force -AllowClobber
Update-Module Microsoft.Graph -Force
# 必要なスコープを指定して接続(-NoWelcomeで出力をスッキリ)
Connect-MgGraph -Scopes "User.Read.All", "Directory.Read.All", "AuditLog.Read.All", "Organization.Read.All" -NoWelcome
権限について重要な注意点:User.ReadWrite.AllやDirectory.ReadWrite.Allは非常に強い権限です。必ず検証環境で動作確認してから本番適用してください。本番環境ではPrivileged Identity Management(PIM)や一時昇格の利用を推奨します。
レシピ①:ライセンス未割り当てユーザーを高速取得してCSV出力
実務での使いどころ:約2,000ユーザー規模のテナントで毎月のライセンス棚卸しに使っています。GUI確認では30分以上かかっていた作業が、CSV出力込みで1分以内に短縮できました。
Where-Objectでフィルタリングする方法は大規模テナントで非常に遅くなります。Microsoftが推奨するserver-side filteringを使います。
Connect-MgGraph -Scopes "User.Read.All" -NoWelcome
# サーバーサイドフィルタリング(大規模テナントでも高速)
$users = Get-MgUser -All `
-Filter "assignedLicenses/`$count eq 0 and accountEnabled eq true" `
-ConsistencyLevel eventual `
-Property DisplayName, UserPrincipalName, AccountEnabled
$users | Select-Object DisplayName, UserPrincipalName |
Export-Csv -Path ".\unlicensed_users.csv" -Encoding UTF8 -NoTypeInformation
Write-Host "出力完了: $($users.Count) 件"
ゲストを除いてメンバーのみに絞る場合:
$users = Get-MgUser -All `
-Filter "assignedLicenses/`$count eq 0 and userType eq 'Member' and accountEnabled eq true" `
-ConsistencyLevel eventual `
-Property DisplayName, UserPrincipalName
レシピ②:ライセンスをCSVから一括付与する
実務での使いどころ:部門異動やプロジェクト参加に伴うライセンス一括付与で使います。人事異動のタイミングで50〜100件まとめて処理する場面が多いです。
ステップ1:テナントのSKU一覧を確認
Connect-MgGraph -Scopes "Organization.Read.All" -NoWelcome
Get-MgSubscribedSku | Select-Object SkuPartNumber, SkuId, @{
Name = "利用可能数"
Expression = { $_.PrepaidUnits.Enabled - $_.ConsumedUnits }
} | Format-Table
ステップ2:CSVから一括付与
Connect-MgGraph -Scopes "User.ReadWrite.All" -NoWelcome
# users.csvの形式: UserPrincipalName列のみ
$targets = Import-Csv ".\users.csv"
$skuId = "06ebc4ee-1bb5-47dd-8120-11324bc54e06" # E5のSkuId
foreach ($user in $targets) {
try {
Set-MgUserLicense -UserId $user.UserPrincipalName `
-AddLicenses @(@{ SkuId = $skuId }) `
-RemoveLicenses @()
Write-Host "付与成功: $($user.UserPrincipalName)"
}
catch {
Write-Warning "付与失敗: $($user.UserPrincipalName) - $($_.Exception.Message)"
}
}
※ Microsoft.Graph 2.29.0以降を使用してください。それ以前のバージョンではSet-MgUserLicenseに不具合報告がありました。
レシピ③:メッセージセンターの対応必須MCを自動抽出
実務での使いどころ:月次のMC棚卸しで毎回使っています。「planForChange」タグ(対応必須系)を自動抽出してCSVに出力し、「対応必須/確認推奨/無視OK」の判定をつけてチームに展開するワークフローの元データとして使います。GUIで毎回読む作業がなくなり、判断作業に集中できるようになりました。
Connect-MgGraph -Scopes "ServiceMessage.Read.All" -NoWelcome
$messages = Get-MgServiceAnnouncementMessage -All -Property `
Id, Title, Severity, StartDateTime, EndDateTime, Tags, Services
# planForChange(対応必須系)で絞り込み
$planForChange = $messages | Where-Object {
$_.Tags -contains "planForChange"
} | ForEach-Object {
[PSCustomObject]@{
MCId = $_.Id
タイトル = $_.Title
重要度 = $_.Severity
開始日 = $_.StartDateTime
終了日 = $_.EndDateTime
対象サービス = ($_.Services -join ", ")
}
}
$planForChange | Export-Csv -Path ".\mc_planforchange.csv" -Encoding UTF8 -NoTypeInformation
Write-Host "対応必須MC数: $($planForChange.Count) 件"
stayInformed(確認推奨)タグで絞る場合は"planForChange"を"stayInformed"に変えるだけです。
レシピ④:テナント内のゲストユーザーを最終サインイン日時付きで一覧取得
実務での使いどころ:四半期ごとのゲスト棚卸しで使います。最終サインインから180日以上経過しているゲストをアカウント削除の候補として抽出し、業務部門に確認を取るフローに組み込んでいます。
Connect-MgGraph -Scopes "User.Read.All", "AuditLog.Read.All" -NoWelcome
$guests = Get-MgUser -All `
-Filter "userType eq 'Guest'" `
-Property DisplayName, UserPrincipalName, Mail, CreatedDateTime, SignInActivity, ExternalUserState `
-PageSize 999
$result = $guests | ForEach-Object {
$lastSignIn = $_.SignInActivity?.LastSignInDateTime
$daysSince = if ($lastSignIn) {
[int](New-TimeSpan -Start $lastSignIn -End (Get-Date)).TotalDays
} else { 9999 }
[PSCustomObject]@{
DisplayName = $_.DisplayName
UPN = $_.UserPrincipalName
Mail = $_.Mail
作成日 = $_.CreatedDateTime
最終サインイン = $lastSignIn
未サインイン日数 = $daysSince
状態 = $_.ExternalUserState
削除候補 = if ($daysSince -ge 180) { "要確認" } else { "" }
}
}
$result | Sort-Object 未サインイン日数 -Descending |
Export-Csv -Path ".\guest_users.csv" -Encoding UTF8 -NoTypeInformation
Write-Host "ゲスト総数: $($result.Count) 件 / 要確認: $(($result | Where-Object {$_.削除候補 -eq '要確認'}).Count) 件"
SignInActivityはEntra ID P1/P2ライセンスが必要です。
レシピ⑤:失敗したサインインをフィルタリングしてCSVエクスポート
実務での使いどころ:インシデント対応や不審なサインインの調査で使います。特定のエラーコード(50126=パスワード誤り、53003=条件付きアクセスブロックなど)で絞り込むと原因特定が速くなります。
Connect-MgGraph -Scopes "AuditLog.Read.All" -NoWelcome
$startDate = (Get-Date).AddDays(-7).ToString("yyyy-MM-ddTHH:mm:ssZ")
$failedSignIns = Get-MgAuditLogSignIn -All `
-Filter "status/errorCode ne 0 and createdDateTime ge $startDate" `
-Property UserDisplayName, UserPrincipalName, CreatedDateTime, Status, `
IpAddress, Location, AppDisplayName, ConditionalAccessStatus
$result = $failedSignIns | ForEach-Object {
[PSCustomObject]@{
ユーザー名 = $_.UserDisplayName
UPN = $_.UserPrincipalName
日時 = $_.CreatedDateTime
エラーコード = $_.Status.ErrorCode
失敗理由 = $_.Status.FailureReason
IPアドレス = $_.IpAddress
国 = $_.Location.CountryOrRegion
アプリ = $_.AppDisplayName
CAステータス = $_.ConditionalAccessStatus
}
}
$result | Export-Csv -Path ".\failed_signins.csv" -Encoding UTF8 -NoTypeInformation
Write-Host "失敗ログ: $($result.Count) 件"
よく使うエラーコードの対応表:
| エラーコード | 意味 |
|---|---|
| 50126 | パスワード誤り |
| 50074 | MFAが必要(未完了) |
| 53003 | 条件付きアクセスによるブロック |
| 70011 | 無効なスコープ |
| 90095 | 管理者の同意が必要 |
レシピ⑥:条件付きアクセスポリシーの設計棚卸し
実務での使いどころ:テナントの条件付きアクセス設計レビューで使います。ポリシー数が20を超えると全体像の把握がGUIでは難しくなります。CSV出力してExcelで整理するだけで、重複・漏れ・過剰制御が見えやすくなります。
Connect-MgGraph -Scopes "Policy.Read.All" -NoWelcome
$policies = Get-MgIdentityConditionalAccessPolicy -All
$result = $policies | ForEach-Object {
[PSCustomObject]@{
DisplayName = $_.DisplayName
State = $_.State
作成日 = $_.CreatedDateTime
最終更新 = $_.ModifiedDateTime
対象ユーザー = ($_.Conditions.Users.IncludeUsers -join ", ")
除外ユーザー = ($_.Conditions.Users.ExcludeUsers -join ", ")
対象グループ = ($_.Conditions.Users.IncludeGroups -join ", ")
除外グループ = ($_.Conditions.Users.ExcludeGroups -join ", ")
付与制御 = ($_.GrantControls.BuiltInControls -join ", ")
}
}
$result | Export-Csv -Path ".\ca_policies.csv" -Encoding UTF8 -NoTypeInformation
Write-Host "ポリシー数: $($result.Count) 件"
よくあるエラーと対処
Insufficient privileges
→ Connect-MgGraphのScopesに必要な権限を追加して再接続してください。
400 Bad Request(filterとcountを組み合わせた場合)
→ -ConsistencyLevel eventualを追加してください。$count・$orderbyとの組み合わせ時に必要です。
SignInActivityプロパティが返ってこない
→ -Property SignInActivityを明示的に指定してください。デフォルトでは返ってきません。
大量データ取得でタイムアウト
→ -PageSize 999を追加してページング制御してください。-Allだけでは不安定になる場合があります。
おわりに
M365運用は「GUIで頑張る管理」から「Graph APIで運用をコード化する管理」へ確実に移行しています。数百〜数千ユーザー規模になると、GUI中心の運用は人的ミス・属人化・工数増加の原因になります。
本記事のスクリプトはすべて実案件で使っているものです。コピーしてそのまま動かし、自テナントの環境に合わせてフィルター条件を調整してみてください。
各レシピの設計判断や概念的な解説はしがないblogでも書いています。Graph Explorerを使ったインタラクティブな動作確認方法はこちらを参照してください。