Get-M365LicensingReport.ps1
|
<#PSScriptInfo .VERSION 1.0.0 .GUID 8e6c8ab7-0ea3-4749-bde0-f9b090d2794d .AUTHOR cloudguyus .COMPANYNAME .COPYRIGHT .TAGS Microsoft365 Entra Graph Licensing M365 Sku .LICENSEURI https://github.com/cloudguyus/Entra-LicensingReport/blob/main/LICENSE .PROJECTURI https://github.com/cloudguyus/Entra-LicensingReport .ICONURI .EXTERNALMODULEDEPENDENCIES Microsoft.Graph.Authentication,Microsoft.Graph.Identity.DirectoryManagement .REQUIREDSCRIPTS .EXTERNALSCRIPTDEPENDENCIES .RELEASENOTES Initial release as Get-M365LicensingReport. Graph-connected M365/Entra licensing inventory with optional CSV export. .PRIVATEDATA #> #Requires -Version 7.2 #Requires -Modules Microsoft.Graph.Authentication, Microsoft.Graph.Identity.DirectoryManagement <# .SYNOPSIS Reports Microsoft 365 / Entra subscribed SKU licensing usage for a tenant. .DESCRIPTION Connects to Microsoft Graph, downloads Microsoft's product-name mapping CSV, and returns (and optionally exports) license inventory: purchased, consumed, available, and percent used — with friendly product names. Requires PowerShell 7.2 or later (Windows, macOS, or Linux). Install from the PowerShell Gallery (after publish): Install-Script -Name Get-M365LicensingReport -Scope CurrentUser .PARAMETER TenantId Optional Entra tenant ID (GUID) or verified domain. Used when connecting. .PARAMETER AuthMode How to authenticate to Microsoft Graph: Interactive - Browser sign-in (default) DeviceCode - Device code flow (headless / remote sessions) ManagedIdentity - Azure managed identity (Automation, VM, App Service) None - Use an existing Connect-MgGraph session .PARAMETER ClientId Optional app (client) ID for Interactive or DeviceCode when using a custom public client registration. Defaults to the Microsoft Graph PowerShell multi-tenant app when omitted. .PARAMETER OutputPath Optional path to write a CSV report (e.g. ./LicensingReport.csv). .PARAMETER Disconnect Disconnect the Graph session when the script finishes (only if this script established the connection). .EXAMPLE ./Get-M365LicensingReport.ps1 Interactive sign-in; prints a table to the host. .EXAMPLE Get-M365LicensingReport.ps1 -TenantId contoso.onmicrosoft.com -OutputPath ./report.csv .EXAMPLE Connect-MgGraph -Scopes 'Organization.Read.All' Get-M365LicensingReport.ps1 -AuthMode None .EXAMPLE Get-M365LicensingReport.ps1 -AuthMode ManagedIdentity -OutputPath ./report.csv #> [CmdletBinding()] param( [Parameter()] [string]$TenantId, [Parameter()] [ValidateSet('Interactive', 'DeviceCode', 'ManagedIdentity', 'None')] [string]$AuthMode = 'Interactive', [Parameter()] [string]$ClientId, [Parameter()] [string]$OutputPath, [Parameter()] [switch]$Disconnect ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' # Graph permission required for Get-MgSubscribedSku $script:RequiredScopes = @('Organization.Read.All') # Official Microsoft product names / service plan identifiers CSV $script:ProductNamesCsvUrl = 'https://download.microsoft.com/download/e/3/e/e3e9faf2-f28b-490a-9ada-c6089a1fc5b0/Product%20names%20and%20service%20plan%20identifiers%20for%20licensing.csv' $script:ConnectedByThisScript = $false function Connect-LicensingReportGraph { [CmdletBinding()] param( [string]$TenantId, [ValidateSet('Interactive', 'DeviceCode', 'ManagedIdentity', 'None')] [string]$AuthMode, [string]$ClientId ) if ($AuthMode -eq 'None') { $context = Get-MgContext if (-not $context) { throw "AuthMode is 'None' but there is no active Microsoft Graph connection. Run Connect-MgGraph first, or use -AuthMode Interactive." } Write-Verbose "Using existing Graph session for tenant '$($context.TenantId)' as '$($context.Account)'." return } $connectParams = @{ NoWelcome = $true } if ($TenantId) { $connectParams['TenantId'] = $TenantId } if ($ClientId) { $connectParams['ClientId'] = $ClientId } switch ($AuthMode) { 'Interactive' { $connectParams['Scopes'] = $script:RequiredScopes Write-Verbose 'Connecting to Microsoft Graph (interactive)...' } 'DeviceCode' { $connectParams['Scopes'] = $script:RequiredScopes $connectParams['UseDeviceCode'] = $true Write-Verbose 'Connecting to Microsoft Graph (device code)...' } 'ManagedIdentity' { $connectParams['Identity'] = $true Write-Verbose 'Connecting to Microsoft Graph (managed identity)...' } } try { Connect-MgGraph @connectParams | Out-Null } catch { throw "Failed to connect to Microsoft Graph ($AuthMode). $_" } $script:ConnectedByThisScript = $true $context = Get-MgContext if (-not $context) { throw 'Connect-MgGraph completed but no Graph context is available.' } Write-Verbose "Connected to tenant '$($context.TenantId)'." } function Get-M365LicensingTable { [CmdletBinding()] param() $tempFile = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath ("M365LicensingTable_{0}.csv" -f [guid]::NewGuid().ToString('N')) try { Write-Verbose 'Downloading product names CSV from Microsoft...' Invoke-WebRequest -Uri $script:ProductNamesCsvUrl -UseBasicParsing -OutFile $tempFile $table = Import-Csv -LiteralPath $tempFile if (-not $table -or @($table).Count -eq 0) { throw 'Product names CSV downloaded but contained no rows.' } $sample = $table | Select-Object -First 1 $props = $sample.PSObject.Properties.Name foreach ($required in @('GUID', 'Product_Display_Name')) { if ($props -notcontains $required) { throw "Product names CSV is missing expected column '$required'. Columns found: $($props -join ', ')" } } return $table } catch { throw "Failed to retrieve or parse Microsoft product names CSV from '$($script:ProductNamesCsvUrl)'. $_" } finally { if (Test-Path -LiteralPath $tempFile) { Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue } } } function Get-M365TenantSkus { [CmdletBinding()] param() try { $skus = Get-MgSubscribedSku -All -Property @('SkuId', 'SkuPartNumber', 'ConsumedUnits', 'PrepaidUnits') } catch { throw "Failed to read subscribed SKUs from Microsoft Graph. Ensure you are connected and have Organization.Read.All. $_" } if (-not $skus) { Write-Warning 'No subscribed SKUs were returned for this tenant.' return @() } foreach ($sku in $skus) { [PSCustomObject]@{ SkuId = $sku.SkuId SkuPartNumber = $sku.SkuPartNumber ConsumedUnits = [int]$sku.ConsumedUnits EnabledUnits = [int]$(if ($null -ne $sku.PrepaidUnits.Enabled) { $sku.PrepaidUnits.Enabled } else { 0 }) SuspendedUnits = [int]$(if ($null -ne $sku.PrepaidUnits.Suspended) { $sku.PrepaidUnits.Suspended } else { 0 }) WarningUnits = [int]$(if ($null -ne $sku.PrepaidUnits.Warning) { $sku.PrepaidUnits.Warning } else { 0 }) } } } function Get-M365LicensingUsage { [CmdletBinding()] param( [Parameter(Mandatory)] [object[]]$M365LicensingTable, [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$TenantSkus ) $friendlyNameLookup = @{} foreach ($row in $M365LicensingTable) { if ($row.GUID) { $friendlyNameLookup[$row.GUID] = $row.Product_Display_Name } } $usage = foreach ($sku in $TenantSkus) { $productName = if ($friendlyNameLookup.ContainsKey([string]$sku.SkuId)) { $friendlyNameLookup[[string]$sku.SkuId] } else { $sku.SkuPartNumber } $enabled = [int]$sku.EnabledUnits $consumed = [int]$sku.ConsumedUnits $available = $enabled - $consumed $percentageUsed = if ($enabled -ne 0) { [math]::Round(($consumed / $enabled) * 100, 2) } else { $null } [PSCustomObject]@{ ProductName = $productName SkuPartNumber = $sku.SkuPartNumber SkuId = $sku.SkuId PurchasedUnits = $enabled ConsumedUnits = $consumed AvailableUnits = $available SuspendedUnits = [int]$sku.SuspendedUnits WarningUnits = [int]$sku.WarningUnits PercentageUsed = $percentageUsed } } # Sort by product name for a stable, readable report $usage | Sort-Object -Property ProductName } function Export-LicensingReport { [CmdletBinding()] param( [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Report, [Parameter(Mandatory)] [string]$Path ) $directory = Split-Path -Parent -Path $Path if ($directory -and -not (Test-Path -LiteralPath $directory)) { New-Item -ItemType Directory -Path $directory -Force | Out-Null } $Report | Export-Csv -LiteralPath $Path -NoTypeInformation -Encoding utf8 Write-Host "Report exported to: $Path" } # --- Main --- try { Connect-LicensingReportGraph -TenantId $TenantId -AuthMode $AuthMode -ClientId $ClientId $licensingTable = Get-M365LicensingTable $tenantSkus = @(Get-M365TenantSkus) $report = @(Get-M365LicensingUsage -M365LicensingTable $licensingTable -TenantSkus $tenantSkus) if ($OutputPath) { Export-LicensingReport -Report $report -Path $OutputPath } # Host-friendly view; full objects still returned for piping $report | Select-Object ProductName, SkuPartNumber, PurchasedUnits, ConsumedUnits, AvailableUnits, PercentageUsed | Format-Table -AutoSize | Out-Host # Return objects to the pipeline for further processing $report } finally { if ($Disconnect -and $script:ConnectedByThisScript) { Write-Verbose 'Disconnecting Microsoft Graph session...' Disconnect-MgGraph | Out-Null } } |