Skip to content

Instantly share code, notes, and snippets.

@sphinxid
Created September 8, 2025 12:57
Show Gist options
  • Select an option

  • Save sphinxid/b4f12bee82093d3692b90c4e29f60148 to your computer and use it in GitHub Desktop.

Select an option

Save sphinxid/b4f12bee82093d3692b90c4e29f60148 to your computer and use it in GitHub Desktop.
This PowerShell script generates a report of Office 365 license assignments and removals within a specified timeframe by querying the Microsoft Graph API. It connects to Microsoft Graph, fetches audit log data, and parses different log formats to identify license changes.
#Requires -Modules Microsoft.Graph.Authentication, Microsoft.Graph.Reports, Microsoft.Graph.Identity.DirectoryManagement
#
# Firman Gautama <[email protected]>
#
<#
.SYNOPSIS
Generate a report of Office 365 license assignments and removals
.DESCRIPTION
This script connects to Microsoft Graph and retrieves audit log data for license assignments
and removals within a specified time period. It exports the results to a CSV file.
.PARAMETER TimeFrameDays
Number of days to look back for license changes (default: 30)
.PARAMETER OutputPath
Path where the CSV report will be saved (default: current directory)
.PARAMETER TenantId
Azure AD Tenant ID (optional - will prompt if not provided)
.EXAMPLE
.\Get-LicenseAssignmentReport.ps1 -TimeFrameDays 30
.EXAMPLE
.\Get-LicenseAssignmentReport.ps1 -TimeFrameDays 30 -OutputPath "C:\Reports\"
.EXAMPLE
.\Get-LicenseAssignmentReport.ps1 -TimeFrameDays 30 -TenantId "your-tenant-id"
#>
param(
[Parameter(Mandatory = $false)]
[ValidateRange(1, 365)]
[int]$TimeFrameDays = 30,
[Parameter(Mandatory = $false)]
[string]$OutputPath = ".",
[Parameter(Mandatory = $false)]
[string]$TenantId,
[Parameter(Mandatory = $false)]
[switch]$UseDebugCache,
[Parameter(Mandatory = $false)]
[switch]$ForceRefresh
)
# Function to check if required modules are installed
function Test-RequiredModules {
$requiredModules = @(
"Microsoft.Graph.Authentication",
"Microsoft.Graph.Reports",
"Microsoft.Graph.Identity.DirectoryManagement"
)
$missingModules = @()
foreach ($module in $requiredModules) {
if (-not (Get-Module -ListAvailable -Name $module)) {
$missingModules += $module
}
}
if ($missingModules.Count -gt 0) {
Write-Error "Missing required modules: $($missingModules -join ', ')"
Write-Host "Please install missing modules using:"
Write-Host "Install-Module -Name Microsoft.Graph -Scope CurrentUser" -ForegroundColor Yellow
return $false
}
return $true
}
# Function to get license assignment changes from audit logs
function Get-LicenseAuditData {
param(
[datetime]$StartDate,
[datetime]$EndDate
)
Write-Host "Retrieving audit log data for license changes..." -ForegroundColor Green
try {
# Get audit logs for license-related activities
$auditLogs = Get-MgAuditLogSignIn -Filter "createdDateTime ge $($StartDate.ToString('yyyy-MM-ddTHH:mm:ssZ')) and createdDateTime le $($EndDate.ToString('yyyy-MM-ddTHH:mm:ssZ'))" -All
# Also get directory audit logs for license assignments
$directoryAudits = Get-MgAuditLogDirectoryAudit -Filter "activityDisplayName eq 'Add license to user' or activityDisplayName eq 'Remove license from user' and activityDateTime ge $($StartDate.ToString('yyyy-MM-ddTHH:mm:ssZ')) and activityDateTime le $($EndDate.ToString('yyyy-MM-ddTHH:mm:ssZ'))" -All
return $directoryAudits
}
catch {
Write-Error "Failed to retrieve audit data: $($_.Exception.Message)"
return $null
}
}
# Function to get license assignment changes using PowerShell for Graph API
function Get-LicenseChangesFromGraph {
param(
[datetime]$StartDate,
[datetime]$EndDate,
# Pass the SKU names hash table here
[hashtable]$SkuNamesMap,
[bool]$UseCache = $false,
[bool]$ForceRefresh = $false
)
# Define debug cache file path
$debugCacheFile = Join-Path $OutputPath "debug_audit_cache_$($TimeFrameDays)days.json"
$unparsedDebugFile = Join-Path $OutputPath "debug_unparsed_entries_$($TimeFrameDays)days.json"
# Check if we should use cached data
if ($UseCache -and (Test-Path $debugCacheFile) -and -not $ForceRefresh) {
Write-Host "Loading audit data from debug cache: $debugCacheFile" -ForegroundColor Yellow
try {
$auditData = Get-Content $debugCacheFile -Raw | ConvertFrom-Json
Write-Host "Loaded $($auditData.Count) audit entries from cache" -ForegroundColor Green
}
catch {
Write-Warning "Failed to load cache file. Querying API instead..."
$auditData = $null
}
}
# Query API if no cached data or force refresh
if (-not $auditData -or $ForceRefresh) {
Write-Host "Querying Microsoft Graph for license assignment data..." -ForegroundColor Green
try {
$auditData = Get-MgAuditLogDirectoryAudit -Filter "activityDateTime ge $($StartDate.ToString('yyyy-MM-ddTHH:mm:ssZ')) and activityDateTime le $($EndDate.ToString('yyyy-MM-ddTHH:mm:ssZ')) and (category eq 'UserManagement')" -All
# Save to debug cache for future use
Write-Host "Saving audit data to debug cache: $debugCacheFile" -ForegroundColor Cyan
$auditData | ConvertTo-Json -Depth 10 | Out-File $debugCacheFile -Encoding UTF8
Write-Host "Cached $($auditData.Count) audit entries" -ForegroundColor Green
}
catch {
Write-Error "Failed to retrieve audit data: $($_.Exception.Message)"
return @()
}
}
$licenseChanges = @()
$unparsedEntries = @()
try {
foreach ($audit in $auditData) {
$targetResource = $audit.TargetResources | Where-Object { $_.Type -eq "User" } | Select-Object -First 1
# Fix InitiatedBy parsing to handle both User and App scenarios properly
$initiatedByUser = $null
$initiatorType = "Unknown"
if ($audit.InitiatedBy) {
if ($audit.InitiatedBy.User -and $audit.InitiatedBy.User.UserPrincipalName) {
$initiatedByUser = $audit.InitiatedBy.User.UserPrincipalName
$initiatorType = "User"
}
elseif ($audit.InitiatedBy.App -and $audit.InitiatedBy.App.DisplayName) {
$initiatedByUser = $audit.InitiatedBy.App.DisplayName
$initiatorType = "Application"
}
# If both are null/empty, leave as null
}
# Initialize per-record defaults
$skuIdValue = "N/A"
$licenseNameValue = "Unknown"
$actionName = $audit.ActivityDisplayName # Default to original activity display name
#==================================================================================
# PARSER 1: Handles the clean "Update user" event format (e.g., from Graph calls)
#==================================================================================
if ($audit.ActivityDisplayName -eq "Update user") {
$licenseProperty = $targetResource.ModifiedProperties | Where-Object { $_.DisplayName -eq 'AssignedLicenses' }
if ($licenseProperty) {
$oldLicenses = $licenseProperty.OldValue | ConvertFrom-Json -ErrorAction SilentlyContinue
$newLicenses = $licenseProperty.NewValue | ConvertFrom-Json -ErrorAction SilentlyContinue
$oldSkuIds = if ($oldLicenses) { $oldLicenses | ForEach-Object { $_.skuId } } else { @() }
$newSkuIds = if ($newLicenses) { $newLicenses | ForEach-Object { $_.skuId } } else { @() }
$changedSkus = Compare-Object -ReferenceObject $oldSkuIds -DifferenceObject $newSkuIds
foreach ($changedSku in $changedSkus) {
$currentSkuId = $changedSku.InputObject
$currentActionName = "Unknown License Change"
if ($changedSku.SideIndicator -eq '=>') { $currentActionName = "Add license" }
elseif ($changedSku.SideIndicator -eq '<=') { $currentActionName = "Remove license" }
# Look up license name from SkuId
$currentLicenseName = if ($SkuNamesMap.ContainsKey($currentSkuId)) { $SkuNamesMap[$currentSkuId] } else { "Unknown" }
$licenseChange = [PSCustomObject]@{
DateTime = $audit.ActivityDateTime
Action = $currentActionName
TargetUser = $targetResource.UserPrincipalName
TargetUserDisplayName = $targetResource.DisplayName
InitiatedBy = $initiatedByUser
InitiatorType = $initiatorType
SkuId = $currentSkuId
Result = $audit.Result
CorrelationId = $audit.CorrelationId
LicenseName = $currentLicenseName
}
$licenseChanges += $licenseChange
}
}
}
#==================================================================================
# PARSER 2: Handles the messy "Change user license" event format (e.g., from Admin Portal)
# This uses both Regex and Object parsing to extract SkuName and SkuId.
#==================================================================================
elseif ($audit.ActivityDisplayName -eq "Change user license") {
$debugInfo = @{}
$foundLicenseChange = $false
# Method 1: Try object-based parsing first
$additionalDetails = $audit.AdditionalDetails
foreach ($detail in $additionalDetails) {
if ($detail.Key -eq 'b' -and $detail.Value) {
$b_ValueString = $detail.Value
$debugInfo.b_Value = $b_ValueString.Substring(0, [Math]::Min(200, $b_ValueString.Length)) + "..."
# Try to parse the nested JSON structure
try {
# The b value contains escaped JSON, we need to unescape it
$unescapedJson = $b_ValueString -replace '\\"', '"'
$parsedB = $unescapedJson | ConvertFrom-Json -ErrorAction SilentlyContinue
if ($parsedB -and $parsedB.targetUpdatedProperties) {
# Further unescape the targetUpdatedProperties
$targetPropsString = $parsedB.targetUpdatedProperties -replace '\\"', '"'
$targetProps = $targetPropsString | ConvertFrom-Json -ErrorAction SilentlyContinue
foreach ($prop in $targetProps) {
if ($prop.Name -eq 'AssignedLicense') {
# Process OldValue (licenses being removed)
if ($prop.OldValue -and $prop.OldValue.Count -gt 0) {
foreach ($oldLicense in $prop.OldValue) {
if ($oldLicense -match 'SkuName=([A-Za-z0-9_]+).*?SkuId=([a-f0-9\-]{36})') {
$licenseChange = [PSCustomObject]@{
DateTime = $audit.ActivityDateTime
Action = "Remove license"
TargetUser = $targetResource.UserPrincipalName
TargetUserDisplayName = $targetResource.DisplayName
InitiatedBy = $initiatedByUser
InitiatorType = $initiatorType
SkuId = $matches[2]
Result = $audit.Result
CorrelationId = $audit.CorrelationId
LicenseName = $matches[1]
DEBUG_AdditionalDetails = "Object-based parsing"
}
$licenseChanges += $licenseChange
$foundLicenseChange = $true
}
}
}
# Process NewValue (licenses being added)
if ($prop.NewValue -and $prop.NewValue.Count -gt 0) {
foreach ($newLicense in $prop.NewValue) {
if ($newLicense -match 'SkuName=([A-Za-z0-9_]+).*?SkuId=([a-f0-9\-]{36})') {
$licenseChange = [PSCustomObject]@{
DateTime = $audit.ActivityDateTime
Action = "Add license"
TargetUser = $targetResource.UserPrincipalName
TargetUserDisplayName = $targetResource.DisplayName
InitiatedBy = $initiatedByUser
InitiatorType = $initiatorType
SkuId = $matches[2]
Result = $audit.Result
CorrelationId = $audit.CorrelationId
LicenseName = $matches[1]
DEBUG_AdditionalDetails = "Object-based parsing"
}
$licenseChanges += $licenseChange
$foundLicenseChange = $true
}
}
}
}
}
}
}
catch {
# Object parsing failed, will try regex method
$debugInfo.ObjectParsingError = $_.Exception.Message
}
# Method 2: Fallback to improved regex parsing if object parsing didn't find anything
if (-not $foundLicenseChange) {
# Improved regex patterns that handle the complex nested structure
# Pattern for licenses in OldValue (being removed)
# Looking for pattern: "OldValue":["[SkuName=XXX, ... SkuId=YYY, ...]"
$removeMatches = [regex]::Matches($b_ValueString, '\\"OldValue\\":\[(.*?)\],\\"NewValue')
if ($removeMatches.Count -gt 0) {
$oldValueContent = $removeMatches[0].Groups[1].Value
$skuMatches = [regex]::Matches($oldValueContent, 'SkuName=([A-Za-z0-9_]+).*?SkuId=([a-f0-9\-]{36})')
foreach ($match in $skuMatches) {
$licenseChange = [PSCustomObject]@{
DateTime = $audit.ActivityDateTime
Action = "Remove license"
TargetUser = $targetResource.UserPrincipalName
TargetUserDisplayName = $targetResource.DisplayName
InitiatedBy = $initiatedByUser
InitiatorType = $initiatorType
SkuId = $match.Groups[2].Value
Result = $audit.Result
CorrelationId = $audit.CorrelationId
LicenseName = $match.Groups[1].Value
DEBUG_AdditionalDetails = "Regex parsing (OldValue)"
}
$licenseChanges += $licenseChange
$foundLicenseChange = $true
}
}
# Pattern for licenses in NewValue (being added)
# Looking for pattern: "NewValue":["[SkuName=XXX, ... SkuId=YYY, ...]"
$addMatches = [regex]::Matches($b_ValueString, '\\"NewValue\\":\[(.*?)\]}')
if ($addMatches.Count -gt 0) {
$newValueContent = $addMatches[0].Groups[1].Value
$skuMatches = [regex]::Matches($newValueContent, 'SkuName=([A-Za-z0-9_]+).*?SkuId=([a-f0-9\-]{36})')
foreach ($match in $skuMatches) {
$licenseChange = [PSCustomObject]@{
DateTime = $audit.ActivityDateTime
Action = "Add license"
TargetUser = $targetResource.UserPrincipalName
TargetUserDisplayName = $targetResource.DisplayName
InitiatedBy = $initiatedByUser
InitiatorType = $initiatorType
SkuId = $match.Groups[2].Value
Result = $audit.Result
CorrelationId = $audit.CorrelationId
LicenseName = $match.Groups[1].Value
DEBUG_AdditionalDetails = "Regex parsing (NewValue)"
}
$licenseChanges += $licenseChange
$foundLicenseChange = $true
}
}
}
# If still no license found, add a debug entry and save for analysis
if (-not $foundLicenseChange) {
# Store the raw data for debugging
$licenseChange = [PSCustomObject]@{
DateTime = $audit.ActivityDateTime
Action = "Change user license"
TargetUser = $targetResource.UserPrincipalName
TargetUserDisplayName = $targetResource.DisplayName
InitiatedBy = $initiatedByUser
InitiatorType = $initiatorType
SkuId = ""
Result = $audit.Result
CorrelationId = $audit.CorrelationId
LicenseName = "Unknown"
DEBUG_AdditionalDetails = $b_ValueString.Substring(0, [Math]::Min(500, $b_ValueString.Length))
}
$licenseChanges += $licenseChange
# Save unparsed entry for debugging
$unparsedEntry = @{
ActivityDateTime = $audit.ActivityDateTime
ActivityDisplayName = $audit.ActivityDisplayName
TargetUser = $targetResource.UserPrincipalName
InitiatedBy = $initiatedByUser
CorrelationId = $audit.CorrelationId
FullBValue = $b_ValueString
AdditionalDetails = $audit.AdditionalDetails
}
$unparsedEntries += $unparsedEntry
}
}
}
}
}
# Save unparsed entries for debugging if any exist
if ($unparsedEntries.Count -gt 0) {
Write-Host "Saving $($unparsedEntries.Count) unparsed entries to: $unparsedDebugFile" -ForegroundColor Yellow
$unparsedEntries | ConvertTo-Json -Depth 10 | Out-File $unparsedDebugFile -Encoding UTF8
Write-Host "Review this file to analyze parsing failures" -ForegroundColor Yellow
}
return $licenseChanges
}
catch {
Write-Error "Failed to retrieve license change data: $($_.Exception.Message)"
return @()
}
}
# Function to get license SKU names
function Get-LicenseSkuNames {
try {
$skus = Get-MgSubscribedSku
$skuHash = @{}
foreach ($sku in $skus) {
$skuHash[$sku.SkuId] = $sku.SkuPartNumber
}
return $skuHash
}
catch {
Write-Warning "Could not retrieve SKU information: $($_.Exception.Message)"
return @{}
}
}
# Main execution
Write-Host "=== Office 365 License Assignment Report ===" -ForegroundColor Cyan
Write-Host "Time Frame: Last $TimeFrameDays days" -ForegroundColor Yellow
# if ($UseDebugCache) {
# Write-Host "Debug cache mode enabled - will use cached data if available" -ForegroundColor Yellow
# }
# if ($ForceRefresh) {
# Write-Host "Force refresh enabled - will query API regardless of cache" -ForegroundColor Yellow
# }
# Check for required modules
if (-not (Test-RequiredModules)) {
exit 1
}
# Calculate date range
$endDate = Get-Date
$startDate = $endDate.AddDays(-$TimeFrameDays)
Write-Host "Report Period: $($startDate.ToString('yyyy-MM-dd')) to $($endDate.ToString('yyyy-MM-dd'))" -ForegroundColor Yellow
# Connect to Microsoft Graph
Write-Host "Connecting to Microsoft Graph..." -ForegroundColor Green
$connectParams = @{
Scopes = @(
"AuditLog.Read.All",
"Directory.Read.All",
"User.Read.All"
)
}
if ($TenantId) {
$connectParams.TenantId = $TenantId
}
try {
Connect-MgGraph @connectParams -NoWelcome
Write-Host "Successfully connected to Microsoft Graph" -ForegroundColor Green
}
catch {
Write-Error "Failed to connect to Microsoft Graph: $($_.Exception.Message)"
exit 1
}
# Get license SKU information
Write-Host "Retrieving license SKU information..." -ForegroundColor Green
$skuNames = Get-LicenseSkuNames
# Get license change data
$licenseChanges = Get-LicenseChangesFromGraph -StartDate $startDate -EndDate $endDate -SkuNamesMap $skuNames -UseCache $UseDebugCache -ForceRefresh $ForceRefresh
if ($licenseChanges.Count -eq 0) {
Write-Warning "No license assignment changes found in the specified time period."
Write-Host "This could mean:"
Write-Host " - No license changes occurred during this period"
Write-Host " - Audit logs may not be available for the full period"
Write-Host " - Insufficient permissions to access audit logs"
Write-Host ""
Write-Host "Required permissions: AuditLog.Read.All, Directory.Read.All, User.Read.All"
}
else {
# Enhance the data with SKU names
# foreach ($change in $licenseChanges) {
# if ($change.SkuId -and $skuNames.ContainsKey($change.SkuId)) {
# $change | Add-Member -NotePropertyName "LicenseName" -NotePropertyValue $skuNames[$change.SkuId]
# }
# else {
# $change | Add-Member -NotePropertyName "LicenseName" -NotePropertyValue "Unknown"
# }
# }
# Sort by date (newest first)
$licenseChanges = $licenseChanges | Sort-Object DateTime -Descending
# Generate output filename
$timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
$outputFile = Join-Path $OutputPath "LicenseAssignmentReport_$($TimeFrameDays)days_$timestamp.csv"
# Export to CSV
Write-Host "Exporting report to: $outputFile" -ForegroundColor Green
$licenseChanges | Export-Csv -Path $outputFile -NoTypeInformation -Encoding UTF8
# Display summary
Write-Host ""
Write-Host "=== REPORT SUMMARY ===" -ForegroundColor Cyan
Write-Host "Total license changes found: $($licenseChanges.Count)" -ForegroundColor Yellow
$assignmentCount = ($licenseChanges | Where-Object {$_.Action -like "*Add*" -or $_.Action -like "*assign*"}).Count
$removalCount = ($licenseChanges | Where-Object {$_.Action -like "*Remove*" -or $_.Action -like "*unassign*"}).Count
Write-Host "License assignments: $assignmentCount" -ForegroundColor Green
Write-Host "License removals: $removalCount" -ForegroundColor Red
# Show top 10 recent changes
Write-Host ""
Write-Host "=== RECENT CHANGES (Top 10) ===" -ForegroundColor Cyan
$licenseChanges | Select-Object -First 10 | Format-Table DateTime, Action, TargetUser, InitiatedBy, LicenseName -AutoSize
}
# Disconnect from Microsoft Graph
# Write-Host "Disconnecting from Microsoft Graph..." -ForegroundColor Green
# Disconnect-MgGraph
# Write-Host "Note: MgGraph connection kept open for testing. Run 'Disconnect-MgGraph' manually when done." -ForegroundColor Yellow
Write-Host ""
Write-Host "Report generation completed!" -ForegroundColor Green
Write-Host ""
Write-Host "=== TROUBLESHOOTING INFORMATION ===" -ForegroundColor Cyan
Write-Host "If you experienced authentication issues or no data was found:" -ForegroundColor Yellow
Write-Host ""
Write-Host "1. Authentication Issues:" -ForegroundColor White
Write-Host " - Run 'Disconnect-MgGraph' and try again"
Write-Host " - Update PowerShell modules: Update-Module Microsoft.Graph -Force"
Write-Host " - Ensure you have Global Reader or Global Admin permissions"
Write-Host ""
Write-Host "2. No License Data Found:" -ForegroundColor White
Write-Host " - Audit logs may have retention limits (In my tenant is 30 days)"
Write-Host " - License changes might be logged under different activity names"
Write-Host " - Your organization might not have audit logging enabled"
Write-Host ""
Write-Host "3. Alternative Approaches:" -ForegroundColor White
Write-Host " - Use the Microsoft 365 Admin Center -> Reports -> Usage"
Write-Host " - Check Azure AD -> Audit logs in the Azure portal"
Write-Host " - Use Microsoft 365 compliance center for detailed audit logs"
Write-Host ""
Write-Host "4. Required Permissions:" -ForegroundColor White
Write-Host " - Reports.Read.All"
Write-Host " - AuditLog.Read.All"
Write-Host " - Directory.Read.All"
Write-Host " - User.Read.All"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment