Microsoft Graphでライセンス割り当て日時を取得する方法|PowerShellと監査ログでPower BI Proの「いつ」を特定

「Power BI Pro を誰に、いつ付与したのか」を PowerShell と Microsoft Graph で追跡したい——管理現場ではよくある要件です。ところが Get-MgUserLicenseDetail には「割り当て日時」のプロパティがありません。本記事では、どの監査ログを参照し、どのようにスクリプト化すれば安全確実に「いつ」を特定できるのかを、実運用を意識した設計・コード例・落とし穴とともに徹底解説します。

目次

質問概要

PowerShell で Power BI Pro ライセンスが付与されている全ユーザーを取得し、ライセンスが「いつ」割り当てられたかも知りたい。しかし Get-MgUserLicenseDetail では日付情報が得られない。どの API/ログを使えばよいか、サンプル スクリプトはあるか?

結論(最短で答えがほしい人向け)

  • Graph のユーザー/ライセンス系エンドポイントには「割り当て日時」は存在しない。
  • 日時を知る唯一の信頼できる情報源は監査ログ。Entra ID(旧 Azure AD)の Directory Audit、または Microsoft 365 の Unified Audit Log(以下 UAL)を使う。
  • 実装は二段構えが現実的:① Graph で対象 SKU のライセンス保有ユーザーを抽出 → ② 監査ログから Add license 等のイベントで日時を引く。
  • 監査ログは保持期間に制限(Entra ID P1/P2: 30 日、Microsoft 365 E5/Advanced Audit: 最大 1 年)。必要なら定期エクスポートで保全する。

やりたいこと ⇔ 実装ポイント(要点表)

やりたいこと実装ポイント備考・注意
ライセンス割り当て日時を取得Graph API や Get-MgUserLicenseDetail には日時プロパティがないため、監査ログを参照する。licenseAssignmentStates.lastUpdatedDateTime はサービス プラン単位の更新時刻であり、ライセンス全体の初回割り当て日時ではない。
使える監査ログ1) Entra ID Directory Audit
2) Microsoft 365 Unified Audit Log
どちらも「Add license」等のイベントに割り当て日時が記録される。
Graph 経由で取得GET /auditLogs/directoryAudits?$filter=activityDisplayName eq 'Add license' で取得し、クライアント側で SkuId を突合。ネストした配列の値(SkuId)でのサーバー側フィルタは限定的。確実さを優先して結果をローカルで絞り込むのが実運用向き。
PowerShell 例(Unified Audit Log)$SkuId = "f8a1db68-be16-40ed-86d5-cb42ce701560" # 例: Power BI Pro Connect-ExchangeOnline # 直近 90 日間の「Add license」を検索し、JSON 内に SkuId が含まれるものだけ抽出 $logs = Search-UnifiedAuditLog -Operations "Add license" -StartDate (Get-Date).AddDays(-90) -EndDate (Get-Date) -ResultSize 5000 -SessionCommand ReturnLargeSet -SessionId ([guid]::NewGuid()) $hits = $logs | Where-Object { $*.AuditData -match $SkuId } | Select-Object @{N='User';E={$*.UserIds}}, CreationTime, AuditData $hits | Format-Table -AutoAuditData は JSON。ConvertFrom-Json で展開し、SkuId / UserKey を抽出する。
既存スクリプトが動かない2023-08-24 以降に Microsoft が JSON の階層やキー名を更新。
→ 実際の JSON 構造を再確認してパーサを修正。
まずは ConvertFrom-Json と Get-Member で形を把握する。
ログ保持期間に注意Entra ID P1/P2: 30 日
Microsoft 365 E5/Advanced Audit: 最大 1 年
過去データが消える前に自動エクスポートを推奨。
大量ユーザー対応のコツ① Get-MgUser -Filter "assignedLicenses/any(s:s/skuId eq $SkuId)" で対象ユーザー抽出
② 監査ログはユーザー ID で個別検索
パフォーマンスと API 制限(スロットリング)を両立。

