GetLicenseInfo.psm1

Function Get-ProductTable
{
    param()

    begin
    {
        #see https://learn.microsoft.com/en-us/azure/active-directory/enterprise-users/licensing-service-plan-reference
        $uri = 'https://download.microsoft.com/download/e/3/e/e3e9faf2-f28b-490a-9ada-c6089a1fc5b0/Product%20names%20and%20service%20plan%20identifiers%20for%20licensing.csv'
    }
    process
    {
        $tree = @{}
        $rsp = Invoke-WebRequest -Uri $uri
        $displayNamesTable = [System.Text.Encoding]::UTF8.GetString($rsp.Content) | ConvertFrom-Csv -Delimiter ','
        foreach($descriptor in $displayNamesTable)
        {
            if($null -eq $tree[$descriptor.GUID])
            {
                $tree[$descriptor.GUID] = @{
                    Name = $descriptor.String_Id
                    DisplayName = $descriptor.Product_Display_Name
                    Description = "$($descriptor.Product_Display_Name) ($($descriptor.String_Id))"
                    Plans = @{}
                }
            }
            $product = $tree[$descriptor.GUID]
            if($null -eq $product.Plans[$descriptor.Service_Plan_Id])
            {
                $product.Plans[$descriptor.Service_Plan_Id] = @{
                    Name = $descriptor.Service_Plan_Name
                    DisplayName = $descriptor.Service_Plans_Included_Friendly_Names
                    Description = "$($descriptor.Service_Plans_Included_Friendly_Names) ($($descriptor.Service_Plan_Name))"
                }
            }
        }
        $tree
    }
}

function Connect-Tenant
{
    <#
.SYNOPSIS
    Sets up the connection to tenant
 
.DESCRIPTION
    Sets up connection parameters to tenant (and to internet)
 
.OUTPUTS
    List of tenants already connected to, along with authentication provider
#>


    param
    (
        [Parameter(ParameterSetName = 'PublicClient')]
        [Parameter(ParameterSetName = 'ConfidentialClientWithSecret')]
        [Parameter(ParameterSetName = 'ConfidentialClientWithCertificate')]
        [string]
            #Id of tenant where to autenticate the user. Can be tenant id, or any registerd DNS domain
            #Not necessary when connecting with Managed Identity, otherwise ncesessary
        $TenantId,

        [Parameter()]
        [string]
            #ClientId of application that gets token to Graph API.
            #Default: well-known clientId for Azure PowerShell
        $ClientId = (Get-AadDefaultClientId),

        [Parameter()]
        [Uri]
            #RedirectUri for the client
            #Default: default MSAL redirect Uri
        $RedirectUri,

        [Parameter(ParameterSetName = 'ConfidentialClientWithSecret')]
        [string]
            #Client secret for ClientID
            #Used to get access as application rather than as calling user
        $ClientSecret,

        [Parameter(ParameterSetName = 'ConfidentialClientWithCertificate')]
        [System.Security.Cryptography.X509Certificates.X509Certificate2]
            #Authentication certificate for ClientID
            #Used to get access as application rather than as calling user
        $X509Certificate,

        [Parameter()]
        [string]
            #AAD auth endpoint
            #Default: endpoint for public cloud
        $LoginApi = 'https://login.microsoftonline.com',
        
        [Parameter(Mandatory, ParameterSetName = 'PublicClient')]
        [ValidateSet('Interactive', 'DeviceCode', 'WIA', 'WAM')]
        [string]
            #How to authenticate client
        $AuthMode,
        
        [Parameter(ParameterSetName = 'PublicClient')]
        [string]
            #Username hint for interactive authentication flows
        $UserNameHint,

        [Parameter(ParameterSetName = 'MSI')]
        [Switch]
            #tries to get parameters from environment and token from internal endpoint provided by Azure MSI support
        $UseManagedIdentity,

        [Parameter()]
        [System.Net.WebProxy]
            #Proxy configuration for cases when internet is begind proxy
        $Proxy
    )

    process
    {
        if($null -ne $proxy)
        {
            [system.net.webrequest]::defaultwebproxy = $proxy
        }
        try {
                switch($PSCmdlet.ParameterSetName)
                {
                    'PublicClient' {
                        $script:AuthFactory = New-AadAuthenticationFactory -TenantId $TenantId -ClientId $ClientId -RedirectUri $RedirectUri -LoginApi $LoginApi -AuthMode $AuthMode -DefaultUsername $UserNameHint
                        break;
                    }
                    'ConfidentialClientWithSecret' {
                        $script:AuthFactory = New-AadAuthenticationFactory -TenantId $TenantId -ClientId $ClientId -RedirectUri $RedirectUri -ClientSecret $clientSecret -LoginApi $LoginApi
                        break;
                    }
                    'ConfidentialClientWithCertificate' {
                        $script:AuthFactory = New-AadAuthenticationFactory -TenantId $TenantId -ClientId $ClientId -X509Certificate $X509Certificate -LoginApi $LoginApi
                        break;
                    }
                    'MSI' {
                        $script:AuthFactory = New-AadAuthenticationFactory -ClientId $clientId -UseManagedIdentity
                        break;
                    }
                }
                $script:AuthFactory
        }
        catch {
            throw
        }
    }
}

