MSOLLicenseManagement.psm1

#==========================
# Utility Functions
#==========================
# Writes output to a log file with a time date stamp
Function Write-Log {
    Param ([string]$string)
    
    # Get the current date
    [string]$date = Get-Date -Format G
    # Write everything to our log file
    ( "[" + $date + "] - " + $string) | Out-File -FilePath $LogFile -Append
    
    # If NonInteractive true then suppress host output
    if (!($NonInteractive)) {
        ( "[" + $date + "] - " + $string) | Write-Host
    }
}

# Make sure that we are connected to the MSOL Server if not connect us
#TODO: Need to move from $error.clear() to try-catch
Function Test-MSOLServiceConnection {
    # Set the Error Action Prefernce to stop
    $ErrorActionPreference = "Stop"

    # Log out some basic information since we run this at the begining of every cmdlet
    Write-Log ('Module version ' + (Get-Module MSOLLicenseManagement | sort-object -Property version -Descending)[0].version.tostring())
    Write-Log ('Invocation ' + $PSCmdlet.MyInvocation.line)

    # Make sure that the MSOL Module is installed
    if ($null -eq (Get-Module -ListAvailable MSOnline)) {
        Write-Error "MSOL Module Not installed. Please install from https://docs.microsoft.com/en-us/office365/enterprise/powershell/connect-to-office-365-powershell" -ErrorAction Stop
    }

    # Make sure we are connected to the MSOL service
    $error.clear()
    
    # Call Get-msolaccountsku if we are not connected we will get an error if we are we will not
    $null = Get-MSOLAccountSKU -ErrorVariable error -ErrorAction SilentlyContinue

    # Check to see if we threw an error
    if ($error.count -gt 0) {
        # If we have the expected error for not being connected the call connect-msolservice
        if ($error.Exception -like "*You must call the Connect-MsolService cmdlet*") {
            Write-Log "Not Connected to MSOLService calling Connect-MSOLService"
            import-module MSOnline
            Connect-MsolService
        }
        # If we get any other error then throw and error and stop
        else {
            Write-log ("Unexpected Error encountered" + $error)
            Write-Error "Unexpected Error stopping execution" -ErrorAction Stop
        }
    } else {
        # Do nothing because we are connected
    }
}

# Updates a progress bar
Function Update-Progress {
    Param 
    (
        [Parameter(Mandatory = $true)]
        [int]$CurrentCount,
        [Parameter(Mandatory = $true)]
        [int]$MaxCount,
        [Parameter(Mandatory = $true)]
        [string]$Message
    )

    # If currentcount = maxcount we are done so we need to kill the progress bar
    if ($CurrentCount -ge $MaxCount) {
        Write-Progress -Completed -Activity "User Modification"
    }
    # Every 25 update the progress bar
    elseif ($CurrentCount % 25 -eq 0) {

        [int]$Percent = (($CurrentCount / $MaxCount) * 100)
        [string]$Operation = ([string]$Percent + "% " + $Message)
        Write-Progress -Activity "User Modification" -CurrentOperation $Operation -PercentComplete $Percent
    } else {
        # Nothing to do if not divisible by 25
    }
}

