Public/Get-TntLicenseChangeAuditReport.ps1
|
function Get-TntLicenseChangeAuditReport { <# .SYNOPSIS Reports on license change audit events from directory audit logs. .DESCRIPTION Queries the Microsoft Graph directoryAudits API for license change events and provides a summary of license additions, removals, and most affected users over a configurable time period. .PARAMETER TenantId The Azure AD Tenant ID (GUID) to connect to. .PARAMETER ClientId The Application (Client) ID of the app registration created for security reporting. .PARAMETER ClientSecret The client secret for the app registration. Use this for automated scenarios. .PARAMETER CertificateThumbprint The thumbprint of the certificate to use for authentication instead of client secret. .PARAMETER DaysBack Number of days to look back for license change events. Defaults to 30. .EXAMPLE Get-TntLicenseChangeAuditReport -TenantId $tenantId -ClientId $clientId -ClientSecret $secret Retrieves license change audit events from the last 30 days. .EXAMPLE Get-TntLicenseChangeAuditReport -TenantId $tid -ClientId $cid -ClientSecret $secret -DaysBack 90 Retrieves license change audit events from the last 90 days. .INPUTS None. This function does not accept pipeline input. .OUTPUTS System.Management.Automation.PSCustomObject Returns a structured object containing: - Summary: Total changes, additions, removals, most changed users - Changes: Detailed list of license change records .NOTES Author: Tom de Leeuw Website: https://systom.dev Module: TenantReports Required Permissions: - AuditLog.Read.All (Application) .LINK https://systom.dev #> [CmdletBinding(DefaultParameterSetName = 'ClientSecret')] [OutputType([System.Management.Automation.PSCustomObject])] param( [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName, ParameterSetName = 'ClientSecret')] [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName, ParameterSetName = 'Certificate')] [Parameter(Mandatory = $false, ParameterSetName = 'Interactive')] [ValidateNotNullOrEmpty()] [Alias('Tenant')] [string]$TenantId, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName, ParameterSetName = 'ClientSecret')] [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName, ParameterSetName = 'Certificate')] [Parameter(Mandatory = $false, ParameterSetName = 'Interactive')] [ValidatePattern('^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$')] [Alias('ApplicationId')] [string]$ClientId, [Parameter(Mandatory = $true, ParameterSetName = 'ClientSecret', ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] [Alias('Secret', 'ApplicationSecret')] [SecureString]$ClientSecret, [Parameter(Mandatory = $true, ParameterSetName = 'Certificate', ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] [Alias('Thumbprint')] [string]$CertificateThumbprint, # Use interactive authentication (no app registration required). [Parameter(Mandatory = $true, ParameterSetName = 'Interactive')] [switch]$Interactive, [Parameter()] [ValidateRange(1, 365)] [int]$DaysBack = 30 ) begin { # Load SKU translation table for friendly license names $SkuHashTable = @{} $SkuTable = Get-SkuTranslationTable if ($SkuTable) { $SkuTable | Group-Object GUID | ForEach-Object { $SkuHashTable[$_.Name] = ($_.Group | Select-Object -First 1).Product_Display_Name } } else { Write-Verbose 'SKU Translation Table not available.' } Write-Information 'Starting license change audit analysis...' -InformationAction Continue } process { try { $ConnectionParams = Get-ConnectionParameters -BoundParameters $PSBoundParameters $ConnectionInfo = Connect-TntGraphSession @ConnectionParams $Changes = [System.Collections.Generic.List[PSCustomObject]]::new() # Build filter date $FilterDate = (Get-Date).AddDays(-$DaysBack).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') $Filter = "activityDateTime ge $FilterDate and activityDisplayName eq 'Change user license'" $Uri = "https://graph.microsoft.com/v1.0/auditLogs/directoryAudits?`$filter=$Filter&`$top=999" Write-Verbose "Querying audit logs from $FilterDate..." # Page through results do { $Response = Invoke-MgGraphRequest -Uri $Uri -Method GET -ErrorAction Stop foreach ($AuditEvent in $Response.value) { # Skip failed events and signup noise if ($AuditEvent.result -eq 'failure') { continue } $InitiatedBy = if ($AuditEvent.initiatedBy.user) { $AuditEvent.initiatedBy.user.userPrincipalName ?? $AuditEvent.initiatedBy.user.displayName ?? 'Unknown User' } elseif ($AuditEvent.initiatedBy.app) { $AuditEvent.initiatedBy.app.displayName ?? 'Unknown App' } else { 'Unknown' } # Skip Signup-initiated events if ($InitiatedBy -eq 'Signup') { continue } $TargetUser = $AuditEvent.targetResources | Where-Object { $_.type -eq 'User' } | Select-Object -First 1 $ModifiedProperties = if ($TargetUser) { $TargetUser.modifiedProperties } else { @() } $AddedLicenses = @() $RemovedLicenses = @() foreach ($Prop in $ModifiedProperties) { if ($Prop.displayName -eq 'AssignedLicense') { # Helper to extract SkuIds from the audit log license data # The data can be JSON or .NET object string format like: # [SkuName=O365_BUSINESS_PREMIUM, AccountId=..., SkuId=f245ecc8-..., DisabledPlans=[]] $ExtractSkuIds = { param([string]$RawValue) if ([string]::IsNullOrWhiteSpace($RawValue)) { return @() } $SkuIds = [System.Collections.Generic.List[string]]::new() # Try JSON parsing first try { $Parsed = $RawValue | ConvertFrom-Json -ErrorAction Stop foreach ($Item in @($Parsed)) { $Id = $Item.SkuId ?? $Item.skuId if ($Id) { $SkuIds.Add($Id) } } if ($SkuIds.Count -gt 0) { return $SkuIds.ToArray() } } catch { } # Fall back to regex for .NET object string format # Matches: SkuId=f245ecc8-75af-4f8e-b61f-27d8114de5f3 $Matches = [regex]::Matches($RawValue, 'SkuId=([a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})') foreach ($Match in $Matches) { if ($Match.Groups[1].Success) { $SkuIds.Add($Match.Groups[1].Value) } } return $SkuIds.ToArray() } $AddedSkuIds = & $ExtractSkuIds $Prop.newValue $RemovedSkuIds = & $ExtractSkuIds $Prop.oldValue # Resolve SKU GUIDs to friendly names $AddedLicenses = @($AddedSkuIds | ForEach-Object { Resolve-SkuName -SkuId $_ -SkuHashTable $SkuHashTable }) $RemovedLicenses = @($RemovedSkuIds | ForEach-Object { Resolve-SkuName -SkuId $_ -SkuHashTable $SkuHashTable }) } } # Skip events with no actual license changes if ($AddedLicenses.Count -eq 0 -and $RemovedLicenses.Count -eq 0) { continue } $Changes.Add([PSCustomObject]@{ ActivityDate = $AuditEvent.activityDateTime UserPrincipal = $TargetUser.userPrincipalName ?? $TargetUser.displayName ?? 'Unknown' UserId = $TargetUser.id AddedLicenses = $AddedLicenses RemovedLicenses = $RemovedLicenses InitiatedBy = $InitiatedBy CorrelationId = $AuditEvent.correlationId Result = $AuditEvent.result }) } $Uri = $Response.'@odata.nextLink' } while ($Uri) # Build summary $UserChangeCounts = $Changes | Group-Object -Property UserPrincipal | Sort-Object Count -Descending $MostChangedUsers = $UserChangeCounts | Select-Object -First 10 | ForEach-Object { [PSCustomObject]@{ UserPrincipalName = $_.Name ChangeCount = $_.Count } } $Summary = [PSCustomObject]@{ TenantId = $TenantId ReportGeneratedDate = Get-Date DaysBack = $DaysBack TotalChanges = $Changes.Count UniqueUsersAffected = ($Changes.UserPrincipal | Select-Object -Unique).Count MostChangedUsers = $MostChangedUsers } Write-Information "License change audit completed - $($Changes.Count) changes found across $($Summary.UniqueUsersAffected) users." -InformationAction Continue [PSCustomObject][Ordered]@{ Summary = $Summary Changes = $Changes.ToArray() } } catch { $errorRecord = [System.Management.Automation.ErrorRecord]::new( [System.Exception]::new("Get-TntLicenseChangeAuditReport failed: $($_.Exception.Message)", $_.Exception), 'GetTntLicenseChangeAuditReportError', [System.Management.Automation.ErrorCategory]::OperationStopped, $TenantId ) $PSCmdlet.ThrowTerminatingError($errorRecord) } finally { if ($ConnectionInfo.ShouldDisconnect) { Disconnect-TntGraphSession -ConnectionState $ConnectionInfo } } } } |