Time to Review Licensing When Prices Increase

On September 9, I explained the steps to convert a PowerShell script from the Azure AD cmdlets which will stop working on June 30, 2022, to equivalents from the Microsoft Graph SDK for PowerShell. I then followed up by noting some of the issues involved in using the SDK for interactive scripts. Given Microsoft’s announcement of price increases for Office 365 and Microsoft 365 licenses due in March 2022, it’s a good time to audit the set of licenses in a tenant and ask some questions about the distribution of licenses across accounts. To do that, we need a licensing report.

Many examples of generating such a report exist based on the cmdlets which will stop working next June (here’s one version), so in this article I’ll go through the steps to generate a licensing report using SDK cmdlets.

Stage 1: Extract Licensing Data for the Tenant

The basic steps in generating a report are in two stages. First, we create two data (CSV) files containing:

  • The product licenses (SKUs) used in the tenant.
  • The service plans belonging to the product licenses. A service plan is something like Exchange Online or Teams which is bunded in a product license like Office 365 E3.

Because every tenant is different, we have some code (available from GitHub) to generate these files. We connect to the Graph, select the beta profile to ensure that license data is available, and use the Get-MgSubscribedSku cmdlet to fetch the license data for the tenant. We then export the list of products (SKUs) to a CSV before looping through the products to extract the service plans and exporting them to a second CSV.

Connect-MgGraph -TenantId xxxxxxx-14fc-43a2-9a7a-d2e27f4f3478
Select-MgProfile beta
[Array]$Skus = Get-MgSubscribedSku

# Generate CSV of all product SKUs used in tenant
$Skus | Select SkuId, SkuPartNumber  | Export-Csv -NoTypeInformation c:\temp\ListOfSkus.Csv
# Generate list of all service plans used in SKUs in tenant
$SPData = [System.Collections.Generic.List[Object]]::new()
ForEach ($S in $Skus) {
   ForEach ($SP in $S.ServicePlans) {
     $SPLine = [PSCustomObject][Ordered]@{  
         ServicePlanId = $SP.ServicePlanId
         ServicePlanName = $SP.ServicePlanName
         ServicePlanDisplayName = $SP.ServicePlanName }
     $SPData.Add($SPLine)
 }
}
$SPData | Sort ServicePlanId -Unique | Export-csv c:\Temp\ServicePlanData.csv -NoTypeInformation

This code generates two files. However, the files need some massaging before we can use them because the generated data contains GUIDs to identify the licenses and service plans plus code names. For example, 6fd2c87f-b296-42f0-b197-1e91e994b900 is the GUID identifying Office 365 E3, which has a code name of ENTERPRISEPACK, while 3fb82609-8c27-4f7b-bd51-30634711ee67 is an example of a service plan GUID. This is code BPOS_S_TODO_3, the To Do service plan included in Office 365 E5.

We could use these values in the report, but it’s nicer to have user-friendly (or user-understandable) names. We therefore edit the CSV files to add a column containing the user-friendly names. Figure 1 shows editing of the CSV file holding service plan data.

Updating service plan information with display names
Figure 1: Updating service plan information with display names

After you’re finished editing the CSV files, they should both have three fields per record. Here’s an extract of the SKU information (top) and Service Plan information (bottom):

SKU information:

SkuId	                                SkuPartNumber	    DisplayName
078d2b04-f1bd-4111-bbd4-b4b1b354cef4	AAD_PREMIUM         Azure AD Premium P1
84a661c4-e949-4bd2-a560-ed7766fcaf2b	AAD_PREMIUM_P2      Azure AD Premium P2
c52ea49f-fe5d-4e95-93ba-1de91d380f89	RIGHTSMANAGEMENT    Azure Information Protection P1
90d8b3f8-712e-4f7b-aa1e-62e7ae6cbe96	SMB_APPS            Business Apps (free)
 
Service Plan Information:

ServicePlanId	                        ServicePlanName	                ServicePlanDisplayName
041fe683-03e4-45b6-b1af-c0cdc516daee	POWER_VIRTUAL_AGENTS_O365_P2    Power Virtual Agents for Office 365 P2
0683001c-0492-4d59-9515-d9a6426b5813	POWER_VIRTUAL_AGENTS_O365_P1    Power Virtual Agents for Office 365 P1
07699545-9485-468e-95b6-2fca3738be01	FLOW_O365_P3	                Flow for Office 365 P3
0898bdbb-73b0-471a-81e5-20f1fe4dd66e	KAIZALA_STANDALONE              Kaizala Standalone
0f9b09cb-62d1-4ff4-9129-43f4996f83f4	FLOW_O365_P1                    Flow for Office 365 P1