Function Get-Info
{
<#
.SYNOPSIS
    Command retrieves license information for user
 
.DESCRIPTION
    Command retrieves license information for user and prepares formatted report. Command makes use of diplay names of products and licenses published by Microsoft as separate downloadable.
 
.LINK
    https://github.com/jformacek/GetLicenseInfo
 
.EXAMPLE
$user = Get-LicenseInfo -UserPrincipalName user@domain.com -TenantId $domain.com
#display assigned licenses
$user.AssignedLicenses
 
Command above retrieves licenses for given user and shows them
 
.EXAMPLE
$user = Get-LicenseInfo -UserPrincipalName user@domain.com -TenantId $domain.com
#display license report, sorted by assigned time
$user.AssignedLicenses.Report('assignedDateTime')
 
Command above retrieves licenses for given user and shows them as report, sorted by SKU assigned date
 
.EXAMPLE
$users = Get-LicenseInfo -TenantId mydomain.com -UpnStartsWith a -ShowProogress
$user.AssignedLicenses
 
Command above gets and shows license info about all users whose UPN starts with 'a', showing progress
#>

[CmdletBinding()]
param
    (
        [Parameter(Mandatory,ValueFromPipeline,ParameterSetName='SingleUser')]
        [ValidateScript({$_ -match '@'})]
        [string]
        #UPN of user. Multiple UPNs can be sent from pipeline
        $UserPrincipalName,
        [Parameter(ParameterSetName='MultipleUsers')]
        [int]
        #page size for graph api call
        $BatchSize = 100,
        [Parameter(ParameterSetName='MultipleUsers')]
        [string]
        #limit number of returned users by specifying beginning of UPN
        $UpnStartsWith,
        [switch]
        #whether to define Report() function on assigned licenses on user
        $CreateReport,
        [switch]
        #whether to show progress UI
        $ShowProgress,
        [switch]
        #whether each assigned license sku shall also contain Upn of owning user
        $IncludeUpnInAssignedLicenses,
        #only process SKUs in the list (SKU id or SKU displayName)
        [string[]]$IncludedSkus=@(),
        #omit SKUs in the list (SKU id or SKU displayName) from results
        [string[]]$ExcludedSkus=@()
    )

    begin
    {

        switch($PsCmdlet.ParameterSetName)
        {
            'MultipleUsers' {
                if($BatchSize -gt 999 -or $BatchSize -lt 1)
                {
                    throw 'BatchSize must be between 1 and 999'
                }
                break;
            }
        }

        if($null -eq $script:AuthFactory)
        {
            throw "Please call 'Connect-LicenseTenant' first"
        }

        if($null -eq $script:prods) {$script:prods = Get-ProductTable}

        if($null -eq $script:orgSkus)
        {
            $rsp = Invoke-RestMethod -Uri 'https://graph.microsoft.com/v1.0/subscribedSkus' -Headers (Get-AadToken -AsHashTable -Factory $script:authFactory -Scopes 'https://graph.microsoft.com/.default')
            $script:orgSkus = $rsp.Value
        }
        if($IncludedSkus.Count -gt 0)
        {
            $orgSubscribedSkus = $script:orgSkus.Where{($_.skuId -in $IncludedSkus) -or ($_.skuPartNumber -in $IncludedSkus)}
        }
        else
        {
            if($ExcludedSkus.Count -gt 0)
            {
                $orgSubscribedSkus = $script:orgSkus.Where{($_.skuId -notin $ExcludedSkus) -or ($_.skuPartNumber -notin $ExcludedSkus)}
            }
            else
            {
                $orgSubscribedSkus = $script:orgSkus
            }
        }
    }

    process
    {
        switch($PsCmdlet.ParameterSetName)
        {
            'SingleUser' {
                $user = Invoke-RestMethod `
                    -Uri "https://graph.microsoft.com/v1.0/users/$UserPrincipalName`?`$select=id,userPrincipalName,assignedLicenses,assignedPlans" `
                    -Headers (Get-AadToken -AsHashTable -Factory $script:authFactory -Scopes 'https://graph.microsoft.com/.default')
                $user | ProcessUser -orgSubscribedSkus $orgSubscribedSkus -CreateReport $CreateReport -IncludeUpnInAssignedLicenses $IncludeUpnInAssignedLicenses
                break;
            }
            'MultipleUsers' {
                if([string]::IsNullOrWhiteSpace($UpnStartsWith))
                {
                    $Uri = "https://graph.microsoft.com/v1.0/users`?`$select=id,userPrincipalName,assignedLicenses,assignedPlans`&`$filter=assignedLicenses/`$count ne 0`&`$count=true`&`$top=$BatchSize"
                }
                else
                {
                    $Uri = "https://graph.microsoft.com/v1.0/users`?`$select=id,userPrincipalName,assignedLicenses,assignedPlans`&`$filter=startsWith(userPrincipalName,'$UpnStartsWith') and assignedLicenses/`$count ne 0`&`$count=true`&`$top=$BatchSize"
                }
                $total = 0
                $current = 0
                do
                {
                    $headers = Get-AadToken -AsHashTable -Factory $script:authFactory -Scopes 'https://graph.microsoft.com/.default'
                    $headers['ConsistencyLevel'] = 'eventual'
                    $data = Invoke-RestMethod -Uri $Uri -Headers $headers
                    if($null -ne $data.'@odata.count')
                    {
                        $total = $data.'@odata.count'
                    }
                    $current+=$data.value.count
                    if($ShowProgress)
                    {
                        Write-Progress -Activity 'Processing' -Status "$current / $total " -PercentComplete ([int]($current * 100 / $total))
                    }
                    $data.value | ProcessUser -orgSubscribedSkus $orgSubscribedSkus -CreateReport $CreateReport -IncludeUpnInAssignedLicenses $IncludeUpnInAssignedLicenses
                    $Uri = $data.'@odata.nextLink'
                }while($null -ne $Uri)
                if($ShowProgress)
                {
                    Write-Progress -Activity 'Processing' -Completed
                }
                break;
            }
        }
    }
}