前提・権限

  • PowerShell 7 系推奨(PS5.1 でも動作するよう記述しますが高速化・JSON 取り回しは 7 系が有利)。
  • モジュール:Microsoft.Graph、ExchangeOnlineManagement。
  • Graph の委任スコープ(またはアプリ権限):AuditLog.Read.All、Directory.Read.All、(必要に応じて)User.Read.All。
  • UAL 閲覧権限:監査閲覧者相当のロール(監査ログの検索が可能であること)。

設計方針:二段階で「いつ」を確定する

  1. 対象ユーザーの確定(Graph)
    SKU ID をもとに、現在その SKU を保持しているユーザーを高速に抽出します。
  2. 割り当てイベントの抽出(監査ログ)
    UAL または Directory Audit から「Add license」関連のイベントを取得し、最古の記録を割り当て日時として採用します。グループベース割り当ての場合は、必要に応じて「Add member to group」も併せて参照します。

SKU ID の調べ方(Power BI Pro 以外でも流用可能)

# Microsoft Graph PowerShell
Install-Module Microsoft.Graph -Scope CurrentUser -Force
Connect-MgGraph -Scopes "Directory.Read.All"
Select-MgProfile -Name beta # v1.0 でも OK。ここでは例として beta。

# 利用可能な SKU 一覧を表示(PartNumber と SkuId)

Get-MgSubscribedSku | Select-Object SkuPartNumber, SkuId | Sort-Object SkuPartNumber

# 例:Power BI Pro の SkuId を取得

$Sku = Get-MgSubscribedSku | Where-Object {$_.SkuPartNumber -eq "POWER_BI_PRO"}
$SkuId = $Sku.SkuId
$SkuId 

有名どころの SkuId は覚えておくと便利です。Power BI Pro の例:f8a1db68-be16-40ed-86d5-cb42ce701560

対象ユーザーの抽出(Graph)

ユーザー リソースの assignedLicenses に対する OData の any() を使うと、サーバー側で絞り込みでき、高速です。

# 前提:Connect-MgGraph 済み
$SkuId = "f8a1db68-be16-40ed-86d5-cb42ce701560"

# "any()" フィルターは高度クエリ。-ConsistencyLevel eventual が必要な場合あり

$users = Get-MgUser -All `  -ConsistencyLevel eventual`
-CountVariable countUsers `  -Filter "assignedLicenses/any(s:s/skuId eq $SkuId)"`
-Property "id,displayName,userPrincipalName,assignedLicenses"

"$countUsers users matched."

# 結果の確認

$users | Select-Object displayName, userPrincipalName | Sort-Object userPrincipalName 

Unified Audit Log で割り当て日時を引く(簡易版)

最短距離で「いつ」を取りたい場合は、JSON を文字列マッチで絞り込むのが手堅いです。まずは全体像を掴みましょう。

Install-Module ExchangeOnlineManagement -Scope CurrentUser -Force
Connect-ExchangeOnline

$SkuId = "f8a1db68-be16-40ed-86d5-cb42ce701560"
$start = (Get-Date).AddDays(-90)  # UAL 標準保持の想定
$end   = Get-Date

# 多件時のための ReturnLargeSet / SessionId を付与