Interpreting the values used by Microsoft is an art into itself. The Product names and service plan licensing page is a valuable resource, but sometimes it’s a matter of guesswork based on your knowledge of the products and service plans in use.

Finally, after editing the SKU and Service Plan information, rename the files to match the expected names used in the reporting script. These are:

  • c:\temp\SkuDataComplete.csv: The product data
  • c:\temp\ServicePlanDataComplete.csv: The service plan data.

Of course, you can use whatever names you like if you update the script code to match.

Stage 2: Generate the Report

The steps involved in creating a report are straightforward.

  • Connect to the Microsoft Graph as before.
  • Check that the input data files are available and exit if not.
  • Fetch the set of Azure AD user accounts using the Get-MgUser cmdlet.
  • Loop through the set of user accounts.
  • For each licensed account (some accounts like those used for resource or shared mailboxes don’t need licenses), extract the license data and check if any license has disabled service plans. Check the information against the input data files to get human-friendly names.
  • Record the license information in a PowerShell list.
  • After processing the user accounts, create a HTML report and CSV file containing the license information.

Here’s the main loop (you can download the complete script from GitHub):

ForEach ($User in $Users) {
If ([string]::IsNullOrWhiteSpace($User.AssignedLicenses) -eq $False) { # Only process account if it has some licenses
  Write-Host "Processing" $User.DisplayName
  [array]$LicenseInfo = $Null; [array]$DisabledPlans = $Null
  ForEach ($License in $User.AssignedLicenses) {
     If ($SkuHashTable.ContainsKey($License.SkuId) -eq $True) { # We found a match in the SKU hash table
        $LicenseInfo += $SkuHashTable.Item($License.SkuId) }
     Else { # Nothing doing, so output the SkuID
        $LicenseInfo += $License }
  # Report any disabled service plans in licenses
   If ([string]::IsNullOrWhiteSpace($License.DisabledPlans) -eq $False ) { # Check if disabled service plans in a license
     ForEach ($DisabledPlan in $License.DisabledPlans) { # Try and find what service plan is disabled
       If ($ServicePlanHashTable.ContainsKey($DisabledPlan) -eq $True) { # We found a match in the Service Plans hash table
          $DisabledPlans += $ServicePlanHashTable.Item($DisabledPlan) }
       Else { # Nothing doing, so output the Service Plan ID
          $DisabledPlans += $DisabledPlan }
      } # End ForEach disabled plans
    } # End if check for disabled plans  
   } # End Foreach Licenses
  # Report information
  [string]$DisabledPlans = $DisabledPlans -join ", " 
  [string]$LicenseInfo = $LicenseInfo -join (", ")
  $ReportLine = [PSCustomObject][Ordered]@{  
       User            = $User.DisplayName
       UPN             = $User.UserPrincipalName
       Country         = $User.Country
       Department      = $User.Department
       Title           = $User.JobTitle
       Licenses        = $LicenseInfo
      "Disabled Plans" = $DisabledPlans }
  $Report.Add($ReportLine)
  } #end If account is licensed
  Else { $UnlicensedAccounts++}
} # End ForEach Users

Figure 2 shows the license information as generated by the script. As you can see, we include the country and department properties from the Azure AD accounts to allow sorting of the information based on those properties. Because this is PowerShell code, it’s easy to add any property available in Azure AD user accounts to the set included in the report.

Using Out-GridView to view the license information
Figure 2: Using Out-GridView to view the license information

Figure 3 shows the HTML version of the report.

HTML version of the licensing report for a Microsoft 365 tenant
Figure 3: HTML version of the licensing report for a Microsoft 365 tenant

Apart from anything else, reports like this also highlight deficiencies in user information stored in Azure AD. In Figure 3, you can see that several accounts lack a country property while some lack a job title. These deficiencies affect the information displayed in different places within Office 365 such as user profile cards and the organization view for people available in Teams.

Running the Report

Generating a licensing report could be something you do quarterly or semi-annually. If that’s the case, it’s probably OK to run the script using an interactive Graph SDK connection. On the other hand, if you want a more regular output, consider creating a new registered app in Azure AD to use to run the script (app-only access).

Remember, this code is PowerShell and what I have written is there to explore and explain the principles behind generating a licensing report. Now that you know what to do, let your imagination run riot and let us know what kind of interesting ways you exploit license data.

About the Author

Tony Redmond