function ProcessUser
{
    param
    (
        [Parameter(Mandatory,ValueFromPipeline)]
        [PSCustomObject]$graphUser,
        [Parameter(Mandatory)]
        $orgSubscribedSkus,
        [Parameter(Mandatory)]
        [bool]$CreateReport,
        [bool]$IncludeUpnInAssignedLicenses
    )

    process
    {
        $user = [PSCustomObject]@{
            UserPrincipalName = $graphUser.userPrincipalName
            Id = $graphUser.id
            AssignedLicenses = $graphUser.assignedLicenses.Where{$_.skuId -in $orgSubscribedSkus.skuId}
        }
        foreach($sku in $user.assignedLicenses)
        {
            $sku `
            | Add-Member -MemberType NoteProperty -Name AssignedServices -Value @() -PassThru `
            | Add-Member -MemberType NoteProperty -Name AssignedDate -Value ([DateTime]::MaxValue) -PassThru `
            | Add-Member -MemberType NoteProperty -Name Name -Value ($script:prods[$sku.skuId]).Name -PassThru `
            | Add-Member -MemberType NoteProperty -Name DisplayName -Value ($script:prods[$sku.skuId]).DisplayName
            if($CreateReport)
            {
                $sku | Add-Member -MemberType ScriptMethod -Name Report -Value {
                    param( [string]$Sort = "DisplayName")
                    $marker = [char]27
                    $bold = "$marker[1m"
                    $underline = "$marker[4m"
                    $resetChanges = "$marker[0m"
                    $header = $bold + $underline
                    if(-not [string]::IsNullOrEmpty($this.userPrincipalName)) {$header+="$($this.UserPrincipalName)`t"}
                    $header+="$($this.Name)`t$($this.DisplayName)`t$($this.AssignedDate)"
                    $header+=$resetChanges
                    $header
                    ($this.AssignedServices | Sort-Object $Sort | Format-Table displayName, assignedDateTime, capabilityStatus)
                }
            }
            if($IncludeUpnInAssignedLicenses)
            {
                $sku | Add-Member -MemberType NoteProperty -Name UserPrincipalName -Value $User.UserPrincipalName
            }

            if([string]::IsNullOrEmpty($sku.Name))
            {
                #name not published in downloadable CSV - fallback
                $sku.Name = ($orgSubscribedSkus | Where-Object{$_.skuId -eq $sku.skuId}).SkuPartNumber
            }
            if([string]::IsNullOrEmpty($sku.DisplayName))
            {
                $sku.DisplayName = $sku.Name
            }
        }
    
        $userAssignedSkus = $orgSubscribedSkus.Where{$_.skuId -in $user.assignedLicenses.skuId}
        foreach($plan in $graphUser.assignedPlans)
        {
            if($plan.psobject.properties.name -notcontains 'displayName')
            {
                $plan | Add-Member -MemberType NoteProperty -Name displayName -Value $null
            }
            #user may have multiple products assigned containing the same plan
            foreach($sku in $userAssignedSkus.Where{$_.servicePlans.servicePlanId -eq $plan.servicePlanId})
            {
                if($null -ne $script:prods[$sku.skuId])
                {
                    $plan.displayName = $script:prods[$sku.skuId].Plans[$plan.servicePlanId].DisplayName
                }
                else
                {
                    #display name not published in downloadable CSV - fallback
                    $plan.displayName = ($sku.servicePlans | Where-Object{$_.servicePlanId -eq $plan.servicePlanId}).ServicePlanName
                }
                foreach($userSku in $user.assignedLicenses.Where{$_.skuId -eq $sku.skuId})
                {
                    $userSku.AssignedServices+=$plan
                    #PS 5 may not parse datetime out of box
                    if($plan.assignedDateTime -is [string]) {$plan.assignedDateTime = [DateTime]::Parse($plan.assignedDateTime)}
                    if($plan.assignedDateTime -lt $userSku.AssignedDate)
                    {
                        $userSku.AssignedDate = $plan.assignedDateTime
                    }
                }
            }
        }
        $user
    }
}
if($null -eq 'TrustAllCertsPolicy' -as [type])
{
    add-type @"
    using System.Net;
    using System.Security.Cryptography.X509Certificates;
    public class TrustAllCertsPolicy : ICertificatePolicy {
        public bool CheckValidationResult(
            ServicePoint srvPoint, X509Certificate certificate,
            WebRequest request, int certificateProblem) {
            return true;
        }
    }
"@
    
}
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12