UCLobbyMicrosoft365.psm1

function Invoke-UcGraphRequest {
    <#
        .SYNOPSIS
        Invoke a Microsoft Graph Request using Entra Auth or Microsoft.Graph.Authentication
 
        .DESCRIPTION
        This function will send a Microsoft Graph request to an available connections, "Test-UcServiceConnection -Type MsGraph" will have to be executed first to determine if we have a session with EntraAuth or Microsoft.Graph.Authentication.
 
        Requirements: EntraAuth PowerShell module (Install-Module EntraAuth)
                        or
                        Microsoft Graph Authentication PowerShell Module (Install-Module Microsoft.Graph.Authentication)
 
        .PARAMETER Path
        Specifies Microsoft Graph Path that we want to send the request.
 
        .PARAMETER Header
        Specify the header for cases we need to have a custom header.
 
        .PARAMETER Requests
        If wwe want to send a batch request.
 
        .PARAMETER Beta
        When present, it will use the Microsoft Graph Beta API.
 
        .PARAMETER IncludeBody
        Some Ms Graph APIs can require specific AuthType, Application or Delegated (User).
 
        .PARAMETER Activity
        For Batch requests we have use this for Activity Progress.
    #>

    param(
        [string]$Path = "/`$batch",
        [object]$Header,
        [object]$Requests,
        [switch]$Beta,
        [switch]$IncludeBody,
        [string]$Activity
    )
    #This is an easy way to switch between v1.0 and beta.
    $BatchPath = "`$batch"
    if ($Beta) {
        $Path = "../beta" + $Path
        $BatchPath = "../beta/`$batch"
    }

    #If requests then we need to do a batch request to Graph.
    if (!$Requests) {
        if ($script:GraphEntraAuth) {
            if ($Header) {
                return Invoke-EntraRequest -Path $Path -NoPaging -Header $Header
            } 
            return Invoke-EntraRequest -Path $Path -NoPaging
        }
        else {
            if ($Header) {
                $GraphResponse = Invoke-MgGraphRequest -Uri ("/v1.0/" + $Path) -Headers $Header
            }
            else {
                $GraphResponse = Invoke-MgGraphRequest -Uri ("/v1.0/" + $Path)
            }
            #When it's more than one result Invoke-MgGraphRequest returns "value", we need to remove it to match EntraAuth behaviour.
            if ($GraphResponse.value) {
                return $GraphResponse.value
            }
            else {
                return $GraphResponse
            }
        }
    }
    else {
        $outBatchResponses = [System.Collections.ArrayList]::new()
        $tmpGraphRequests = [System.Collections.ArrayList]::new()
        $g = 1
        $requestHeader = New-Object 'System.Collections.Generic.Dictionary[string, string]'
        $requestHeader.Add("Content-Type", "application/json")
        #If activity is null then we can use this to get the function that call this function.
        if (!($Activity)) {
            $Activity = [string]$(Get-PSCallStack)[1].FunctionName
        }
        $batchCount = [int][Math]::Ceiling(($Requests.count / 20))
        foreach ($GraphRequest in $Requests) {
            Write-Progress -Activity $Activity -Status "Running batch $g of $batchCount"
            [void]$tmpGraphRequests.Add($GraphRequest) 
            if ($tmpGraphRequests.Count -ge 20) {
                $g++
                $grapRequestBody = ' { "requests": ' + ($tmpGraphRequests  | ConvertTo-Json) + ' }' 
                if ($script:GraphEntraAuth) {
                    #TODO: Add support for Graph Batch with EntraAuth
                    $GraphResponses += (Invoke-EntraRequest -Path $BatchPath -Body $grapRequestBody -Method Post -Header $requestHeader).responses
                }
                else {
                    $GraphResponses += (Invoke-MgGraphRequest -Method Post -Uri ("/v1.0/" + $BatchPath) -Body $grapRequestBody).responses
                }
                $tmpGraphRequests = [System.Collections.ArrayList]::new()
            }
        }
        
        if ($tmpGraphRequests.Count -gt 0) {
            Write-Progress -Activity $Activity -Status "Running batch $g of $batchCount"
            #TO DO: Look for alternatives instead of doing this.
            if ($tmpGraphRequests.Count -gt 1) {
                $grapRequestBody = ' { "requests": ' + ($tmpGraphRequests | ConvertTo-Json) + ' }' 
            }
            else {
                $grapRequestBody = ' { "requests": [' + ($tmpGraphRequests | ConvertTo-Json) + '] }' 
            }
            try {
                if ($script:GraphEntraAuth) {
                    #TODO: Add support for Graph Batch with EntraAuth
                    $GraphResponses += (Invoke-EntraRequest -Path $BatchPath -Body $grapRequestBody -Method Post -Header $requestHeader).responses
                }
                else {
                    $GraphResponses += (Invoke-MgGraphRequest -Method Post -Uri  ("/v1.0/" + $BatchPath) -Body $grapRequestBody).responses
                }
            }
            catch {
                Write-Warning "Error while getting the Graph Request."
            }
        }
        
        #In some cases we will need the complete graph response, in that case the calling function will have to process pending pages.
        $attempts = 1
        for ($j = 0; $j -lt $GraphResponses.length; $j++) {
            $ResponseCount = 0
            if ($IncludeBody) {
                $outBatchResponses += $GraphResponses[$j]
            }
            else {
                $outBatchResponses += $GraphResponses[$j].body
                if ($GraphResponses[$j].status -eq "200") {
                    #Checking if there are more pages available
                    $GraphURI_NextPage = $GraphResponses[$j].body.'@odata.nextLink'
                    $GraphTotalCount = $GraphResponses[$j].body.'@odata.count'
                    $ResponseCount += $GraphResponses[$j].body.value.count
                    while (![string]::IsNullOrEmpty($GraphURI_NextPage)) {
                        try {
                            if ($script:GraphEntraAuth) {
                                #TODO: Add support for Graph Batch with EntraAuth, for now we need to use NoPaging to have the same behaviour as Invoke-MgGraphRequest
                                $graphNextPageResponse = Invoke-EntraRequest -Path $GraphURI_NextPage -NoPaging
                            }
                            else {
                                $graphNextPageResponse = Invoke-MgGraphRequest -Method Get -Uri $GraphURI_NextPage
                            }
                            $outBatchResponses += $graphNextPageResponse
                            $GraphURI_NextPage = $graphNextPageResponse.'@odata.nextLink'
                            $ResponseCount += $graphNextPageResponse.value.count
                            Write-Progress -Activity $Activity -Status "$ResponseCount of $GraphTotalCount"
                        }
                        catch {
                            Write-Warning "Failed to get the next batch page, retrying..."
                            $attempts--
                        }
                        if ($attempts -eq 0) {
                            Write-Warning "Could not get next batch page, skiping it."
                            break
                        }
                    }
                }
                else {
                    Write-Warning ("Failed to get Graph Response" + [Environment]::NewLine + `
                            "Error Code: " + $GraphResponses[$j].status + " " + $GraphResponses[$j].body.error.code + [Environment]::NewLine + `
                            "Error Message: " + $GraphResponses[$j].body.error.message + [Environment]::NewLine + `
                            "Request Date: " + $GraphResponses[$j].body.error.innerError.date + [Environment]::NewLine + `
                            "Request ID: " + $GraphResponses[$j].body.error.innerError.'request-id' + [Environment]::NewLine + `
                            "Client Request Id: " + $GraphResponses[$j].body.error.innerError.'client-request-id')
                } 
            }
        }
        return $outBatchResponses
    }
}function Test-UcPowerShellModule {
    <#
        .SYNOPSIS
        Test if PowerShell module is installed and updated
 
        .DESCRIPTION
        This function returns FALSE if PowerShell module is not installed.
 
        .PARAMETER ModuleName
        Specifies PowerShell module name
 
        .EXAMPLE
        PS> Test-UcPowerShellModule -ModuleName UCLobbyTeams
    #>

    param(
        [string]$ModuleName
    )
    
    try { 
        #Region 2025-07-23: We can use the current module name, this will make the code simpler in the other functions.
        $ModuleName = $MyInvocation.MyCommand.Module.Name
        if (!($ModuleName)) {
            Write-Warning "Please specify a module name using the ModuleName parameter."
            return
        }
        $ModuleNameCheck = Get-Variable -Scope Global -Name ($ModuleName + "ModuleCheck") -ErrorAction SilentlyContinue
        if ($ModuleNameCheck.Value) {
            return $true
        }
        if ($ModuleNameCheck) {
            Set-Variable -Scope Global -Name ($ModuleName + "ModuleCheck") -Value $true
        }
        else {
            New-Variable -Scope Global -Name ($ModuleName + "ModuleCheck") -Value $true
        }
        #endRegion
        
        #Get all installed versions
        $installedVersions = (Get-Module $ModuleName -ListAvailable | Sort-Object Version -Descending).Version

        #Get the lastest version available
        $availableVersion = (Find-Module -Name $ModuleName -Repository PSGallery -ErrorAction SilentlyContinue).Version

        if (!($installedVersions)) {
            if ($availableVersion ) {
                #Module not installed and there is an available version to install.
                Write-Warning ("The PowerShell Module $ModuleName is not installed, please install the latest available version ($availableVersion) with:" + [Environment]::NewLine + "Install-Module $ModuleName")
            }
            else {
                #Wrong name or not found in the registered PS Repository.
                Write-Warning ("The PowerShell Module $ModuleName not found in the registered PS Repository, please check the module name and try again.")
            }
            return $false
        }

        #Get the current loaded version
        $tmpCurrentVersion = (Get-Module $ModuleName | Sort-Object Version -Descending)
        if ($tmpCurrentVersion) {
            $currentVersion = $tmpCurrentVersion[0].Version.ToString()
        }

        if (!($currentVersion)) {
            #Module is installed but not imported, in this case we check if there is a newer version available.
            if ($availableVersion -in $installedVersions) {
                Write-Warning ("The lastest available version of $ModuleName module is installed, however the module is not imported." + [Environment]::NewLine + "Please make sure you import it with:" + [Environment]::NewLine + "Import-Module $ModuleName -RequiredVersion $availableVersion")
                return $false
            }
            else {
                Write-Warning ("There is a new version available $availableVersion, the lastest installed version is " + $installedVersions[0] + "." + [Environment]::NewLine + "Please update the module with:" + [Environment]::NewLine + "Update-Module $ModuleName")
            }
        }

        if ($currentVersion -ne $availableVersion ) {
            if ($availableVersion -in $installedVersions) {
                Write-Warning ("The lastest available version of $ModuleName module is installed, however version $currentVersion is imported." + [Environment]::NewLine + "Please make sure you import it with:" + [Environment]::NewLine + "Import-Module $ModuleName -RequiredVersion $availableVersion")
            }
            else {
                Write-Warning ("There is a new version available $availableVersion, current version $currentVersion." + [Environment]::NewLine + "Please update the module with:" + [Environment]::NewLine + "Update-Module $ModuleName")
            }
        }
        return $true
    }
    catch {
    }
    return $false
}function Test-UcServiceConnection {
    <#
        .SYNOPSIS
        Test connection to a Service
 
        .DESCRIPTION
        This function will validate if the there is an active connection to a service and also if the required module is installed.
 
        Requirements: MsGraph, TeamsDeviceTAC - EntraAuth PowerShell module (Install-Module EntraAuth)
                        TeamsModule - MicrosoftTeams PowerShell module (Install-Module MicrosoftTeams)
 
        .PARAMETER Type
        Specifies a Type of Service, valid options:
            MSGraph - Microsoft Graph
            TeamsModule - Microsoft Teams PowerShell module
            TeamsDeviceTAC - Teams Admin Center (TAC) API for Teams Devices
 
        .PARAMETER Scopes
        When present it will check if the require permissions are in the current Scope, only applicable to Microsoft Graph API.
         
        .PARAMETER AltScopes
        Allows checking for alternative permissions to the ones specified in AltScopes, only applicable to Microsoft Graph API.
 
        .PARAMETER AuthType
        Some Ms Graph APIs can require specific AuthType, Application or Delegated (User)
    #>

    param(
        [Parameter(mandatory = $true)]
        [ValidateSet("MSGraph", "TeamsPowerShell", "TeamsDeviceTAC")]
        [string]$Type,
        [string[]]$Scopes,
        [string[]]$AltScopes,
        [ValidateSet("Application", "Delegated")]
        [string]$AuthType
    )
    switch ($Type) {
        "MSGraph" {
            #UCLobbyTeams is moving to use EntraAuth instead of Microsoft.Graph.Authentication, both will be supported for now.
            $script:GraphEntraAuth = $false
            $EntraAuthModuleAvailable = Get-Module EntraAuth -ListAvailable
            $MSGraphAuthAvailable = Get-Module Microsoft.Graph.Authentication -ListAvailable
      
            if ($EntraAuthModuleAvailable) {
                $AuthToken = Get-EntraToken -Service Graph
                if ($AuthToken) {
                    $script:GraphEntraAuth = $true
                    $currentScopes = $AuthToken.Scopes
                    $AuthTokenType = $AuthToken.tokendata.idtyp.replace('app', 'Application').replace('user', 'Delegated')
                }
            }

            #EntraAuth has priority if already connected.
            if ($MSGraphAuthAvailable -and !$script:GraphEntraAuth) {
                $MgGraphContext = Get-MgContext
                $currentScopes = $MgGraphContext.Scopes
                $AuthTokenType = (""+$MgGraphContext.AuthType).replace('AppOnly', 'Application')
            }

            if(!$EntraAuthModuleAvailable -and !$MSGraphAuthAvailable) {
                Write-Warning ("Missing EntraAuth PowerShell module. Please install it with:" + [Environment]::NewLine + "Install-Module EntraAuth") 
                return $false
            }

            if (!($currentScopes)) {
                Write-Warning  ("Not Connected to Microsoft Graph" + `
                        [Environment]::NewLine + "Please connect to Microsoft Graph before running this cmdlet." + `
                        [Environment]::NewLine + "Commercial Tenant: Connect-EntraService -ClientID Graph -Scopes " + ($Scopes -join ",") + `
                        [Environment]::NewLine + "US Gov (GCC-H) Tenant: Connect-EntraService -ClientID Graph " + ($Scopes -join ",") + " -Environment USGov")
                return $false
            }

            if ($AuthType -and $AuthTokenType -ne $AuthType) {
                Write-Warning "Wrong Permission Type: $AuthTokenType, this PowerShell cmdlet requires: $AuthType"
                return $false
            }
            $strScope = ""
            $strAltScope = ""
            $missingScopes = ""
            $missingAltScopes = ""
            $missingScope = $false
            $missingAltScope = $false
            foreach ($scope in $Scopes) {
                $strScope += "`"" + $scope + "`","
                if ($scope -notin $currentScopes) {
                    $missingScope = $true
                    $missingScopes += $scope + ","
                }
            }
            if ($missingScope -and $AltScopes) {
                foreach ($altScope in $AltScopes) {
                    $strAltScope += "`"" + $altScope + "`","
                    if ($altScope -notin $currentScopes) {
                        $missingAltScope = $true
                        $missingAltScopes += $altScope + ","
                    }
                }
            }
            else {
                $missingAltScope = $true
            }
            #If scopes are missing we need to connect using the required scopes
            if ($missingScope -and $missingAltScope) {
                if ($Scopes -and $AltScopes) {
                    Write-Warning  ("Missing scope(s): " + $missingScopes.Substring(0, $missingScopes.Length - 1) + " and missing alternative Scope(s): " + $missingAltScopes.Substring(0, $missingAltScopes.Length - 1) + `
                            [Environment]::NewLine + "Please reconnect to Microsoft Graph before running this cmdlet." + `
                            [Environment]::NewLine + "Commercial Tenant: Connect-EntraService -ClientID Graph -Scopes " + $strScope.Substring(0, $strScope.Length - 1) + " or Connect-EntraService -ClientID Graph -Scopes " + $strAltScope.Substring(0, $strAltScope.Length - 1) + `
                            [Environment]::NewLine + "US Gov (GCC-H) Tenant: Connect-EntraService -ClientID Graph -Environment USGov -Scopes " + $strScope.Substring(0, $strScope.Length - 1) + " or Connect-EntraService -ClientID Graph -Environment USGov -Scopes " + $strAltScope.Substring(0, $strAltScope.Length - 1) )
                }
                else {
                    Write-Warning  ("Missing scope(s): " + $missingScopes.Substring(0, $missingScopes.Length - 1) + `
                            [Environment]::NewLine + "Please reconnect to Microsoft Graph before running this cmdlet." + `
                            [Environment]::NewLine + "Commercial Tenant: Connect-EntraService -ClientID Graph -Scopes " + $strScope.Substring(0, $strScope.Length - 1) + `
                            [Environment]::NewLine + "US Gov (GCC-H) Tenant: Connect-EntraService -ClientID Graph -Scopes " + $strScope.Substring(0, $strScope.Length - 1))
                }
                return $false
            }
            return $true
        }
        "TeamsPowerShell" { 
            #Checking if MicrosoftTeams module is installed
            if (!(Get-Module MicrosoftTeams -ListAvailable)) {
                Write-Warning ("Missing MicrosoftTeams PowerShell module. Please install it with:" + [Environment]::NewLine + "Install-Module MicrosoftTeams") 
                return $false
            }
            #We need to use a cmdlet to know if we are connected to MicrosoftTeams PowerShell
            try {
                Get-CsTenant -ErrorAction SilentlyContinue | Out-Null
                return $true
            }
            catch [System.UnauthorizedAccessException] {
                Write-Warning ("Please connect to Microsoft Teams PowerShell with Connect-MicrosoftTeams before running this cmdlet")
                return $false
            }
        }
        "TeamsDeviceTAC" {
            #Checking if EntraAuth module is installed
            if (!(Get-Module EntraAuth -ListAvailable)) {
                Write-Warning ("Missing EntraAuth PowerShell module. Please install it with:" + [Environment]::NewLine + "Install-Module EntraAuth") 
                return $false
            }
            if (Get-EntraToken TeamsDeviceTAC) {
                return $true
            }
            else {
                Write-Warning "Please connect to Teams TAC API with Connect-UcTeamsDeviceTAC before running this cmdlet"
            }
        }
        Default {
            return $false
        }
    }
}function Export-UcM365LicenseAssignment {
    <#
        .SYNOPSIS
        Generate a report of the User assigned licenses either direct or assigned by group (Inherited)
 
        .DESCRIPTION
        This script will get a report of all Service Plans assigned to users and how the license is assigned to the user (Direct, Inherited)
 
        Contributors: David Paulino, Freydem Fernandez Lopez, Gal Naor
 
        Requirements: EntraAuth PowerShell Module (Install-Module EntraAuth)
                        or
                        Microsoft Graph Authentication PowerShell Module (Install-Module Microsoft.Graph.Authentication)
                         
                        Microsoft Graph Scopes:
                            "Directory.Read.All"
         
        .PARAMETER UseFriendlyNames
        When present will download a csv file containing the License/ServicePlans friendly names
 
        Product names and service plan identifiers for licensing
        https://learn.microsoft.com/en-us/azure/active-directory/enterprise-users/licensing-service-plan-reference
 
        .PARAMETER SkipServicePlan
        When present will just check the licenses and not the service plans assigned to the user.
 
        .PARAMETER OutputPath
        Allows to specify the path where we want to save the results. By default, it will save on current user Download.
 
        .PARAMETER DuplicateServicePlansOnly
        When present the report will be the users that have the same service plan from different assigned licenses.
 
        .EXAMPLE
        PS> Export-UcM365LicenseAssignment
 
        .EXAMPLE
        PS> Export-UcM365LicenseAssignment -UseFriendlyNames
    #>

    param(
        [string]$SKU,    
        [switch]$UseFriendlyNames,
        [switch]$SkipServicePlan,
        [string]$OutputPath,
        [switch]$DuplicateServicePlansOnly
    )

    $startTime = Get-Date
    #region Graph Connection, Scope validation and module version
    if (!(Test-UcServiceConnection -Type MSGraph -Scopes "Directory.Read.All" -AltScopes ("User.Read.All", "Organization.Read.All"))) {
        return
    }

    #2025-07-23: All logic to check if we run this and getting the module name moved to the Test-UcPowerShellModule.
    Test-UcPowerShellModule | Out-Null
    #endregion
    
    $outFile = "M365LicenseAssigment_" 
    #region 2024-09-05: Users with Duplicate Service Plans
    if ($DuplicateServicePlansOnly) {
        $outFile += "DuplicateServicePlansOnly_"
    }
    #endregion
    $outFile += (Get-Date).ToString('yyyyMMdd-HHmmss') + ".csv"

    #Verify if the Output Path exists
    if ($OutputPath) {
        if (!(Test-Path $OutputPath -PathType Container)) {
            Write-Host ("Error: Invalid folder " + $OutputPath) -ForegroundColor Red
            return
        }
        $OutputFilePath = [System.IO.Path]::Combine($OutputPath, $outFile)
    }
    else {                
        $OutputFilePath = [System.IO.Path]::Combine($env:USERPROFILE, "Downloads", $outFile)
    }
        
    if ($UseFriendlyNames) {
        #2023-10-19: Change: OutputPath will be for both report and Product names and service plan identifiers for licensing.csv
        $SKUnSPFilePath = [System.IO.Path]::Combine($OutputPath, "Product names and service plan identifiers for licensing.csv")
        if (!(Test-Path -Path $SKUnSPFilePath)) {
            try {
                Write-Warning "M365 Product Names and Service Plans file not found, attempting to download it."
                Invoke-WebRequest -Uri "https://download.microsoft.com/download/e/3/e/e3e9faf2-f28b-490a-9ada-c6089a1fc5b0/Product%20names%20and%20service%20plan%20identifiers%20for%20licensing.csv" -OutFile $SKUnSPFilePath
            }
            catch {
                Write-Warning "Could not download M365 Product Names and Service Plans."
            }
        }
        try {
            $SKUnSP = import-CSV -Path $SKUnSPFilePath
        }
        catch {
            Write-Warning "Could not import Service Plan ID file."
            $UseFriendlyNames = $false
        }
    }

    #region 2024-09-05: Combined Graph calls for SKUs, Licensed Groups
    #Tenant SKUs - All Licenses that exist in the tenant
    $graphRequests = [System.Collections.ArrayList]::new()
    $gRequestTmp = New-Object -TypeName PSObject -Property @{
        id     = "TenantSKUs"
        method = "GET"
        url    = "/subscribedSkus?`$select=skuID,skuPartNumber,servicePlans,appliesTo,consumedUnits"
    }
    [void]$graphRequests.Add($gRequestTmp)

    #Groups with Licenses Assignment.
    $GraphRequestHeader = New-Object 'System.Collections.Generic.Dictionary[string, string]'
    $GraphRequestHeader.Add("ConsistencyLevel", "eventual")
    $gRequestTmp = New-Object -TypeName PSObject -Property @{
        id      = "GroupsWithLicenses"
        method  = "GET"
        headers = $GraphRequestHeader
        url     = "/groups?`$filter=assignedLicenses/`$count ne 0&`$count=true&`$select=id,displayName,assignedLicenses&`$top=999"
    }
    [void]$graphRequests.Add($gRequestTmp)
    $BatchResponse = Invoke-UcGraphRequest -Requests $graphRequests -Beta -IncludeBody -Activity "Export-UcM365LicenseAssignment, Step 1: Getting Tenant License details"

    $tmpGraphResponse = $BatchResponse | Where-Object { $_.id -eq ("TenantSKUs") }
    if ($tmpGraphResponse.status -eq 200) {
        $TenantSKUs = $tmpGraphResponse.body.value
    }
    $tmpGraphResponse = $BatchResponse | Where-Object { $_.id -eq ("GroupsWithLicenses") }
    if ($tmpGraphResponse.status -eq 200) {
        $GroupsWithLicenses = $tmpGraphResponse.body.value
    }
    #endregion

    #region 2023-10-19: Adding filter to SKU
    if ($SKU) {
        if ($UseFriendlyNames) {
            #2024-10-22: Change to allow to search using SKU parameter instead of exact match.
            $SKUGUID = ($SKUnSP | Where-Object { $_.String_Id -match $SKU -or $_.Product_Display_Name -match $SKU } | Sort-Object GUID -Unique ).GUID
            $TenantSKUs = $TenantSKUs | Where-Object { $_.skuId -in $SKUGUID -or $_.skuPartNumber -match $SKU }
            if ($TenantSKUs.count -eq 0) {
                Write-Warning "Could not find `"$SKU`" (SKU Name/Part Number) subscription associated with the tenant."
                return 
            }
        }
        else {
            #2024-10-22: Change to allow to search using SKU parameter instead of exact match.
            $TenantSKUs = $TenantSKUs | Where-Object { $_.skuPartNumber -match $SKU }
            if ($TenantSKUs.count -eq 0) {
                Write-Warning "Could not find `"$SKU`" (SKU Part Number) subscription associated with the tenant."
                return 
            }
        }
    }
    else {
        $TenantSKUs = $TenantSKUs | Where-Object -Property consumedUnits -GT -Value 0 | Sort-Object skuPartNumber
    }
    #endregion

    #region 2023-10-19: Getting all Service Plans for new matrix style report
    $allServicePlans = [System.Collections.ArrayList]::new()
    foreach ($TenantSKU in $TenantSKUs) {
        $tmpUserServicePlans = $TenantSKU.ServicePlans | Where-Object -Property appliesTo -EQ -Value "User" 
        foreach ($ServicePlan in $tmpUserServicePlans) {
            if (!($ServicePlan.ServicePlanId -in $allServicePlans.ServicePlanId)) {
                if ($UseFriendlyNames) {
                    $servicePlanName = ($SKUnSP | Where-Object { $_.Service_Plan_Id -eq $ServicePlan.ServicePlanId -and $_.GUID -eq $TenantSKU.skuID } | Sort-Object Service_Plans_Included_Friendly_Names -Unique).Service_Plans_Included_Friendly_Names
                    if ([string]::IsNullOrEmpty($servicePlanName)) {
                        $servicePlanName = $ServicePlan.servicePlanName    
                    }
                }
                else {
                    $servicePlanName = $ServicePlan.ServicePlanName
                }
                $tmpSP = New-Object -TypeName PSObject -Property @{
                    servicePlanId   = $ServicePlan.ServicePlanId
                    servicePlanName = $servicePlanName
                }
                [void]$allServicePlans.Add($tmpSP)
            }
        }
    }
    #Sorting service plans by name and creating the file header
    $allServicePlans = $allServicePlans | Sort-Object ServicePlanName
    $row = "UserPrincipalName,LicenseAssigned,LicenseAssignment,LicenseAssignmentGroup"
    if (!($SkipServicePlan)) {
        foreach ($ServicePlan in $allServicePlans) {
            $row += "," + $ServicePlan.servicePlanName
        }
    }
    $row += [Environment]::NewLine
    #endregion

    Write-Progress -Id 2 -Activity "Export-UcM365LicenseAssignment, Step 2: Reading users assigned licenses/service plans"
    if ($DuplicateServicePlansOnly) {
        #region 2024-09-05: Users with Duplicate Service Plans
        #We need to check license per user, this is slower than check per SKU like in Licensing Assignment but required in order to detect duplicates.
        $TotalUsers = 0
        $usersProcessed = 0
        $GraphNextPage = "/users?`$filter=assignedLicenses/`$count ne 0&`$count=true&`$select=userPrincipalName,licenseAssignmentStates&`$top=999"
        do {
            $GraphResponse = Invoke-UcGraphRequest -Path $GraphNextPage -Headers $GraphRequestHeader
            $GraphNextPage = $GraphResponse.'@odata.nextLink'
            $UsersWithLicenses = $GraphResponse.value
            if (![string]::IsNullOrEmpty($GraphResponse.'@odata.count')) {
                $TotalUsers = $GraphResponse.'@odata.count'
            }
            
            foreach ($LicensedUser in $UsersWithLicenses) {
                $usersProcessed++
                #Update the status every 100 users
                if (($usersProcessed % 100 -eq 0) -or ($usersProcessed -eq $TotalUsers) -or ($usersProcessed -eq 1) ) {
                    Write-Progress -Id 2 -Activity "Export-UcM365LicenseAssignment, Step 2: Reading users assigned licenses/service plans" -Status "$usersProcessed of $TotalUsers"
                }
                #We only need to process users that have 2 or more licenses assigned.
                if ($LicensedUser.licenseAssignmentStates.count -gt 1) {
                    $tmpUserServicePlans = [System.Collections.ArrayList]::new()
                    foreach ($licenseState in $LicensedUser.licenseAssignmentStates) {
                        #If not a in the Tenant SKUs we can skip it
                        if ($licenseState.skuId -in $TenantSKUs.skuId) {
                            $tmpLicenseInfo = ($TenantSKUs | Where-Object { $_.skuId -eq $licenseState.skuId })
                            $LicenseDisplayName = $tmpLicenseInfo.skuPartNumber
                            if ($UseFriendlyNames) {
                                $LicenseDisplayName = ($SKUnSP | Where-Object { $_.GUID -eq $licenseState.skuId } | Sort-Object Product_Display_Name -Unique).Product_Display_Name
                            }
                            if ([string]::IsNullOrEmpty($LicenseDisplayName)) {
                                $LicenseDisplayName = $licenseState.skuId
                            }
                            $licenseAssignment = "Direct"
                            $licenseAssignmentGroup = "NA"
                            if (!([string]::IsNullOrEmpty($licenseState.assignedByGroup))) {
                                $licenseAssignment = "Inherited"
                                $licenseAssignmentGroup = ($GroupsWithLicenses | Where-Object -Property "id" -EQ -Value $licenseState.assignedByGroup).displayName
                                if ([string]::IsNullOrEmpty($licenseAssignmentGroup)) {
                                    $licenseAssignmentGroup = $licenseState.assignedByGroup
                                }
                            }
                            
                            $SKUUserServicePlans = $tmpLicenseInfo.servicePlans | Where-Object -Property appliesTo -EQ -Value "User" | Sort-Object servicePlanName
                            foreach ($SKUUserServicePlan in $SKUUserServicePlans) {
                                $SPStatus = "Off"
                                if ($SKUUserServicePlan.servicePlanId -notin $licenseState.disabledPlans) {
                                    $SPStatus = "On"
                                }
                                $ObjUserServicePlans = [PSCustomObject]@{
                                    LicenseSkuId           = $licenseState.skuId
                                    LicenseDisplayName     = $LicenseDisplayName
                                    LicenseAssignment      = $licenseAssignment
                                    LicenseAssignmentGroup = $licenseAssignmentGroup
                                    ServicePlanId          = $SKUUserServicePlan.servicePlanId
                                    ServicePlanName        = $SKUUserServicePlan.servicePlanName
                                    Status                 = $SPStatus
                                }
                                [void]$tmpUserServicePlans.Add($ObjUserServicePlans)
                            }
                        }
                    }
                    
                    #Checking if we have more then one Service Plan
                    #In the future we can add filters, like only if both are ON or Ignore Direct/Inherited
                    $skuWithDupServicePlans = $tmpUserServicePlans | Group-Object -Property ServicePlanId | Where-Object { $_.Count -gt 1 } | Select-Object -ExpandProperty Group | Select-Object LicenseSkuId, LicenseDisplayName, LicenseAssignment, LicenseAssignmentGroup  | Sort-Object -Property LicenseDisplayName, LicenseAssignment, LicenseAssignmentGroup -Unique 
                    if (($skuWithDupServicePlans.Count -gt 0)) {
                        foreach ($UserLicenseState in  $skuWithDupServicePlans) {
                            foreach ($ServicePlan in $allServicePlans) {
                                $tmpSPStatus = $tmpUserServicePlans | Where-Object { $UserLicenseState.LicenseSkuId -eq $_.LicenseSkuId -and $UserLicenseState.LicenseAssignment -eq $_.LicenseAssignment -and $UserLicenseState.LicenseAssignmentGroup -eq $_.LicenseAssignmentGroup -and $_.servicePlanId -eq $servicePlan.servicePlanId }
                                if ($tmpSPStatus.Status -in ("On", "Off")) {
                                    $userServicePlans += "," + $tmpSPStatus.Status
                                }
                                else {
                                    $userServicePlans += ","
                                }
                            }
                            $row += $LicensedUser.userPrincipalName + "," + $UserLicenseState.LicenseDisplayName + "," + $UserLicenseState.LicenseAssignment + "," + $UserLicenseState.LicenseAssignmentGroup + $userServicePlans
                            Out-File -FilePath $OutputFilePath -InputObject $row -Encoding UTF8 -append
                            $row = ""
                            $userServicePlans = ""
                        }
                    }
                }
            }
        } while (!([string]::IsNullOrEmpty($GraphNextPage)))
        #endregion
    }
    else {
        #region License Assignment
        foreach ($TenantSKU in $TenantSKUs) {
            $LicenseDisplayName = $TenantSKU.skuPartNumber
            if ($UseFriendlyNames) {
                $tmpFriendlyName = ($SKUnSP | Where-Object { $_.GUID -eq $TenantSKU.skuID } | Sort-Object Product_Display_Name -Unique).Product_Display_Name
                #2024-10-22: To prevent empty name when a license exists in the tenant but the data is not available in "Products names and Services Identifiers" file.
                if ($tmpFriendlyName) {
                    $LicenseDisplayName = $tmpFriendlyName
                }  
            }
            $SKUUserServicePlans = $TenantSKU.servicePlans | Where-Object -Property appliesTo -EQ -Value "User" | Sort-Object servicePlanName
            $usersProcessed = 0       
            $GraphRequestURI = "https://graph.microsoft.com/v1.0/users?`$filter=assignedLicenses/any(u:u/skuId eq " + $TenantSKU.skuId + " )&`$select=userPrincipalName,licenseAssignmentStates&`$orderby=userPrincipalName&`$count=true&`$top=999"
            do {
                try {
                    $UsersWithLicenses = Invoke-MgGraphRequest -Method Get -Uri $GraphRequestURI -Headers $GraphRequestHeader
                    if (![string]::IsNullOrEmpty($UsersWithLicenses.'@odata.count')) {
                        $TotalUsers = $UsersWithLicenses.'@odata.count'
                    }
                    $GraphRequestURI = $UsersWithLicenses.'@odata.nextLink'
                    foreach ($UserWithLicense in $UsersWithLicenses.value) {
                        if (($usersProcessed % 1000 -eq 0) -or ($usersProcessed -eq $TotalUsers)) {
                            Write-Progress -ParentId 2 -Activity "Checking license assignments for $LicenseDisplayName" -Status "$usersProcessed of $TotalUsers"
                        }
                        $tmpLicenseAssignmentStates = $UserWithLicense.licenseAssignmentStates | Where-Object -Property skuId -EQ -Value $TenantSKU.skuId | Sort-Object assignedByGroup
                        foreach ($licenseState in $tmpLicenseAssignmentStates) {
                            $licenseAssignment = "Direct"
                            $licenseAssignmentGroup = ""
                            if (!([string]::IsNullOrEmpty($licenseState.assignedByGroup))) {
                                $licenseAssignment = "Inherited"
                                $licenseAssignmentGroup = ($GroupsWithLicenses | Where-Object -Property "id" -EQ -Value $licenseState.assignedByGroup).displayName
                                if ([string]::IsNullOrEmpty($licenseAssignmentGroup)) {
                                    $licenseAssignmentGroup = $licenseState.assignedByGroup
                                }
                            }
                            $userServicePlans = ""
                            if (!($SkipServicePlan)) {
                                foreach ($ServicePlan in $allServicePlans) {
                                    if ($servicePlan.servicePlanId -in $SKUUserServicePlans.servicePlanId) {
                                        if ($servicePlan.servicePlanId -notin $licenseState.disabledPlans) {
                                            $userServicePlans += ",On"
                                        }
                                        else {
                                            $userServicePlans += ",Off"
                                        }
                                    }
                                    else {
                                        $userServicePlans += ","
                                    }
                                }
                            }
                            $row += $UserWithLicense.userPrincipalName + "," + $LicenseDisplayName + "," + $LicenseAssignment + "," + $LicenseAssignmentGroup + $userServicePlans
                            Out-File -FilePath $OutputFilePath -InputObject $row -Encoding UTF8 -append
                            $row = ""
                        }
                        $usersProcessed++
                    }
                }
                catch {
                    Write-Warning ("Failed to get Users with assigned SKU Id: " + $TenantSKU.skuID)
                    $GraphRequestURI = ""
                }
            } while (![string]::IsNullOrEmpty($GraphRequestURI))
        }
        #endregion
    }

    if ($usersProcessed -gt 0) {
        Write-Host ("Results available in " + $OutputFilePath) -ForegroundColor Cyan
        #region 2023-10-19: Added execution time to the output.
        $endTime = Get-Date
        $totalSeconds = [math]::round(($endTime - $startTime).TotalSeconds, 2)
        $totalTime = New-TimeSpan -Seconds $totalSeconds
        Write-Host "Execution time:" $totalTime.Hours "Hours" $totalTime.Minutes "Minutes" $totalTime.Seconds "Seconds" -ForegroundColor Green
        #endregion
    }
}function Get-UcM365Domains {
    <#
        .SYNOPSIS
        Get Microsoft 365 Domains from a Tenant
 
        .DESCRIPTION
        This function returns a list of domains that are associated with a Microsoft 365 Tenant.
 
        .PARAMETER Domain
        Specifies a domain registered with Microsoft 365.
 
        .EXAMPLE
        PS> Get-UcM365Domains -Domain uclobby.com
    #>

    param(
        [Parameter(Mandatory = $true)]
        [string]$Domain
    )

    $regex = "^(.*@)(.*[.].*)$"
    $outDomains = [System.Collections.ArrayList]::new()
    try {
        #2025-07-23: All logic to check if we run this and getting the module name moved to the Test-UcPowerShellModule.
        Test-UcPowerShellModule | Out-Null
        
        $AllowedAudiences = Invoke-WebRequest -Uri ("https://accounts.accesscontrol.windows.net/" + $Domain + "/metadata/json/1") -UseBasicParsing | ConvertFrom-Json | Select-Object -ExpandProperty allowedAudiences
    }
    catch [System.Net.WebException] {
        if ($PSItem.Exception.Message -eq "The remote server returned an error: (400) Bad Request.") {
            Write-Warning "The domain $Domain is not part of a Microsoft 365 Tenant."
        }
        else {
            Write-Warning $PSItem.Exception.Message
        }
    }
    catch {
        #20240318 - Support for GCC High tenants.
        try {
            $AllowedAudiences = Invoke-WebRequest -Uri ("https://login.microsoftonline.us/" + $Domain + "/metadata/json/1") -UseBasicParsing | ConvertFrom-Json | Select-Object -ExpandProperty allowedAudiences
        }
        catch {
            Write-Warning "Unknown error while checking domain: $Domain"
        }
    }
    try {
        foreach ($AllowedAudience in $AllowedAudiences) {
            $temp = [regex]::Match($AllowedAudience , $regex).captures.groups
            if ($temp.count -ge 2) {
                $tempObj = New-Object -TypeName PSObject -Property @{
                    Name = $temp[2].value
                }
                $outDomains.Add($tempObj) | Out-Null
            }
        }
    }
    catch {
        Write-Warning "Unknown error while checking domain: $Domain"
    }
    return $outDomains
}function Get-UcM365TenantId {
    <#
        .SYNOPSIS
        Get Microsoft 365 Tenant Id
 
        .DESCRIPTION
        This function returns the Tenant ID associated with a domain that is part of a Microsoft 365 Tenant.
 
        .PARAMETER Domain
        Specifies a domain registered with Microsoft 365
 
        .EXAMPLE
        PS> Get-UcM365TenantId -Domain uclobby.com
    #>

    param(
        [Parameter(Mandatory = $true)]
        [string]$Domain
    )
    
    $regexTenantID = "^(.*@)(\w{8}-\w{4}-\w{4}-\w{4}-\w{12})$"
    $regexOnMicrosoftDomain = "^(.*@)(?!.*mail)(.*.onmicrosoft.com)$"

    try {
        #2025-07-23: All logic to check if we run this and getting the module name moved to the Test-UcPowerShellModule.
        Test-UcPowerShellModule | Out-Null

        $AllowedAudiences = Invoke-WebRequest -Uri ("https://accounts.accesscontrol.windows.net/" + $Domain + "/metadata/json/1") -UseBasicParsing | ConvertFrom-Json | Select-Object -ExpandProperty allowedAudiences
    }
    catch [System.Net.Http.HttpRequestException] {
        if ($PSItem.Exception.Response.StatusCode -eq "BadRequest") {
            Write-Error "The domain $Domain is not part of a Microsoft 365 Tenant."
        }
        else {
            Write-Error $PSItem.Exception.Message
        }
    }
    catch {
        Write-Error "Unknown error while checking domain: $Domain"
    }
    $output = [System.Collections.ArrayList]::new()
    $OnMicrosoftDomains = [System.Collections.ArrayList]::new()
    $TenantID = ""
    foreach ($AllowedAudience in $AllowedAudiences) {
        $tempTID = [regex]::Match($AllowedAudience , $regexTenantID).captures.groups
        $tempID = [regex]::Match($AllowedAudience , $regexOnMicrosoftDomain).captures.groups
        if ($tempTID.count -ge 2) {
            $TenantID = $tempTID[2].value 
        }
        if ($tempID.count -ge 2) {
            [void]$OnMicrosoftDomains.Add($tempID[2].value)
        }
    }
    #Multi Geo will have multiple OnMicrosoft Domains
    foreach ($OnMicrosoftDomain in $OnMicrosoftDomains) {
        if ($TenantID -and $OnMicrosoftDomain) {
            $M365TidPSObj = [PSCustomObject]@{ TenantID = $TenantID
                OnMicrosoftDomain                       = $OnMicrosoftDomain
            }
            $M365TidPSObj.PSObject.TypeNames.Insert(0, 'M365TenantId')
            [void]$output.Add($M365TidPSObj)
        }
    }
    return $output 
}