# Test the logfile path to ensure the dir exists and that a file was provided
Function Test-LogPath {
    param (
        # Log File e.g. c:\temp\out.log
        [Parameter(Mandatory)]
        [string]
        $LogFile
    )

    #Get the parent of the provided path
    $parent = split-path $LogFile -Parent

    # If the parent is null then the user provided the root of a drive for the log file
    if ([string]::IsNullOrEmpty($parent)) {
        Write-Error -Message "Log File path appears to be a drive root. Please provide a folder and file name with -logfile e.g. C:\Temp\out.log" -ErrorAction Stop
        break
    }

    # Verify that the parent folder exists since we won't create it if it doesn't
    if (!(test-path $parent)) {
        Write-Error -Message ("Path not found " + $parent + " ;please provide a valid path.") -ErrorAction Stop
        break
    }

    # Check to make sure the path contains a file with an extension
    if ((($LogFile).split('\'))[-1] -notmatch '\.') {
        Write-Error -Message ("Log file path does not appear to contain a file extension. Please provide a path and file name e.g. c:\temp\out.log")
        break
    }
    
    # Check to see if the value passed in a container (folder) if so we need to abort
    if (test-path $LogFile -PathType Container) {
        Write-Error  -Message "Log File Path appears to be a folder. Please provide a file name with -logfile e.g. C:\Temp\out.log" -ErrorAction Stop
        break
    }
}

# Determine if we have an array with UPNs or just a single UPN / UPN array unlabeled
Function Test-UserObject {
    param ([array]$ToTest)

    # See if we can get the UserPrincipalName property off of the input object
    # If we can't get the property then test the input and see if we can generate an acceptable object array
    if ($null -eq $ToTest[0].UserPrincipalName) {
        # Very basic check to see if this is a UPN
        # This deals with the input user@company.com,user2@company.com ....
        
        if ($ToTest[0] -match '@') {
            [array]$Output = $ToTest | Select-Object -Property @{Name = "UserPrincipalName"; Expression = { $_ } }
            Return $Output
        }
        # If we couldn't find the UserPrincipalName and we didn't see an @ sign then we need to abort
        else {
            Write-Log "[ERROR] - Unable to determine if input is a UserPrincipalName"
            Write-Log "Please provide a UPN or array of objects with the property UserPrincipalName populated"
            Write-Error "Unable to determine if input is a User Principal Name" -ErrorAction Stop
        }
    }
    # If we can pull the value of UserPrincipalName then just return the same object back
    else {
        Return $ToTest
    }
}




# Convert SkuID into Common Name
Function Get-SKUCommonName {
    param 
    (
        [Parameter(Mandatory = $true)]
        [string]$Skuid,
        [Parameter(Mandatory = $true)]
        [array]$SkuArray
    )

    # Find the Common name from the SkuID
    [string]$CN = ($Skuarray | where-object { $_.skuid -eq $Skuid }).CommonName

    # If we can't find the SkuID then we set it to "Not found"
    if ([string]::IsNullOrEmpty($CN)) { [string]$CN = "Not Found" }
    
    Return $CN
}

#==========================
# Common SKU Related Functions
#==========================

# Validate we have provided a valid SKU or collect one if we have provided none
Function Select-SKU {
    param 
    (
        [string]$SKUToCheck,                
        [string]$Message
    )    
    
    # If we don't have a value for the SKU then we need to ask for one.
    if ([string]::IsNullOrEmpty($SKUToCheck)) {
        
        Write-Log ("No SKU value provided. Please select from the availible account SKUs")
    
        # Setup a counter
        $i = 1
        
        # Write out the provided message
        Write-Host "`n$Message`n"
        
        
        # Get all of the SKUs and display them with a "selection number"
        $TenantSKU = Get-MSOLAccountSKU
        ForEach ($sku in $TenantSKU) {    
            Write-Host ([string]$i + " - " + $sku.SKUPartNumber)    
            $i++
        }
        
        # Collect the selected item from the user
        [int]$Selected = 0
        While (($Selected -ge $i) -or ($Selected -le 0) -or ($null -eq $Selected)) {
            [int]$Selected = Read-Host ("`nSelect the Number for the SKU to use.")    
        }
        
        Return ($TenantSKU[$Selected - 1].AccountSkuID)
    
    }
    # We need to verify that the submitted SKU is valid
    else {
    
        Write-Log ("Verifying SKU " + $SKUToCheck)
        
        # If we can't find it then throw and error and abort
        if ($null -eq (Get-MSOLAccountSKU | Where-Object { $_.AccountSKUID -eq $SKUToCheck })) {
            Write-Log ("[ERROR] - Did not find SKU with ID: " + $SKUToCheck)
            Write-Error ("Unable to locate SKU " + $SKUToCheck) -ErrorAction Stop
        }
        # If we found it log it
        else {
            Write-Log ("Found SKU")
            Return $SKUToCheck.ToUpper()
        }
    }    
}

# sets list of enabled plans to provided list
Function Set-EnabledPlan {
    param
    (
        [string]$SKU,
        [string[]]$PlansToEnable
    )

    Write-Log "Determining plans to Disable"
    [string[]]$Output = (Get-MsolAccountSku | Where-Object { $_.accountskuid -eq $SKU }).servicestatus.serviceplan.servicename
    
    Foreach ($PlanToRemove in $PlansToEnable) {
        # Take out each of the plans that were provided to us
        $Output = $Output | Where-Object { $_ -ne $PlanToRemove }
    }

    Return $Output
}

# Creates the LicenseOptions variable for setting diabled plans
Function Set-LicenseOption {

    param 
    (
        [string[]]$DisabledPlansArray,
        [Parameter(Mandatory = $true)]
        [string]$SKU    
    )

    # if there are no plans to disable we create the default option set
    if ($null -eq $DisabledPlansArray) {
        Write-log "Setting all SKU Plans to Enabled"
        $licenseOptions = New-MsolLicenseOptions -AccountSKUId $SKU
    }
    # Otherwise add the disabled plans to the option set
    else {
        Write-Log "Setting Disabled Plans License Options"
        Write-Log ("Disabled Plans: " + $DisabledPlansArray)
        $licenseOptions = New-MsolLicenseOptions -AccountSKUId $SKU -DisabledPlans $DisabledPlansArray
    }
    
    Return $licenseOptions
}

# Tests a list of plans agiast availible plans in the SKU
# Return the first plan that fails or null
Function Test-Plan {
    param 
    (
        [string[]]$Plan,
        [string]$Sku
    )

    Write-Log "Validating Provided Plans"
    $PlansInSKU = (Get-MsolAccountSku | Where-Object { $_.accountskuid -eq $sku }).servicestatus.serviceplan.servicename

    # Take each of the provided plans and check it against the plans we found in the SKU
    foreach ($PlanToCheck in $Plan) {
        if ($PlansInSKU -contains $PlanToCheck) { }
        # If we don't find it then throw an error and stop
        else { 
            Write-Log ("[ERROR] - Failed to find plan: " + $PlanToCheck)
            Write-Error ("Invalid plan " + $PlanToCheck + " provided in plan list. Please correct input and try again.") -ErrorAction Stop
        }
    }

    # If we validate them all then log it
    Write-Log "All Plans Valid"
}

##########################################
# Functions to Support change over to MS Graph
##########################################

Function Write-SimpleLogfile {
    [cmdletbinding()]
    Param
    (
        [Parameter(Mandatory = $true)]
        [string]$String,

        [string]$Name = "MGLicenseManagement.log",

        [switch]$OutHost,

        [switch]$OpenLog

    )

    Begin {
        # Get our log file path
        $LogFile = Join-Path $env:LOCALAPPDATA $Name

        if ($OpenLog) {
            Notepad.exe $LogFile
            Exit
        }
    }
    Process {

        # Get the current date
        [string]$date = Get-Date -Format G

        # Build output string
        [string]$logstring = ( "[" + $date + "] - " + $string)

        # Write everything to our log file and the screen
        $logstring | Out-File -FilePath $LogFile -Append -Confirm:$false
        if ($OutHost) { Write-Host $logstring }
        else { Write-Verbose  $logstring }
    }
}

Function Test-MGServiceConnection {

    # Make sure that the MS Graph Module is installed
    if ($null -eq (Get-Module -ListAvailable Microsoft.Graph.Users)) {
        Write-Error "Microsoft Graph Module Not installed. Please install from https://docs.microsoft.com/en-us/powershell/microsoftgraph/overview?view=graph-powershell-beta" -ErrorAction Stop
    }

    # Log out some basic information since we run this at the begining of every cmdlet
    Write-SimpleLogfile ('Module version ' + [array](Get-Module -ListAvailable Microsoft.Graph.Users | sort-object -Property version -Descending)[0].version.tostring())

    # Clear any existing errors
    $error.clear()
    # Try to get a user to test that we have a connection
    try { $null = Get-MgUser -top 1 -ErrorAction Stop }
    catch { 
        Write-SimpleLogFile ("[Error]: " + $_)
        Write-Error "$_" -ErrorAction Stop
    }
}

# Determine if we have an array with UPNs or just a single UPN / UPN array unlabeled
Function Test-MGUserObject {
    param ([array]$ToTest)

    # See if we can get the UserPrincipalName property off of the input object
    # If we can't get the property then test the input and see if we can generate an acceptable object array
    if ($null -eq $ToTest[0].UserPrincipalName) {
        # Very basic check to see if this is a UPN
        # This deals with the input user@company.com,user2@company.com ....
        
        if ($ToTest[0] -match '@') {
            [array]$Output = $ToTest | Select-Object -Property @{Name = "UserPrincipalName"; Expression = { $_ } }
            Return $Output
        }
        # If we couldn't find the UserPrincipalName and we didn't see an @ sign then we need to abort
        else {
            Write-SimpleLogfile "[ERROR] - Unable to determine if input is a UserPrincipalName" -OutHost
            Write-SimpleLogfile "Please provide a UPN or array of objects with the property UserPrincipalName populated" -OutHost
            Write-Error "Unable to determine if input is a User Principal Name" -ErrorAction Stop
        }
    }
    # If we can pull the value of UserPrincipalName then just return the same object back
    else {
        Return $ToTest
    }
}

Function Get-MGLicenseAssignmentMethod {
    param (
        [array]$UserAssignementArray,
        [string]$SkuToResolve
    )

    [array]$Assignement = $UserAssignementArray | where-object { $_.skuid -eq $SkuToResolve }

    # If we didn't get a match then we don't know how this license has been assigned
    If ($Assignement.count -eq 0) {
        $Return = "Unknown"
    } 
    # We shouldn't end up here since we should not have multiple ways to assign a license
    elseif ($Assignement.count -gt 1) {
        $Return = "Multiple"
    } 
    # process if we got a value back
    else {
        # If assign by group is null then we are a direct assignment
        if ($null -eq $Assignement[0].assignedByGroup) {
            $Return = "Explicit"
        } 
        # Else we need to return the display name
        else {
            $Return = Get-MGAssignmentGroup -GroupID $Assignement[0].assignedByGroup
        }
    }

    return $Return
}

Function Get-MGAssignmentGroup {
    param (
        [string]$GroupID
    )

    # If the group ID is in the global variable cache return that
    if ($cache_assignmentgroup -contains $GroupID) {
        return ($cache_assignmentgroup | Where-Object { $_.id -eq $GroupID }).DisplayName
    } 
    # otherwise pull the group from graph and add it to the cache
    else {
        [array]$group = Get-MGGroup -GroupID $GroupID
        Set-Variable -Name cache_assignmentgroup -scope global -value ([array]$cache_assignmentgroup + [array]$group)
        return $group.DisplayName
    }
        

}

# Create and update a progress bar as part of a loop
# Updated for PS 7
Function Update-MGProgress {
    Param 
    (
        [Parameter(Mandatory = $true)]
        [int]$CurrentCount,
        [Parameter(Mandatory = $true)]
        [int]$MaxCount,
        [Parameter(Mandatory = $true)]
        [string]$Message
    )

    # If our counts match then close out the bar
    if ($CurrentCount -eq $MaxCount) {
        Write-Progress -Completed -Activity $Message
    } 
    # Update the progress bar with the current %
    else {
        [int]$Percent = ($CurrentCount / $MaxCount) * 100
        Write-Progress -Activity $Message -Status "$Percent% Complete:"  -PercentComplete $Percent
    }

}

# Validate we have provided a valid SKU or collect one if we have provided none
Function Select-MGSKU {
    param 
    (
        [string]$SKUToCheck,                
        [string]$Message
    )    
    
    # If we don't have a value for the SKU then we need to ask for one.
    if ([string]::IsNullOrEmpty($SKUToCheck)) {
        
        Write-SimpleLogfile ("No SKU value provided. Please select from the availible account SKUs") -OutHost
    
        # Setup a counter
        $i = 1
        
        # Write out the provided message
        Write-Host "`n$Message`n"
        
        
        # Get all of the SKUs and display them with a "selection number"
        $TenantSKU = Get-MSOLAccountSKU
        ForEach ($sku in $TenantSKU) {    
            Write-Host ([string]$i + " - " + $sku.SKUPartNumber)    
            $i++
        }
        
        # Collect the selected item from the user
        [int]$Selected = 0
        While (($Selected -ge $i) -or ($Selected -le 0) -or ($null -eq $Selected)) {
            [int]$Selected = Read-Host ("`nSelect the Number for the SKU to use.")    
        }
        
        Return ($TenantSKU[$Selected - 1].AccountSkuID)
    
    }
    # We need to verify that the submitted SKU is valid
    else {
    
        Write-SimpleLogfile ("Verifying SKU " + $SKUToCheck)
        
        # If we can't find it then throw and error and abort
        if ($null -eq (Get-MSOLAccountSKU | Where-Object { $_.AccountSKUID -eq $SKUToCheck })) {
            Write-SimpleLogfile ("[ERROR] - Did not find SKU with ID: " + $SKUToCheck) -OutHost
            Write-Error ("Unable to locate SKU " + $SKUToCheck) -ErrorAction Stop
        }
        # If we found it log it
        else {
            Write-SimpleLogfile ("Found SKU") -OutHost
            Return $SKUToCheck.ToUpper()
        }
    }    
}