Public/Export-M365RUserLicenceBreakdown.ps1
#Requires -Modules @{ ModuleName = "ImportExcel"; ModuleVersion = "7.1.2"}, @{ ModuleName = "Microsoft.Graph.Authentication"; ModuleVersion = "2.16.0"}, @{ ModuleName = "Microsoft.Graph.Identity.DirectoryManagement"; ModuleVersion = "2.16.0" }, @{ ModuleName = "Microsoft.Graph.Groups"; ModuleVersion = "2.16.0" }, @{ ModuleName = "Microsoft.Graph.Users"; ModuleVersion = "2.16.0" } function Export-M365RUserLicenceBreakdown { <# .SYNOPSIS Name: Export-UserLicenceBreakdown The purpose of this script is is to export licensing details to excel .DESCRIPTION This script will log in to Microsoft 365 and then create a license report by SKU, with each component level status for each user, where 1 or more is assigned. This then conditionally formats the output to colours and autofilter. .NOTES Version 2.06 Updated: 20211110 V2.06 Fixed zero-value parameter positioning Updated: 20211027 V2.05 Added ability to filter the various outputs by adding an input CSV with the column UserPrincipalName Updated: 20211018 V2.04 Updated script to be use the Export Verb and renamed Noun to be more descriptive Updated: 20210805 V2.03 Updated Overwrite File prompt if files already exist Updated: 20210719 V2.02 Added Active Licenses column in License Summary page Updated: 20210714 V2.01 Added DirectorySync column for each user Updated: 20210604 V2.00 Migrated to the Microsoft Graph PowerShell SDK Module and enabled cross-platform support, also added more SKUs Updated: 20210520 V1.48 1 tab = 4 spaces Updated: 20210520 V1.47 Added more components, renamed some components and added more SKUs Updated: 20210514 V1.46 Added more components, renamed some components and updated/added more SKUs Updated: 20210506 V1.45 Formatted Script to remove whitespace etc. Updated: 20210506 V1.44 Added Windows Update for Business Deployment Service component Updated: 20210323 V1.43 Added more Components Updated: 20210323 V1.42 Added more SKUs (F3, Conf PPM, E5 without Conf) Updated: 20210302 V1.41 Fixed missing New-Object's Updated: 20210223 V1.40 performance improvements for Group Based Licensing - no longer gets all groups; only gets the group once the GUID is found as an assigning group Updated: 20210222 V1.39 Added some EDU Root Level SKUs Updated: 20210222 V1.38 Moved Autofit and Autofilter to fix autofit on GBL column Updated: 20210208 V1.37 No longer out-files for everyline and performance improved Updated: 20201216 V1.36 Added components for Power Automate User with RPA Plan Updated: 20201216 V1.35 Added more SKUs (Multi-Geo, Communications Credits, M365 F1, Power Automate User with RPA Plan & Dynamics 365 Remote Assist) Updated: 20201028 V1.34 Added additional licence components (E5 Suite, PowerApps per IW, Win10 VDAE5) Updated: 20201021 V1.33 Resolved GBL issues Updated: 20201013 V1.32 Redid group based licensing to improve performance. Updated: 20201013 V1.31 Added User Enabled column Updated: 20200929 V1.30 Added RMS_Basic Updated: 20200929 V1.29 Added components for E5 Compliance Updated: 20200929 V1.28 Added code for group assigned and direct assigned licensing Updated: 20200820 V1.27 Added additional Office 365 E1 components Updated: 20200812 V1.26 Added Links to Licensing Sheets on All Licenses Page and move All Licenses Page to be first worksheet Updated: 20200730 V1.25 Added AIP P2 and Project for Office (E3 + E5) Updated: 20200720 V1.24 Added Virtual User component Updated: 20200718 V1.23 Added AAD Basic friendly component name Updated: 20200706 V1.22 Updated SKU error and added additional friendly names Updated: 20200626 V1.21 Updated F1 to F3 as per Microsoft's update Updated: 20200625 V1.20 Added Telephony Virtual User Updated: 20200603 V1.19 Added Switch for no name translation Updated: 20200603 V1.18 Added Telephony SKU's Updated: 20200501 V1.17 Script readability changes Updated: 20200430 V1.16 Made script more readable for Product type within component breakdown Updated: 20200422 V1.15 Formats to Segoe UI 9pt. Removed unnecessary True output. Updated: 20200408 V1.14 Added Teams Exploratory SKU Updated: 20200204 V1.13 Added more SKU's and Components Updated: 20191015 V1.12 Tidied up old comments Updated: 20190916 V1.11 Added more components and SKU's Updated: 20190830 V1.10 Added more components. Updated / renamed refreshed licences Updated: 20190627 V1.09 Added more Components Updated: 20190614 V1.08 Added more SKU's and Components Updated: 20190602 V1.07 Parameters, Comment based help, creates folder and deletes folder for csv's, require statements Release Date: 20190530 Release notes from original: 1.0 - Initital Release 1.1 - Added Switch for additional licence components 1.2 - Added PowerApps Plan 2 Trial for additional licence components 1.3 - Added Freeze Panes to Excel output 1.4 - Added AX7 User Trial, Project Online Professional, Visio Online Plan 2, Office 365 E1, Whiteboard SKUs 1.5 - Added Microsoft Search, Premium Encryption and Teams Commercial Trial, RMS Ad Hoc SKUs 1.6 - Added Microsoft 365 E3 and F1 SKU & performs actions on cell by cell basis for colouring Authors: Mark Lofthouse, Justin Barker & Robin Dadswell .EXAMPLE PS> ./Get-M365UserLicenseBreakdown.ps1 -CompanyName "Contoso" -OutputPath c:\temp This example shows how to run the script interactively for the Company Contoso and gives friendly names for SKUs .EXAMPLE ./Get-M365UserLicenseBreakdown.ps1 -CompanyName "Contos" -OutputPath c:\temp -NoNameTranslation This example shows how to run the script interactively for the Company Contoso and does not give friendly names for SKUs #> [Alias("Export-M365RUserLicenseBreakdown", "Export-M365RMSOLUserLicenceBreakdown", "Export-M365RMSOLUserLicenseBreakdown")] [CmdletBinding(DefaultParameterSetName = 'DefaultParameters')] [OutputType([String])] param ( [Parameter( Mandatory, HelpMessage = 'Name of the Company you are running this against. This will form part of the output file name', Position = 0, ParameterSetName = 'DefaultParameters' )] [Parameter( Mandatory, HelpMessage = 'Name of the Company you are running this against. This will form part of the output file name', Position = 0, ParameterSetName = 'Overwrite' )] [Parameter( Mandatory, HelpMessage = 'Name of the Company you are running this against. This will form part of the output file name', Position = 0, ParameterSetName = 'NoOverWrite' )] [ValidateNotNullOrEmpty()] [string]$CompanyName, [Parameter( Mandatory, HelpMessage = 'The location you would like the final excel file to reside', Position = 1, ParameterSetName = 'DefaultParameters' )] [Parameter( Mandatory, HelpMessage = 'The location you would like the final excel file to reside', Position = 1, ParameterSetName = 'Overwrite' )] [Parameter( Mandatory, HelpMessage = 'The location you would like the final excel file to reside', Position = 1, ParameterSetName = 'NoOverWrite' )] [ValidateScript( { if (!(Test-Path -Path $_)) { throw "The folder $_ does not exist" } else { return $true } })] [System.IO.DirectoryInfo]$OutputPath, [Parameter( HelpMessage = 'Filter breakdown to list of users based on User Principal Name', Position = 2, ParameterSetName = 'DefaultParameters' )] [Parameter( HelpMessage = 'Filter breakdown to list of users based on User Principal Name', Position = 2, ParameterSetName = 'Overwrite' )] [Parameter( HelpMessage = 'Filter breakdown to list of users based on User Principal Name', Position = 2, ParameterSetName = 'NoOverWrite' )] [ValidateScript( { if (!(Test-Path -Path $_)) { throw "The folder $_ does not exist" } else { return $true } })] [ValidateNotNullOrEmpty()] [string]$FilterCSVPath, [Parameter( HelpMessage = 'Secondary workbook created with figure only reports (useful for graphing)', ParameterSetName = 'DefaultParameters' )] [Parameter( HelpMessage = 'Secondary workbook created with figure only reports (useful for graphing)', ParameterSetName = 'Overwrite' )] [Parameter( HelpMessage = 'Secondary workbook created with figure only reports (useful for graphing)', ParameterSetName = 'NoOverWrite' )] [switch]$StatisticsReport, [Parameter( HelpMessage = "This stops translation into Friendly Names of SKU's and Components", ParameterSetName = 'DefaultParameters' )] [Parameter( HelpMessage = "This stops translation into Friendly Names of SKU's and Components", ParameterSetName = 'Overwrite' )] [Parameter( HelpMessage = "This stops translation into Friendly Names of SKU's and Components", ParameterSetName = 'NoOverWrite' )] [switch]$NoNameTranslation, [Parameter( HelpMessage = 'This will remove the output file without prompting in the script', ParameterSetName = 'Overwrite' )] [switch]$OverwriteExistingFile, [Parameter( HelpMessage = 'This will not remove the output file and will exit if the file already exists', ParameterSetName = 'NoOverWrite' )] [switch]$DoNotOverwriteExistingFile ) #Enables Information Stream $initialInformationPreference = $InformationPreference $InformationPreference = 'Continue' Write-Information -MessageData ('Started Script at ' + (Get-Date).ToLongTimeString() + ' on ' + (Get-Date).ToLongDateString()) #Helper function for tidier select of Groups for Group Based Licensing Function Invoke-GroupGuidConversion { [CmdletBinding()] param ( [Parameter(Mandatory)] [String[]] $GroupGuid, [Parameter(Mandatory)] [hashtable] $LicenseGroups ) $output = New-Object System.Collections.Generic.List[System.Object] foreach ($guid in $GroupGuid) { $temp = [PSCustomObject]@{ DisplayName = $LicenseGroups[$guid] } $output.Add($temp) Remove-Variable temp } Write-Output $output } $date = Get-Date -Format yyyyMMdd $OutputPath = Get-Item $OutputPath if (!$OutputPath.FullName.EndsWith([IO.Path]::DirectorySeparatorChar)) { $excelfilepath = $OutputPath.FullName + [IO.Path]::DirectorySeparatorChar } else { $excelfilepath = $OutputPath.FullName } $outputFiles = New-Object System.Collections.Generic.List[String] $XLOutput = $excelfilepath + "$CompanyName - License Breakdown - $date.xlsx" $outputFiles.Add($XLOutput) | Out-Null if ($StatisticsReport) { $statsReportLocation = $excelfilepath + "$CompanyName - License Breakdown - Statistics - $date.xlsx" $outputFiles.Add($statsReportLocation) | Out-Null } foreach ($file in $outputFiles) { if (Test-Path $file -ErrorAction SilentlyContinue) { if ($OverwriteExistingFile) { try { Remove-Item -Path $file -Force -Confirm:$false -ErrorAction Stop } catch { Write-Error "There has been an error removing the file $file - please remove this file and try again" $InformationPreference = $initialInformationPreference return } } elseif ($DoNotOverwriteExistingFile) { $InformationPreference = $initialInformationPreference return "The file $file already exists and you do not want to remove it, please move the file and try again" } else { $promptTitle = "$file already exists" $promptMessage = "Please confirm that you would like to remove $file" $yes = New-Object System.Management.Automation.Host.ChoiceDescription '&Yes', "Yes - Deletes $file" $no = New-Object System.Management.Automation.Host.ChoiceDescription '&No', 'No - Exits the script' $promptOptions = [System.Management.Automation.Host.ChoiceDescription[]]($yes, $no) $promptDecision = $host.ui.PromptForChoice($promptTitle, $promptMessage, $promptOptions, 0) if ($promptDecision -eq 0) { try { Remove-Item -Path $file -Force -Confirm:$false -ErrorAction Stop } catch { Write-Error "There has been an error removing the file $file - please remove this file and try again" $InformationPreference = $initialInformationPreference return } } else { $InformationPreference = $initialInformationPreference return 'Not deleting file, exiting script' } } } } Write-Information 'Checking Connection to Office 365' $test365 = Get-MgOrganization -ErrorAction silentlycontinue $preConnected = $test365 if ($null -eq $test365) { do { Connect-MgGraph -Scopes 'Directory.Read.All' $test365 = Get-MgOrganization -ErrorAction silentlycontinue } while ($null -eq $test365) } else { Write-Information ('You were already signed into the tenant ' + $test365.DisplayName + ' - if this was not intentional then please cancel this script, run Disconnect-Graph and try again') Start-Sleep -Seconds 5 } $allowedScopes = 'Directory.ReadWrite.All', 'Directory.Read.All' foreach ($scope in (Get-MgContext).Scopes) { if ($allowedScopes -contains $scope) { $acceptableScopes = $true break } } if (!$acceptableScopes) { Write-Error 'The scope of the current connection is not suitable for the permissions required within this script - please run Disconnect-Graph and try again' Start-Sleep -Seconds 10 return } Write-Information ('Connected to Microsoft Graph for the tenant with a display name of ' + $test365.DisplayName) # Get a list of all licences that exist within the tenant #TODO: Test with a large number of different SKUs as Get-MGSubscribedSku doesn't support custom page sizes $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() $licenseType = Get-MgSubscribedSku <# Replace the above with the below if only a single SKU is required $licenseType = Get-MgSubscribedSku -All | Where-Object {$_.AccountSkuID -like "*Power*"} #> # Get all licences for a summary view if ($NoNameTranslation) { $licenseSummary = $licenseType | Select-Object @{Name = 'Account License SKU'; Expression = { $($_.SkuPartNumber) } }, @{ Name = 'Total Licenses'; Expression = { $_.PrepaidUnits.Enabled + $_.PrepaidUnits.Warning } }, @{ Name = 'Active Licenses'; Expression = { $_.PrepaidUnits.Enabled } }, @{ Name = 'Licenses In Warning'; Expression = { $_.PrepaidUnits.Warning } }, @{ Name = 'Consumed Units'; Expression = { $_.ConsumedUnits } }, @{ Name = 'Applies To'; Expression = { $_.AppliesTo } } | Sort-Object 'Account License SKU' } else { $licenseSummary = $licenseType | Select-Object @{Name = 'Account License SKU'; Expression = { (Get-LicenceTranslation -SKU $_.SkuPartNumber -LicenceLevel Root) } }, @{ Name = 'Total Licenses'; Expression = { $_.PrepaidUnits.Enabled + $_.PrepaidUnits.Warning } }, @{ Name = 'Active Licenses'; Expression = { $_.PrepaidUnits.Enabled } }, @{ Name = 'Licenses In Warning'; Expression = { $_.PrepaidUnits.Warning } }, @{ Name = 'Consumed Units'; Expression = { $_.ConsumedUnits } }, @{ Name = 'Applies To'; Expression = { $_.AppliesTo } } | Sort-Object 'Account License SKU' } $licenseSummary | Export-Excel -Path $XLOutput -WorksheetName 'Tenant License Summary' -FreezeTopRowFirstColumn -AutoSize if ($StatisticsReport) { $licenseSummary | Export-Excel -Path $statsReportLocation -WorksheetName 'Tenant License Summary' -FreezeTopRowFirstColumn -AutoSize } $licenseType = $licenseType | Where-Object { $_.ConsumedUnits -ge 1 -and $_.AppliesTo -eq 'User' } #get all users with licence Write-Information 'Retrieving all licensed users - this may take a while.' if ($FilterCSVPath) { $filteredUsers = Import-Csv -Path $FilterCSVPath $allLicensedUsers = Get-MgUser -All -Property Id, DisplayName, UserPrincipalName, onPremisesSyncEnabled, AccountEnabled, LicenseAssignmentStates, AssignedLicenses | Where-Object { $null -ne $_.AssignedLicenses -and $_.UserPrincipalName -in $filteredUsers.UserPrincipalName } } else { $allLicensedUsers = Get-MgUser -All -Property Id, DisplayName, UserPrincipalName, onPremisesSyncEnabled, AccountEnabled, LicenseAssignmentStates, AssignedLicenses | Where-Object { $null -ne $_.AssignedLicenses } } $licensedGroups = @{} # Loop through all licence types found in the tenant foreach ($license in $licenseType) { Write-Information ('Gathering users with the following subscription: ' + $license.SkuPartNumber) # Gather users for this particular AccountSku from pre-existing array of users $users = $allLicensedUsers | Where-Object { $_.AssignedLicenses.SkuId -contains $license.SkuId } if ($NoNameTranslation) { $rootLicence = ($($license.SkuPartNumber)) } else { $rootLicence = (Get-LicenceTranslation -SKU $($license.SkuPartNumber) -LicenceLevel Root) } $licensedUsers = New-Object System.Collections.Generic.List[System.Object] # Loop through all users and write them to the CSV file foreach ($user in $users) { Write-Verbose ('Processing ' + $user.displayname) $thislicense = Get-MgUserLicenseDetail -UserId $user.Id -All | Where-Object { $_.SkuId -eq $license.SkuId } $userHashTable = @{ DisplayName = $user.DisplayName UserPrincipalName = $user.UserPrincipalName AccountEnabled = $user.AccountEnabled DirectorySynced = $user.onPremisesSyncEnabled AccountSKU = $rootLicence } $licenseAssignmentStates = $user.LicenseAssignmentStates | Where-Object { $_.SkuId -eq $license.SkuId } if ($null -eq $licenseAssignmentStates.AssignedByGroup ) { $userHashTable['DirectAssigned'] = $true $userHashTable['GroupsAssigning'] = $false } else { $groups = $licenseAssignmentStates.AssignedByGroup | Where-Object { $null -ne $_ } foreach ($group in $groups) { if ($null -eq $licensedGroups[$group]) { $getGroup = Get-MgGroup -GroupId $group $licensedGroups[$group] = $getGroup.DisplayName } } $groups = (Invoke-GroupGuidConversion -GroupGuid $groups -LicenseGroups $licensedGroups).DisplayName -Join "`r`n" if (($licenseAssignmentStates | Where-Object { $null -eq $_.AssignedByGroup }).Count -ge 1) { $userHashTable['DirectAssigned'] = $true } else { $userHashTable['DirectAssigned'] = $false } $userHashTable['GroupsAssigning'] = $groups } foreach ($row in $($thislicense.ServicePlans)) { $serviceName = $( if ($NoNameTranslation) { $($row.ServicePlanName) } else { Get-LicenceTranslation -SKU $($row.ServicePlanName) -LicenceLevel Component } ) $userHashTable[$serviceName] = $row.ProvisioningStatus } $licensedUsers.Add([PSCustomObject]$userHashTable) | Out-Null } if ($StatisticsReport) { $licenceStats = New-Object System.Collections.Generic.List[System.Object] foreach ($status in 'Success', 'Pending', 'Disabled') { $componentHashTable = @{ Status = $status } foreach ($component in $($thislicense.ServicePlans)) { $serviceName = $( if ($NoNameTranslation) { $($component.ServicePlanName) } else { Get-LicenceTranslation -SKU $($component.ServicePlanName) -LicenceLevel Component } ) $componentHashTable[$serviceName] = ($licensedUsers.$serviceName | Where-Object { $_ -like "$status*" }).count } $licenceStats.Add([PSCustomObject]$componentHashTable) | Out-Null } $licenceStats | Select-Object Status, * -ErrorAction SilentlyContinue | Export-Excel -Path $statsReportLocation -WorksheetName $RootLicence -FreezeTopRowFirstColumn } $output = @($licensedUsers | Select-Object DisplayName, UserPrincipalName, AccountEnabled, DirectorySynced, AccountSKU, DirectAssigned, GroupsAssigning, * -ErrorAction SilentlyContinue) if ($output.count -gt 0) { $output | Export-Excel -Path $XLOutput -WorksheetName (Get-TabName -LicenceName $RootLicence) -FreezeTopRowFirstColumn -AutoSize -AutoFilter } } Write-Information 'Formatting Excel Workbook' $excel = Open-ExcelPackage -Path $XLOutput foreach ($worksheet in $excel.Workbook.Worksheets) { $fullRange = ($worksheet.Dimension | Select-Object Address).Address $worksheet.Select($fullRange) $worksheet.SelectedRange.Style.Font.Name = 'Segoe UI' $worksheet.SelectedRange.Style.Font.Size = 9 if ($worksheet.Name -eq 'Tenant License Summary') { $formattingRange = "A2:A$($worksheet.Dimension.Rows)" $worksheet.Select($formattingRange) foreach ($cell in $worksheet.SelectedRange) { $tabName = Get-TabName -LicenceName $($cell.Value) if ($excel.Workbook.Worksheets | Where-Object { $_.name -eq $tabName }) { $referenceAddress = "`'$($tabName)`'!A1" $display = $($cell.Value) $hyperlink = New-Object -TypeName OfficeOpenXml.ExcelHyperLink -ArgumentList $referenceAddress, $display $cell.Hyperlink = $hyperlink $cell.Style.Font.Color.SetColor([System.Drawing.Color]::Blue) $cell.Style.Font.UnderLine = $true } } } else { $conditionalFormattingRange = $fullRange.Replace('A1', 'G2') Add-ConditionalFormatting -Worksheet $worksheet -RuleType ContainsText -ConditionValue 'Success' -BackgroundColor ([System.Drawing.Color]::FromArgb(204, 255, 204)) -BackgroundPattern Solid -ForegroundColor ([System.Drawing.Color]::FromArgb(0, 51, 0)) -Range $conditionalFormattingRange Add-ConditionalFormatting -Worksheet $worksheet -RuleType ContainsText -ConditionValue 'Pending' -BackgroundColor ([System.Drawing.Color]::FromArgb(255, 255, 153)) -BackgroundPattern Solid -ForegroundColor ([System.Drawing.Color]::FromArgb(128, 128, 0)) -Range $conditionalFormattingRange Add-ConditionalFormatting -Worksheet $worksheet -RuleType ContainsText -ConditionValue 'Disabled' -BackgroundColor ([System.Drawing.Color]::FromArgb(255, 153, 204)) -BackgroundPattern Solid -ForegroundColor ([System.Drawing.Color]::FromArgb(128, 0, 0)) -Range $conditionalFormattingRange } $worksheet.Select($fullRange) $worksheet.SelectedRange.AutoFitColumns() Set-Format -Address $worksheet.Column(6) -WrapText $worksheet.Select('A1') $excel.workbook.View.ActiveTab = 0 $excel.Save() } $excel | Close-ExcelPackage if (!$preConnected) { Write-Information 'You were not connected prior to running this script so disconnecting' Disconnect-Graph } else { Write-Information 'You were connected at the start of this script, skipping disconnection' } $stopwatch.Stop() $timeSpan = $stopwatch.Elapsed $timeTaken = '{0:00}:{1:00}:{2:00}.{3:00}' -f $timeSpan.Hours, $timeSpan.Minutes, $timeSpan.Seconds, $timeSpan.Milliseconds Write-Information ("Script completed in $($timetaken)") $InformationPreference = $initialInformationPreference $joinString = $( if ($outputFiles.Count -gt 1) { ' & ' } else { ', ' } ) $returnString = $('Results available in {0}' -f ($outputFiles -join $joinString)) return $returnString } |