UCLobbyMicrosoft365.psm1

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
    #>

    [cmdletbinding()]
    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
    }

    #CD20250723: 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 CD20240905: 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) {
        #CD20231019: 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 CD20240905: 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 CD20231019: Adding filter to SKU
    if ($SKU) {
        if ($UseFriendlyNames) {
            #CD20241022: 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 {
            #CD20241022: 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 CD20231019: 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,LicenseState,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"
    #region CD20240905: Users with Duplicate Service Plans
    if ($DuplicateServicePlansOnly) {
        #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.
        $usersProcessed = 1
        $UserLicenseAssignmentURI = "/users?`$filter=assignedLicenses/`$count ne 0&`$count=true&`$select=userPrincipalName,licenseAssignmentStates&`$top=999"
        #region CD20251017: On larger tenants is better to process each graph response that can have up to 999 results.
        try {
            do {
                $GraphResponse = Invoke-UcGraphRequest -Path $UserLicenseAssignmentURI -Header $GraphRequestHeader -Raw
                $UserLicenseAssignmentURI = $GraphResponse.'@odata.nextLink'
                #We only want to run this once so we can use the usersProcessed.
                if ($usersProcessed -eq 1 ) {
                    $TotalUsers = $GraphResponse.'@odata.count'
                    #In case we dont have @odata.count it means only one entry is returned.
                    if (!($TotalUsers)) {
                        $TotalUsers = 1
                    }
                }
                if ($GraphResponse.value) {
                    $UsersWithLicenses = $GraphResponse.value
                }
                else {
                    $UsersWithLicenses = $GraphResponse
                }
                foreach ($LicensedUser in $UsersWithLicenses) {
                    #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"
                    }
                    $tmpUserServicePlans = [System.Collections.ArrayList]::new()
                    #We only need to process users that have 2 or more licenses assigned.
                    if ($LicensedUser.licenseAssignmentStates.count -gt 1) {
                        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
                                    }
                                }
                                if ($SkipServicePlan) {
                                    $ObjUserServicePlans = [PSCustomObject]@{
                                        LicenseSkuId           = $licenseState.skuId
                                        LicenseDisplayName     = $LicenseDisplayName
                                        LicenseState           = $licenseState.state
                                        LicenseAssignment      = $licenseAssignment
                                        LicenseAssignmentGroup = $licenseAssignmentGroup
                                    }
                                    [void]$tmpUserServicePlans.Add($ObjUserServicePlans)
                                }
                                else {
                                    $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
                                            LicenseState           = $licenseState.state
                                            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
                        $userServicePlans = ""
                        if ($SkipServicePlan) {
                            $skuWithDupServicePlans = $tmpUserServicePlans | Group-Object -Property LicenseSkuId | Where-Object { $_.Count -gt 1 } | Select-Object -ExpandProperty Group | Select-Object LicenseSkuId, LicenseDisplayName, LicenseState , LicenseAssignment, LicenseAssignmentGroup  | Sort-Object -Property LicenseDisplayName, LicenseAssignment, LicenseAssignmentGroup -Unique 
                        }
                        else {
                            $skuWithDupServicePlans = $tmpUserServicePlans | Group-Object -Property ServicePlanId | Where-Object { $_.Count -gt 1 } | Select-Object -ExpandProperty Group | Select-Object LicenseSkuId, LicenseDisplayName, LicenseState , LicenseAssignment, LicenseAssignmentGroup | Sort-Object -Property LicenseDisplayName, LicenseAssignment, LicenseAssignmentGroup -Unique 
                        }
                        foreach ($UserLicenseState in  $skuWithDupServicePlans) {
                            if (($skuWithDupServicePlans.Count -gt 0)) {
                                if (!($SkipServicePlan)) {
                                    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 }
                                        $userServicePlans += "," + $tmpSPStatus.Status
                                    }                        
                                }
                                $row += $LicensedUser.userPrincipalName + "," + $UserLicenseState.LicenseDisplayName + "," + $UserLicenseState.LicenseState + "," + $UserLicenseState.LicenseAssignment + "," + $UserLicenseState.LicenseAssignmentGroup + $userServicePlans + [Environment]::NewLine
                                $userServicePlans = ""
                            }
                        }
                    }
                    $usersProcessed++
                }
                if ($row) {
                    Out-File -FilePath $OutputFilePath -InputObject $row -Encoding UTF8 -Append -NoNewline
                    $row = ""
                }
            } while ($UserLicenseAssignmentURI)
        }
        catch {}
        #endregion
    }
    #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
                #CD20241022: 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 = 1       
            $GraphRequestURI = "/users?`$filter=assignedLicenses/any(u:u/skuId eq " + $TenantSKU.skuId + " )&`$select=userPrincipalName,licenseAssignmentStates&`$orderby=userPrincipalName&`$count=true&`$top=999"
            Write-Verbose ("Next Request URI: $GraphRequestURI - " + $GraphRequestURI.count)
            #region CD20251017: On larger tenants is better to process each graph response that can have up to 999 results.
            try {
                do {
                    $GraphResponse = Invoke-UcGraphRequest -Path $GraphRequestURI -Header $GraphRequestHeader -Raw
                    $GraphRequestURI = $GraphResponse.'@odata.nextLink'
                    #We only want to run this once so we can use the usersProcessed.
                    if ($usersProcessed -eq 1 ) {
                        
                        $TotalUsers = $GraphResponse.'@odata.count'
                        #In case we dont have @odata.count it means only one entry is returned.
                        if (!($TotalUsers)) {
                            $TotalUsers = 1
                        }
                    }
                    if ($GraphResponse.value) {
                        $UsersWithLicenses = $GraphResponse.value
                    }
                    else {
                        $UsersWithLicenses = $GraphResponse
                    }
                    foreach ($UserWithLicense in $UsersWithLicenses) {
                        if (($usersProcessed % 100 -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 + "," + $licenseState.state + "," + $LicenseAssignment + "," + $LicenseAssignmentGroup + $userServicePlans + [Environment]::NewLine 
                        }
                        $usersProcessed++
                    }
                    #CD20251017: Improving performance by only writing to the output file once per request instead of every line.
                    if ($row) {
                        Out-File -FilePath $OutputFilePath -InputObject $row -Encoding UTF8 -Append -NoNewline
                        $row = ""
                    }
                } while ($GraphRequestURI)
            }
            catch {
                Write-Warning ("Failed to get Users with assigned SKU Id: " + $TenantSKU.skuID)
                $GraphRequestURI = ""
            }
            #endregion
        }
        #endregion
    }

    if ($usersProcessed -gt 0) {
        Write-Host ("Results available in " + $OutputFilePath) -ForegroundColor Cyan
        #region CD20231019: 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-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
    )
    try {
        #CD20250723: All logic to check if we run this and getting the module name moved to the Test-UcPowerShellModule.
        Test-UcPowerShellModule | Out-Null

        $TenantId = Invoke-WebRequest -Uri ("https://accounts.accesscontrol.windows.net/" + $Domain + "/metadata/json/1") -UseBasicParsing | ConvertFrom-Json | Select-Object -ExpandProperty Realm
    }
    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"
    }

    #CD20260414: Return tenant ID only after the recent Microsoft 365 change.
    return $TenantId 
}