$raw = Search-UnifiedAuditLog `  -StartDate $start -EndDate $end`
-Operations "Add license" `
-ResultSize 5000 -SessionCommand ReturnLargeSet -SessionId ([guid]::NewGuid())

# JSON の中に SkuId が含まれているレコードだけに絞る

$hit = $raw | Where-Object { $_.AuditData -match $SkuId } |
Select-Object CreationTime, UserIds, AuditData

# ユーザーと日時を簡易表示

$hit | Select-Object `  @{N="User";E={$_.UserIds}},`
@{N="AssignedAt(UTC)";E={$_.CreationTime}} |
Sort-Object "AssignedAt(UTC)" 

Unified Audit Log で割り当て日時を引く(堅牢版・JSON 変化に強い)

2023 年後半に UAL の AuditData 階層が変わりました。キー名や入れ子が将来変わっても壊れにくいよう、再帰的に SkuId を検索する関数を用意します。

function Test-JsonContains {
  param(
    [Parameter(Mandatory)][object]$Object,
    [Parameter(Mandatory)][string]$Pattern
  )
  if ($null -eq $Object) { return $false }
  # 文字列
  if ($Object -is [string]) { return ($Object -match $Pattern) }
  # 配列/列挙
  if ($Object -is [System.Collections.IEnumerable] -and -not ($Object -is [string])) {
    foreach ($i in $Object) { if (Test-JsonContains -Object $i -Pattern $Pattern) { return $true } }
  }
  # ハッシュ/PSObject
  $psobj = [PSCustomObject]$Object
  foreach ($prop in $psobj.PSObject.Properties) {
    if ($prop.Name -match $Pattern) { return $true }
    if (Test-JsonContains -Object $prop.Value -Pattern $Pattern) { return $true }
  }
  return $false
}

$SkuId = "f8a1db68-be16-40ed-86d5-cb42ce701560"
$start = (Get-Date).AddDays(-90)
$end   = Get-Date

$all = Search-UnifiedAuditLog -StartDate $start -EndDate $end -Operations "Add license" -ResultSize 5000 -SessionCommand ReturnLargeSet -SessionId ([guid]::NewGuid())

$rows = foreach ($r in $all) {
$j = $r.AuditData | ConvertFrom-Json
if (Test-JsonContains -Object $j -Pattern $SkuId) {
# ユーザーの特定(UserId / UserKey / Target のいずれか)
$upn = if ($r.UserIds) { $r.UserIds } elseif ($j.UserId) { $j.UserId } elseif ($j.UserKey) { $j.UserKey } elseif ($j.Target -and $j.Target[0].ID) { $j.Target[0].ID } else { $null }
[PSCustomObject]@{
UserPrincipalName = $upn
AssignedAtUtc     = $r.CreationTime
Operation         = $r.Operations
Raw               = $j
}
}
}

# 同一ユーザーの最古イベント=初回割り当ての近似値

$first = $rows |
Where-Object { $*.UserPrincipalName } |
Group-Object UserPrincipalName |
ForEach-Object {
$*.Group | Sort-Object AssignedAtUtc | Select-Object -First 1
} |
Sort-Object AssignedAtUtc

$first | Select-Object UserPrincipalName, AssignedAtUtc | Format-Table -Auto 

Directory Audit を Graph 経由で読む(Exchange Online 依存なし)

Exchange Online に接続できない環境や、Azure Automation など Graph 中心で完結させたい場合に有効です。サーバー側フィルターは「Add license」までに留め、SkuId はクライアントで判定します。

Connect-MgGraph -Scopes "AuditLog.Read.All","Directory.Read.All"
$start = (Get-Date).AddDays(-30).ToString("o") # Entra ID の既定保持想定
$SkuId = "f8a1db68-be16-40ed-86d5-cb42ce701560"

# まず「Add license」だけに絞る

$audits = Get-MgAuditLogDirectoryAudit -All `  -Filter "activityDisplayName eq 'Add license' and activityDateTime ge $start"`
-Property "id,activityDateTime,activityDisplayName,initiatedBy,targetResources,additionalDetails"

# SkuId を AdditionalDetails / ModifiedProperties の両方で探索

$rows = foreach ($a in $audits) {
$hasSku =
($a.AdditionalDetails | Where-Object { $*.Key -match 'Sku' -and $*.Value -match $SkuId }) -or
($a.TargetResources.ModifiedProperties | Where-Object { $*.NewValue -match $SkuId -or $*.OldValue -match $SkuId })
if ($hasSku) {
# 対象ユーザーは TargetResources の displayName/UPN から推測
$target = $a.TargetResources | Select-Object -First 1
[PSCustomObject]@{
UserPrincipalName = $target.UserPrincipalName
DisplayName       = $target.DisplayName
AssignedAtUtc     = $a.ActivityDateTime
Source            = "DirectoryAudit"
}
}
}

$rows | Sort-Object AssignedAtUtc | Format-Table -Auto 

ユーザー一覧と突合して「割り当て日時」を付与する(フルパイプライン)

運用では「いま Power BI Pro を持っているユーザーの一覧」に「いつ付与されたか」を列として付けたいケースが多いです。以下は Graph でユーザーを抽出し、UAL(優先)→ Directory Audit(補完)の順に突合するサンプルです。

# 前提:Connect-MgGraph, Connect-ExchangeOnline 済み
$SkuId = "f8a1db68-be16-40ed-86d5-cb42ce701560"
$startUAL = (Get-Date).AddDays(-90)
$startDir = (Get-Date).AddDays(-30)

# 1) 現在ライセンスを持つユーザー一覧(Graph)

$holders = Get-MgUser -All -ConsistencyLevel eventual -Filter "assignedLicenses/any(s:s/skuId eq $SkuId)" -Property "id,displayName,userPrincipalName"

# 2) 監査ログの事前取得(範囲一括のほうが速い)

$ual = Search-UnifiedAuditLog -StartDate $startUAL -EndDate (Get-Date) -Operations "Add license" -ResultSize 5000 -SessionCommand ReturnLargeSet -SessionId ([guid]::NewGuid())

$dirAud = Get-MgAuditLogDirectoryAudit -All -Filter "activityDisplayName eq 'Add license' and activityDateTime ge $($startDir.ToString('o'))" -Property "id,activityDateTime,activityDisplayName,targetResources,additionalDetails"

function Find-AssignedAtFromUal {
param([string]$Upn,[string]$SkuId,[Array]$Ual)
$cand = $Ual | Where-Object { $*.UserIds -eq $Upn -and $*.AuditData -match $SkuId } |
Sort-Object CreationTime | Select-Object -First 1
if ($cand) { return $cand.CreationTime } else { return $null }
}

function Find-AssignedAtFromDir {
param([string]$Upn,[string]$SkuId,[Array]$DirAud)
$cand = foreach ($a in $DirAud) {
$t = $a.TargetResources | Where-Object { $*.UserPrincipalName -eq $Upn }
if (-not $t) { continue }
$hasSku = ($a.AdditionalDetails | Where-Object { $*.Key -match 'Sku' -and $*.Value -match $SkuId }) -or
($a.TargetResources.ModifiedProperties | Where-Object { $*.NewValue -match $SkuId -or $_.OldValue -match $SkuId })
if ($hasSku) { $a }
}
$hit = $cand | Sort-Object ActivityDateTime | Select-Object -First 1
if ($hit) { return $hit.ActivityDateTime } else { return $null }
}

$result = foreach ($u in $holders) {
$assigned = Find-AssignedAtFromUal -Upn $u.UserPrincipalName -SkuId $SkuId -Ual $ual
$source = "UnifiedAuditLog"
if (-not $assigned) {
$assigned = Find-AssignedAtFromDir -Upn $u.UserPrincipalName -SkuId $SkuId -DirAud $dirAud
$source = if ($assigned) { "DirectoryAudit" } else { "N/A" }
}
[PSCustomObject]@{
DisplayName       = $u.DisplayName
UserPrincipalName = $u.UserPrincipalName
AssignedAtUtc     = $assigned
Source            = $source
}
}

# 見やすい順で出力、CSV 保存

$sorted = $result | Sort-Object AssignedAtUtc, UserPrincipalName
$sorted | Format-Table -Auto
$sorted | Export-Csv -NoTypeInformation -Encoding UTF8 -Path ".\PowerBIPro_AssignedAt.csv" 

グループベース割り当て(GBL)の扱い

グループにライセンスを付与している場合、ユーザーへの反映はバックグラウンド処理です。このとき監査ログには、次のいずれか、または両方が記録されます。

  • Assign license to group(グループに SKU を付与)
  • Add member to group(ユーザーがグループへ参加)

最終的にユーザーに付与された事実は Add license でも出ることが多いですが、環境によっては Add member to group の日時が最も早い「割り当ての起点」となります。確実を期すなら、GBL 対象グループの追加・削除イベントも併せて突合してください。

# 例:GBL の場合に「Add member to group」も捕捉
$groupEvents = Search-UnifiedAuditLog -StartDate (Get-Date).AddDays(-90) -EndDate (Get-Date) -Operations "Add member to group" -ResultSize 5000 -SessionCommand ReturnLargeSet -SessionId ([guid]::NewGuid())

# ユーザーが GBL 対象グループに入った最古日時をサブ候補にする…等のロジックを組み合わせる

大量ユーザー・大規模テナントでのパフォーマンス最適化

  • サーバーサイド フィルターを最優先:Get-MgUser -Filter "assignedLicenses/any(...)”。
  • UAL は 日付で分割(1 日単位)して複数回検索すると取りこぼしを防ぎやすい:-SessionCommand ReturnLargeSet と -SessionId を併用。
  • PowerShell 7 の ForEach-Object -Parallel でユーザー単位の突合を並列化(スロットリングに注意し、同時 4~8 並列程度に抑える)。
  • インクリメンタル取得:前回以降の期間だけ監査ログを収集し、ストレージ(例:Azure Table / Blob / SQL)に追記。
  • Graph は 429(スロットル) を必ず考慮。指数バックオフでリトライ実装、または -PageSize を小さめに。

落とし穴と誤解

  • licenseAssignmentStates.lastUpdatedDateTime は「割り当て日時」ではない:トグル操作(サービス プランの有効/無効)でも更新されるため、初回付与のタイムスタンプとは一致しない。
  • 再付与・移管:一度解除→再付与した場合、最古の Add license が保持期間外なら拾えない。保持が短い環境では「初回付与の確定」は統計的近似に留まる。
  • JSON の構造変更:UAL の AuditData は時折キー名・階層が変わる。ハードコードしたパスではなく、再帰探索や -match を併用して耐性を高める。
  • 時刻のタイムゾーン:監査ログのタイムスタンプは UTC。レポート時はローカル タイムゾーンへ明示的に変換する。

運用テンプレート(現実解)

  1. 毎日ジョブ(Azure Automation / Windows タスク スケジューラ)で UAL と Directory Audit を収集。
  2. SKU ごとに「現在の保有者」「初回割り当て推定日時」を CSV/Parquet へエクスポート。
  3. 蓄積データを Power BI で可視化(トレンド、組織別集計、異常検知)。
  4. 保持期間外をカバーするため、ストレージの原本を信頼ソースにする(監査ログからの再計算も可能に)。

FAQ

Q. Graph だけで「割り当て日時」を取れますか?
いいえ。ユーザー/ライセンス系の Graph リソースにそのプロパティはありません。監査ログが必須です。

Q. どのログがよりおすすめ?
保持期間と取得のしやすさで選びます。E5/Advanced Audit を持つなら UAL を優先、Exchange Online へ接続できない運用では Directory Audit(Graph)で完結できます。

Q. ライセンスの「削除日時」も追えますか?
はい。Remove license、Remove service plan 等のイベントを同様に検索します。

コピー&ペースト用:最小構成スニペット集

以下は記事全体の要点を凝縮した最小スニペットです。検証・運用へそのまま流用できます。

1) Power BI Pro の保有者一覧(Graph)

Connect-MgGraph -Scopes "Directory.Read.All"
$SkuId = "f8a1db68-be16-40ed-86d5-cb42ce701560"
Get-MgUser -All -ConsistencyLevel eventual -Filter "assignedLicenses/any(s:s/skuId eq $SkuId)" -Property "displayName,userPrincipalName" |
  Select-Object displayName, userPrincipalName

2) 直近 90 日の割り当て日時(UAL 簡易)

Connect-ExchangeOnline
$SkuId = "f8a1db68-be16-40ed-86d5-cb42ce701560"
Search-UnifiedAuditLog -Operations "Add license" -StartDate (Get-Date).AddDays(-90) -EndDate (Get-Date) -ResultSize 5000 -SessionCommand ReturnLargeSet -SessionId ([guid]::NewGuid()) |
  Where-Object { $_.AuditData -match $SkuId } |
  Select-Object @{N='User';E={$_.UserIds}}, CreationTime

3) Directory Audit(Graph)で補完

Connect-MgGraph -Scopes "AuditLog.Read.All","Directory.Read.All"
$SkuId = "f8a1db68-be16-40ed-86d5-cb42ce701560"
$start = (Get-Date).AddDays(-30).ToString("o")
Get-MgAuditLogDirectoryAudit -All -Filter "activityDisplayName eq 'Add license' and activityDateTime ge $start" |
  Where-Object {
    ($_.AdditionalDetails | Where-Object { $_.Key -match 'Sku' -and $_.Value -match $SkuId }) -or
    ($_.TargetResources.ModifiedProperties | Where-Object { $_.NewValue -match $SkuId -or $_.OldValue -match $SkuId })
  } |
  ForEach-Object {
    $t = $_.TargetResources | Select-Object -First 1
    [PSCustomObject]@{ UPN=$t.UserPrincipalName; AssignedAt=$_.ActivityDateTime }
  }

まとめ

  • 直接取得は不可:Graph のライセンス系 API に割り当て日時はない。
  • 監査ログが唯一の答え:Directory Audit または UAL の「Add license」で時刻を確定。
  • JSON 変更に強い実装:ハードコードを避け、再帰探索や柔軟なマッチで耐性を上げる。
  • 保持期間対策は運用で解決:定期エクスポート&データ蓄積を前提に、Power BI で可視化するのが現実解。
  • 大規模化の鍵:サーバー側フィルター、日付分割、並列化、リトライでスケーラブルに。

付録:フィルター式の注意点(Graph)

/auditLogs/directoryAudits では、activityDisplayName や activityDateTime のフィルタは強力ですが、targetResources 内の入れ子(ModifiedProperties.NewValue など)に対して $filter で直接 SkuId を指定するのは成功しないことがあります。実務では サーバー側は粗く(例:activityDisplayName eq 'Add license')、クライアントで精密に(SkuId のマッチ)という役割分担にするのが堅実です。

付録:タイムゾーン変換の雛形

# UTC → ローカル(例:Tokyo)に変換して表示
$tz = [System.TimeZoneInfo]::FindSystemTimeZoneById("Tokyo Standard Time")
$first | Select-Object UserPrincipalName,
  @{N="AssignedAt(Local)";E={[System.TimeZoneInfo]::ConvertTimeFromUtc($_.AssignedAtUtc, $tz)}}

付録:Power BI による可視化のための列設計例

列名型説明
UserPrincipalNameTextユーザーの UPN(主キー)
DisplayNameText表示名
SkuIdGUID/Text対象 SKU
AssignedAtUtcDateTime割り当て(推定)UTC 時刻
SourceTextUnifiedAuditLog / DirectoryAudit / N/A
DetectedByText検出ロジック(Add license / Add member to group など)

付録:監査ログ保持期間の目安

ソースプラン既定保持備考
Entra ID Directory AuditP1/P230 日Log Analytics / ストレージに転送すれば長期保管可
Microsoft 365 UALE3 等90 日程度テナント設定に依存
Microsoft 365 UALE5 / Advanced Audit最大 1 年長期保持アドオンでさらに延長可

実装チェックリスト

  • Graph/Exchange の接続は 再実行に強い(失敗時リトライ)実装に。
  • 検索範囲・保持期間は環境差が出やすい。まずはサンプルを実行してデータの粒度と件数を把握。
  • CSV は UTF-8(BOM なし)で出力。Excel に取り込む場合は Power Query を推奨。
  • 個人情報の扱いに注意。最小権限・最小出力で設計する。

参考:Graph フィルターのサンプル集(覚えておくと便利)

# 指定 SKU を持つユーザー
Get-MgUser -All -ConsistencyLevel eventual -Filter "assignedLicenses/any(s:s/skuId eq $SkuId)"

# 指定 SKU を持たないユーザー(NOT で否定)

Get-MgUser -All -ConsistencyLevel eventual -Filter "NOT assignedLicenses/any(s:s/skuId eq $SkuId)"

# 最終ログイン日が古いユーザー(例:30 日以上前)(signInActivity は beta)

Select-MgProfile beta
Get-MgUser -All -Property "id,displayName,userPrincipalName,signInActivity" `
| Where-Object { $_.signInActivity.lastSignInDateTime -lt (Get-Date).AddDays(-30) } 

記事のポイント再掲

  • Graph のライセンス系 API に「割り当て日時」はない。
  • 必要な答えは 監査ログから引く。
  • UAL(最大 1 年)と Directory Audit(30 日)を組み合わせれば、実務で十分な精度の「いつ」を再現できる。
  • 将来の仕様変更に備え、JSON 変化に耐えるコードにしておく。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次