Entra/Get-LicenseReport.ps1
|
<#
.SYNOPSIS Generates a report of Microsoft 365 license assignments and availability. .DESCRIPTION Queries Microsoft Graph for all subscribed SKUs in the tenant and reports on total, assigned, and available license counts. Optionally exports per-user license assignments. Essential for client license audits and cost optimization. Requires Microsoft.Graph.Users module and Organization.Read.All permission. .PARAMETER IncludeUserDetail Include per-user license assignment detail in the output. Without this flag, only the SKU summary is returned. .PARAMETER OutputPath Optional path to export results as CSV. If not specified, results are returned to the pipeline. .EXAMPLE PS> . .\Common\Connect-Service.ps1 PS> Connect-Service -Service Graph -Scopes 'Organization.Read.All' PS> .\Entra\Get-LicenseReport.ps1 Displays a summary of all license SKUs with total, assigned, and available counts. .EXAMPLE PS> .\Entra\Get-LicenseReport.ps1 -IncludeUserDetail -OutputPath '.\license-report.csv' Exports per-user license assignments to CSV. #> [CmdletBinding()] param( [Parameter()] [switch]$IncludeUserDetail, [Parameter()] [string]$OutputPath ) $ErrorActionPreference = 'Stop' # Verify Graph connection if (-not (Assert-GraphConnection)) { return } # Ensure required Graph submodules are loaded (PS 7.x does not auto-import) Import-Module -Name Microsoft.Graph.Identity.DirectoryManagement -ErrorAction Stop Import-Module -Name Microsoft.Graph.Users -ErrorAction Stop # ------------------------------------------------------------------ # Build SKU friendly-name lookup # Try Microsoft's live CSV first → bundled CSV fallback → raw SkuPartNumber. # Source: https://learn.microsoft.com/en-us/entra/identity/users/licensing-service-plan-reference # To refresh the bundled copy: run assets/Update-SkuCsv.ps1 # ------------------------------------------------------------------ $skuFriendlyNames = @{} $skuCsvUrl = 'https://download.microsoft.com/download/e/3/e/e3e9faf2-f28b-490a-9ada-c6089a1fc5b0/Product%20names%20and%20service%20plan%20identifiers%20for%20licensing.csv' # Helper — parse a CSV (text or file) into the lookup hashtable function Import-SkuCsv { param([Parameter(Mandatory)][object[]]$CsvRows) foreach ($row in $CsvRows) { $stringId = $row.String_Id $displayName = $row.Product_Display_Name if ($stringId -and $displayName -and -not $skuFriendlyNames.ContainsKey($stringId)) { $skuFriendlyNames[$stringId] = $displayName } } } # 1) Try live download (latest from Microsoft) try { Write-Verbose "Downloading SKU friendly-name list from Microsoft..." $csvText = (Invoke-WebRequest -Uri $skuCsvUrl -UseBasicParsing -TimeoutSec 10).Content Import-SkuCsv -CsvRows ($csvText | ConvertFrom-Csv) Write-Verbose "Loaded $($skuFriendlyNames.Count) SKU friendly names from Microsoft" } catch { Write-Verbose "Could not download SKU list ($($_.Exception.Message)). Trying bundled copy." } # 2) Fill gaps from bundled CSV (assets/sku-friendly-names.csv) if ($skuFriendlyNames.Count -eq 0) { $bundledCsv = Join-Path -Path $PSScriptRoot -ChildPath '..\assets\sku-friendly-names.csv' if (Test-Path -Path $bundledCsv) { try { Import-SkuCsv -CsvRows (Import-Csv -Path $bundledCsv) Write-Verbose "Loaded $($skuFriendlyNames.Count) SKU friendly names from bundled CSV" } catch { Write-Verbose "Could not parse bundled SKU CSV: $($_.Exception.Message)" } } } try { Write-Verbose "Retrieving subscribed SKUs..." $skus = Get-MgSubscribedSku -All } catch { Write-Error "Failed to retrieve license information: $_" return } if (-not $IncludeUserDetail) { # SKU summary only $report = foreach ($sku in $skus) { $friendlyName = $skuFriendlyNames[$sku.SkuPartNumber] if (-not $friendlyName) { $friendlyName = $sku.SkuPartNumber } [PSCustomObject]@{ License = $friendlyName SkuPartNumber = $sku.SkuPartNumber Total = $sku.PrepaidUnits.Enabled Assigned = $sku.ConsumedUnits Available = $sku.PrepaidUnits.Enabled - $sku.ConsumedUnits Suspended = $sku.PrepaidUnits.Suspended Warning = $sku.PrepaidUnits.Warning } } $report = @($report) | Sort-Object -Property License if ($OutputPath) { $report | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8 Write-Output "Exported license summary ($($report.Count) SKUs) to $OutputPath" } else { Write-Output $report } } else { # Per-user license detail Write-Verbose "Retrieving per-user license assignments..." try { $users = Get-MgUser -Property 'Id','DisplayName','UserPrincipalName','AssignedLicenses' -All } catch { Write-Error "Failed to retrieve user license data: $_" return } # Build a SkuId-to-name lookup $skuLookup = @{} foreach ($sku in $skus) { $friendlyName = $skuFriendlyNames[$sku.SkuPartNumber] if (-not $friendlyName) { $friendlyName = $sku.SkuPartNumber } $skuLookup[$sku.SkuId] = $friendlyName } $report = foreach ($user in $users) { if ($user.AssignedLicenses.Count -eq 0) { continue } $licenseNames = foreach ($license in $user.AssignedLicenses) { $name = $skuLookup[$license.SkuId] if (-not $name) { $name = $license.SkuId } $name } [PSCustomObject]@{ DisplayName = $user.DisplayName UserPrincipalName = $user.UserPrincipalName LicenseCount = $user.AssignedLicenses.Count Licenses = $licenseNames -join '; ' } } $report = @($report) | Sort-Object -Property DisplayName if ($OutputPath) { $report | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8 Write-Output "Exported per-user license detail ($($report.Count) users) to $OutputPath" } else { Write-Output $report } } |