「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 -Auto | AuditData は 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 閲覧権限:監査閲覧者相当のロール(監査ログの検索が可能であること)。
設計方針:二段階で「いつ」を確定する
- 対象ユーザーの確定(Graph)
SKU ID をもとに、現在その SKU を保持しているユーザーを高速に抽出します。 - 割り当てイベントの抽出(監査ログ)
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。レポート時はローカル タイムゾーンへ明示的に変換する。
運用テンプレート(現実解)
- 毎日ジョブ(Azure Automation / Windows タスク スケジューラ)で UAL と Directory Audit を収集。
- SKU ごとに「現在の保有者」「初回割り当て推定日時」を CSV/Parquet へエクスポート。
- 蓄積データを Power BI で可視化(トレンド、組織別集計、異常検知)。
- 保持期間外をカバーするため、ストレージの原本を信頼ソースにする(監査ログからの再計算も可能に)。
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 による可視化のための列設計例
| 列名 | 型 | 説明 |
|---|---|---|
| UserPrincipalName | Text | ユーザーの UPN(主キー) |
| DisplayName | Text | 表示名 |
| SkuId | GUID/Text | 対象 SKU |
| AssignedAtUtc | DateTime | 割り当て(推定)UTC 時刻 |
| Source | Text | UnifiedAuditLog / DirectoryAudit / N/A |
| DetectedBy | Text | 検出ロジック(Add license / Add member to group など) |
付録:監査ログ保持期間の目安
| ソース | プラン | 既定保持 | 備考 |
|---|---|---|---|
| Entra ID Directory Audit | P1/P2 | 30 日 | Log Analytics / ストレージに転送すれば長期保管可 |
| Microsoft 365 UAL | E3 等 | 90 日程度 | テナント設定に依存 |
| Microsoft 365 UAL | E5 / 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 変化に耐えるコードにしておく。

コメント