Created
September 8, 2025 12:57
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #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