Reporting Who Made License Assignments

Who Performed an Azure AD License Assignment?

After writing about how to detect underused (and expensive) licenses assigned to Azure AD accounts, I was asked if it was possible to report who assigned a license to accounts. It’s a good question that stumped me for a moment. There’s no obvious off-the-shelf indication of who assigned licenses to accounts in any Microsoft 365 administrative interface.

Azure AD Audit Data

License assignment is an Azure AD activity. It’s therefore possible to find information about these actions in the Azure AD audit log by searching for “Change user license” events. Unfortunately, these events only note that some sort of license assignment occurred. It doesn’t tell you what happened to licenses in terms of additions, removals, or disabling service plans in licenses. For that information, you need to find a matching “Update user” event where the license assignment detail is captured in the Modified Properties tab (Figure 1).

Azure AD license assignment details in the audit log
Figure 1: Azure AD license assignment details in the audit log

Unfortunately, the Get-MgAuditLogDirectoryAudit cmdlet doesn’t report the same level of detail about license assignments, so the Azure AD audit log isn’t a good source for reporting.

License Assignment Records in the Unified Audit Log

Azure AD is a source for the Office 365 (unified) audit log and the information ingested into the Office 365 audit log is more comprehensive albeit formatted in such a way that the data isn’t easy to fetch. However, we can find enough data to write a PowerShell script to create a basic report that contains enough information to at least give administrators some insight into who assigns licenses.

To create the report, the script:

  • Ran the Search-UnifiedAuditLog cmdlet to retrieve audit records for the Change user license and Update User actions.
  • Create separate arrays for both types of event.
  • For each Change user license event, see if there’s a matching Update user record. If one is found, extract the license assignment information from the record.
  • Report what’s been found.

Here’s the script to prove that the concept works:

# Azure AD license assignment script
$StartDate = (Get-Date).AddDays(-90)
$EndDate = (Get-Date).AddDays(1)
Write-Host "Searching for license assignment audit records"
[array]$Records = Search-UnifiedAuditLog -StartDate $StartDate -EndDate $EndDate -Formatted -ResultSize 5000 -Operations "Change user license", "Update User" -SessionCommand ReturnLargeSet
If (!($Records)) { Write-Host "No audit records found... exiting... " ; break}

Write-Host ("Processing {0} records" -f $Records.count)
$Records = $Records | Sort-Object {$_.CreationDate -as [datetime]} -Descending
[array]$LicenseUpdates = $Records | Where-Object {$_.Operations -eq "Change user license."}
[array]$UserUpdates = $Records | Where-Object {$_.Operations -eq "Update user."}

$Report = [System.Collections.Generic.List[Object]]::new()

ForEach ($L in $LicenseUpdates) {
  $NewLicenses = $Null; $OldLicenses = $Null; $OldSkuNames = $Null; $NewSkuNames = $Null
  $AuditData = $L.AuditData | ConvertFrom-Json
  $CreationDate = Get-Date($L.CreationDate) -format s
  [array]$Detail = $UserUpdates | Where-Object {$_.CreationDate -eq $CreationDate -and $_.UserIds -eq $L.UserIds}
  If ($Detail) { # Found a user update record
     [int]$i = 0
     $LicenseData = $Detail[0].AuditData | ConvertFrom-Json
     [array]$OldLicenses = $LicenseData.ModifiedProperties | Where {$_.Name -eq 'AssignedLicense'} | Select-Object -ExpandProperty OldValue | Convertfrom-Json
     If ($OldLicenses) {
        [array]$OldSkuNames = $Null
        ForEach ($OSku in $OldLicenses) {
           $OldSkuName = $OldLicenses[$i].Substring(($OldLicenses[$i].IndexOf("=")+1), ($OldLicenses[$i].IndexOf(",")-$OldLicenses[$i].IndexOf("="))-1)
           $OldSkuNames += $OldSkuName
           $i++
         }
      $OldSkuNames = $OldSkuNames -join ", "
    }
    [array]$NewLicenses = $LicenseData.ModifiedProperties | Where {$_.Name -eq 'AssignedLicense'} | Select-Object -ExpandProperty NewValue | Convertfrom-Json
    If ($NewLicenses) {
        $i = 0
        [array]$NewSkuNames = $Null
        ForEach ($N in $NewLicenses) {
           $NewSkuName = $NewLicenses[$i].Substring(($NewLicenses[$i].IndexOf("=")+1), ($NewLicenses[$i].IndexOf(",")-$NewLicenses[$i].IndexOf("="))-1)
           $NewSkuNames += $NewSkuName
           $i++
         }
      $NewSkuNames = $NewSkuNames -join ", "
    }

  } # end if
  $ReportLine   = [PSCustomObject] @{ 
     Operation      = $AuditData.Operation
     Timestamp      = Get-Date($AuditData.CreationTime) -format g
     'Assigned by'  = $AuditData.UserId
     'Assigned to'  = $AuditData.ObjectId 
     'Old SKU'      = $OldSkuNames
     'New SKU'      = $NewSkuNames
     'New licenses' = $NewLicenses
     'Old licenses' = $OldLicenses
  }
  $Report.Add($ReportLine)
}