Tony Redmond has written thousands of articles about Microsoft technology since 1996. He is the lead author for the Office 365 for IT Pros eBook, the only book covering Office 365 that is updated monthly to keep pace with change in the cloud. Apart from contributing to Practical365.com, Tony also writes at Office365itpros.com to support the development of the eBook. He has been a Microsoft MVP since 2004.

Comments

  1. Martin Sellars

    Switching to new tech is always easier with a good guide… thanks Tony.
    I’m not a big fan of maintaining text files, so I changed that section to retrieve the friendly names on the fly… unsupported APIs can be risky, but this is read only so if it breaks, I can always go back to a file.

    Connect-AzAccount -Tenant “tenantname.onmicrosoft.com”

    $resource = ‘74658136-14ec-4630-ad9b-26e160ff0fc6’;

    $token1 = (Get-AzAccessToken -ResourceUrl $resource -TenantId (Get-AzContext).Tenant.Id.ToString()).token

    $accountskus = Invoke-RestMethod ‘https://main.iam.ad.ext.azure.com/api/AccountSkus’ -Headers @{Authorization = “Bearer $($token1)”; “x-ms-client-request-id” = [guid]::NewGuid().ToString(); “x-ms-client-session-id” = [guid]::NewGuid().ToString()}
    $ImportSkus = $accountskus | select skuid,accountskuid,name | sort skuid -unique
    $ImportServicePlans = $accountskus | %{$_.servicestatuses|%{$_.serviceplan|select serviceplanid,servicename,displayname}} | sort serviceplanid -unique

    $SkuHashTable = @{}
    ForEach ($Line in $ImportSkus) { $SkuHashTable.Add([string]$Line.SkuId, [string]$Line.Name) }
    $ServicePlanHashTable = @{}
    ForEach ($Line2 in $ImportServicePlans) { $ServicePlanHashTable.Add([string]$Line2.ServicePlanId, [string]$Line2.DisplayName) }

    1. Tony Redmond

      I’m glad the post was helpful. All I can do is explain the relevant principles. After that, it’s up to you to decide how to apply the principles in the context of your organization and its business requirements.

  2. Peter Vogl

    Any idea how to get the expiration dates of licenses such as (Get-MsolSubscription).NextLifecycleDate?

  3. Dave

    Thank you for this. I am trying to determine when a specific license, in this case an E3 Security and Mobility license, was added for all users (less than 100).

  4. Brenkster

    Thanks Tony, now it works like a charm.

  5. Brenkster

    I have the same output as Eetu, I’m connected to the beta endpoint.
    The “licenses” row shows “Microsoft.Graph.PowerShell.Models.MicrosoftGraphAssignedLicense”.

    After line 41 ( $SkuHashTable ) I get the following:
    Exception calling “Add” with “2” argument(s): “Item has already been added. Key in dictionary: ” Key being added: ””
    MethodInvocationException:
    Line |
    11 | … icePlans) { $ServicePlanHashTable.Add([string]$Line2.ServicePlanId, [ …

    1. Tony Redmond

      Have you the file c:\temp\ServicePlanDataComplete.csv with Service Plan information? Have you changed it to add something which would cause a duplicate in the hash table (you can’t have a duplicate key – the SKU Id is the key).

      1. Brenkster

        The serviceplandatacomplete looks like this:
        “SkuId”,”SkuPartNumber”,”Displayname”
        “c5928f49-12ba-48f7-ada3-0d743a3601d5″,”VISIOCLIENT”,”VISIO ONLINE PLAN 2″
        The SKUdatacomplete looks like this:
        “ServicePlanId”,”ServicePlanName”,”DisplayName”
        “604ec28a-ae18-4bc6-91b0-11da94504ba9″,”TEAMS_ADVCOMMS”,”Microsoft 365 Advanced Communications”

        1. Tony Redmond

          The script depends on being able to load the contents of two CSV files into hash tables. After loading, the $SkuHashTable should look like this:

          Name Value
          —- —–
          4fb214cb-a430-4a91-9c91-497… Microsoft Teams Rooms Premium
          dab7782a-93b1-4074-8bb1-0e6… Microsoft 365 Business Basic
          c52ea49f-fe5d-4e95-93ba-1de… Azure Information Protection P1
          6a0f6da5-0b87-4190-a6ae-9bb… Windows 10 Enterprise E3
          4b585984-651b-448a-9e53-3b1… Office 365 F3

          And the $ServicePlanHashTable should look like this:

          Name Value
          —- —–
          95b76021-6a53-4741-ab8b-1d1… Common Data Services Office 365 P2
          8a256a2b-b617-496d-b51b-e76… Multi-factor authentication premium
          94065c59-bc8e-4e8b-89e5-513… Microsoft Search
          afa73018-811e-46e9-988f-f75… Common Data Services for Office 365 P3
          531ee2f8-b1cb-453b-9c21-d21… Excel Premium
          6db1f1db-2b46-403f-be40-e39… Customer Key
          31b4e2fc-4cd6-4e7d-9c1b-414… Project for Office 365 P2
          6dc145d6-95dd-4191-b9c3-185… Communications DLP
          8c098270-9dd4-4350-9b30-ba4… Microsoft Cloud App Security for Office 365

          If the hash tables don’t load for some reason, the script won’t be able to resolve SKUIds into display names or Service Plan Ids into display names. The CSV files are loaded in these lines:

          $ImportSkus = Import-CSV c:\temp\SkuDataComplete.csv
          $ImportServicePlans = Import-CSV c:\temp\ServicePlanDataComplete.csv
          $SkuHashTable = @{}
          ForEach ($Line in $ImportSkus) { $SkuHashTable.Add([string]$Line.SkuId, [string]$Line.DisplayName) }
          $ServicePlanHashTable = @{}
          ForEach ($Line2 in $ImportServicePlans) { $ServicePlanHashTable.Add([string]$Line2.ServicePlanId, [string]$Line2.ServicePlanDisplayName) }

          Can you execute the lines outside the script (the code is straightforward PowerShell and doesn’t need to be connected to the Graph or anything else)?

          I assume you built the CSV files using the code in the article?

          1. Brenkster

            The csv was built from the code in the article, I think the line ForEach ($Line2 in $ImportServicePlans) { $ServicePlanHashTable.Add([string]$Line2.ServicePlanId, [string]$Line2.ServicePlanDisplayName) } should be $Line2.DisplayName) } at the end, as there is no ServicePlanDisplayName in the csv.

            After running the 2 import commands I end up with this:
            $SkuHashTable
            Name Value
            —- —–
            Microsoft 365 Advanced Communications

            $ServicePlanHashTable
            Name Value
            —- —–
            VISIO ONLINE PLAN 2
            Could it be the separation character? Mine is , (comma)

          2. Tony Redmond

            I see what happened. I changed the name of the field in my CSV file when I was editing the display names for service plans to make them more readable. I’ve updated the article to make this clear. The contents of the service plan CSV file should look like the following (4 lines of data plus headers shown):

            ServicePlanId ServicePlanName ServicePlanDisplayName
            041fe683-03e4-45b6-b1af-c0cdc516daee POWER_VIRTUAL_AGENTS_O365_P2 Power Virtual Agents for Office 365 P2
            0683001c-0492-4d59-9515-d9a6426b5813 POWER_VIRTUAL_AGENTS_O365_P1 Power Virtual Agents for Office 365 P1
            07699545-9485-468e-95b6-2fca3738be01 FLOW_O365_P3 Flow for Office 365 P3
            0898bdbb-73b0-471a-81e5-20f1fe4dd66e KAIZALA_STANDALONE Kaizala Standalone

        2. Nelson P

          Hi Brenkster,

          I have the same problem. It returns Microsoft.Graph.PowerShell.Models.MicrosoftGraphAssignedLicense,
          What have you done to fix this?

          Thanks,
          NP

          1. Tony Redmond

            Are you sure you have connected using the beta profile? The V1.0 endpoint doesn’t return license information.

  6. Eetu Hämäläinen

    Hi, very nice script. I’m not sure what I’m doing wrong I followed these steps but on the report on the “Licenses” blade I can see only Microsoft.Graph.PowerShell.Models.MicrosoftGraphAssignedLicense, Microsoft.Graph.PowerShell.Models.MicrosoftGraphAssignedLicense. Instead of friendly DisplayName I created as you suggested.

    1. Tony Redmond

      Are you connected to the beta endpoint? The license data is not returned by the V1.0 endpoint.

      1. Eetu Hämäläinen

        Thanks! This worked, also I suggest to run one line at a time and then check the outcomes to see everything works as expected. 🙂

        1. Tony Redmond

          Good to hear that you’re up and running. As to the suggestion, once you’ve created and updated the CSV files, there should be little need to go near them and they can be left alone. They should load accurately every time!

        2. Nelson P

          Hi Eetu,

          I have the same problem. It returns Microsoft.Graph.PowerShell.Models.MicrosoftGraphAssignedLicense,
          What have you done to fix this?

          Thanks,
          NP

Leave a Reply