$Report = $Report | Sort-Object {$_.TimeStamp -as [datetime]} 
$Report | Out-GridView

The output is sparse (Figure 2) but I reckon it is sufficient to understand what happens when a license assignment occurred. Events without any license detail appear to be when an administrator removes a license from an account or a service plan from a license.

Azure AD license assignment data extracted from the Office 365 audit log
Figure 2: License assignment data extracted from the Office 365 audit log

I didn’t bother attempting to parse out the license detail. The information returned by Azure AD includes all the licenses assigned to an account, so you’d end up with something like this for an account with three licenses. Splitting the individual licenses and disabled service plans out from this information is an exercise for the reader.

$NewLicenses.Split(',')
[SkuName=POWER_BI_STANDARD
 AccountId=a662313f-14fc-43a2-9a7a-d2e27f4f3478
 SkuId=a403ebcc-fae0-4ca2-8c8c-7a907fd6c235
 DisabledPlans=[]]
[SkuName=ENTERPRISEPACK
 AccountId=a662313f-14fc-43a2-9a7a-d2e27f4f3478
 SkuId=6fd2c87f-b296-42f0-b197-1e91e994b900
 DisabledPlans=[]]
[SkuName=TOPIC_EXPERIENCES
 AccountId=a662313f-14fc-43a2-9a7a-d2e27f4f3478
 SkuId=4016f256-b063-4864-816e-d818aad600c9
 DisabledPlans=[]]

Principal Proved

In any case, the answer to the question is that it’s possible to track and report Azure AD license assignments by using the audit log to extract events relating to these actions and parsing the information in the events. The resulting output might not be pretty (but could be cleaned up), but it’s enough to prove the principal.


Learn how to exploit the data available to Microsoft 365 tenant administrators through the Office 365 for IT Pros eBook. We love figuring out how things work.

13 Replies to “Reporting Who Made License Assignments”

  1. Search-UnifiedAuditLog cmdlet only available for me after connecting to exchange online. However, that doesn’t pull any information for license assigment only the changes made on exchange. Any suggestions?

    1. Search-UnifiedAuditLog is a cmdlet in the Exchange Online management module, hence why you must connect to EXO before you can run it.

      Azure AD sends information about license assignments (all assignments) to the log. It tracks more than changes made on Exchange.

  2. Hi! I am testing this script but when I execute it is returning time out after some time executing. 🙁

      1. My timeou is here
        [array]$Records = Search-UnifiedAuditLog -StartDate $StartDate -EndDate $EndDate -Formatted -ResultSize 5000 -Operations “Change user license”, “Update User”
        Write-ErrorMessage : The operation has timed out

      2. Add -SessionCommand ReturnLargeSet to the command and see if it works. There have been some problems with the cmdlet recently and this parameter can help. Remember to sort the returned items afterwards. I’ve updated the code to reflect these chamges.

    1. The code ran and found license update events for me. If you mean the old and new license data, Microsoft seems to have changed the format of the captured data since I wrote the script. It’s just a matter of extracting the right data from the $AuditData variable. I don’t have time to look at it now…

  3. The issue appears to be where you made the license changes. If you remove the license through the O365 portal, then the script returns the results. I have some users that I used a PowerShell script to remove all licenses from via the Azure AD module. The results in the log output are completely different for these users even though the RecordType and EventType are the same.

    In the AAD PS result, all data is in ExtendedProperties and ModifiedProperties is blank. Also all of the results appear to be bracketed by multiple backslashes and other characters. I am trying to work out what the format is and how to get it out to modify the script. An example of the start of an ExtendedProperties output is below.

    {@{Name=additionalDetails; Value={“id”:”9ebc85e1-0138-45c2-ac02-a428ff1be8e4″,”seq”:”1″,”b”:”{\”targetUpdatedProperties\”:\”[{\\\”Name\\\”:\\\”AssignedLicense\\\”,\\\”OldValue\\\”:[\\\”[SkuName=SPE_E3,

    1. The sad truth is that the Entra ID team needs to fix the problem with the audit records. The PS inconsistency doesn’t surprise me because the Azure AD module is on its way to retirement and no one is paying much attention to it.

  4. Thanks Tony. What module would you recommend because I was not using the MSOL module because that had supposedly been discontinued?
    Also do you have any idea what format that data is in? Is it JSON with characters that indicate the levels or need to be stripped out to make it appear normal?
    Thank you for your time.

    1. What module would I recommend to do what? As a replacement for MSOL? If so, that’s the Microsoft Graph PowerShell SDK.

      As to ‘what format,’ if you mean the format of audit records, the AuditData property is in JSON. That’s why I use the ConvertFrom-JSON cmdlet to make the data more accessible to PowerShell.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.