PowerShell: Improved Microsoft 365 to Halo PSA Company / Contact Sync

I have finally got to a point in our Halo PSA migration where I am able to come back up for some air and have some time to blog again. Moving PSAs is a lot of work but we are already seeing improvements in numerous areas across our service desk since switching, so it has been worth it! Its a much more pleasant tool to spend our days working in!

Halo has a built in integration with Microsoft 365 that will let you synchronise companies and users. At the moment this is quite basic and will only match on company name. It also won’t let you filter to licensed users only. Releasing this I realised I could repurpose my Autotask synchronisation script for Halo to solve most of these problems. The script will set the values Halo uses to match so you will be able to also leverage their integration as well if you want. This makes it possible to just use the script to match companies initially and then sync contacts with the built in integration.

Setup

You will need to add a custom field called “CFM365SyncKeepActive” to users. This can be a select list defaulting to N with Y as the other option. If you set a contact to Y the script will not deactivate the user no matter what. You will also need M365 Secure App Model details and a Halo API app details.

You will then need to enter these at the top of the script and the set the settings below. I would recommend you slowly enable settings and run the script, to make sure things will be mapped as expected and users will created / disabled as expected.

Company matching

The script will take several steps to match companies:

  1. It will check to see if the azuretenantid on a company in Halo matches the Tenant ID from Microsoft 365. You can manually set this in a Halo company from Company ->Settings ->Microsoft Details -> Azure Tenant Id

2. It will attempt to match the Microsoft 365 tenant name to a Halo company name

3. It will attempt to match the Microsoft 365 default domain name to the website field on a company in Halo

4. It will attempt to match any verified Microsoft 365 domain name to the website field on a company in Halo

5. Finally it will attempt to match on user’s email domains under a company to any verified domain in Microsoft 365

Once matched if the $SetHuduAzureID is set to True it will update the tenant ID in Halo and match off that in the future.

Contact Matching

Contact matching is done via matching the Azure AD Object ID to the Halo azureoid attribute or by matching userprincipal name to the email address of a user in Halo. When creating a contact it will first check if there is an existing disabled contact that matches and re-enable that instead.

The Script

https://github.com/lwhitelock/HaloPSA-Automation/blob/main/Halo-M365-Contact-Sync.ps1

#### Halo Settings ####
$HaloClientID = "Your Halo API Client ID"
$HaloClientSecret = "Your Halo API Client Secret"
$HaloURL = "Your Halo API URL"


#### M365 Settings ####
#Microsoft Secure Application Model Info
$customerExclude = @("Customer 1", "Customer 2") 
$ApplicationId = "Your M365 SAM App ID"
$ApplicationSecret = ConvertTo-SecureString -AsPlainText "Your M365 SAM App Secret" -Force
$TenantID = "Your Tenant ID"
$RefreshToken = "Your M365 SAM App Refresh Token"
$UPN = "UPN That was used to generate Refresh Token"

########################## End Secrets Management ##########################
#$VerbosePreference = "continue"
#$DebugPreference = "continue"

#### Script Settings ####

# Recommended to set this to true on the first run so that you can make sure companies are being mapped correctly and fix any issues.
$CheckMatchesOnly = $true

# Set the AzureTenantID in Azure on a Successful match using any other method
$SetHuduAzureID = $false

# Recommended to set this on first run. It will only tell you what the script would have done and not make any changes
$ListContactChangesOnly = $true

# This will enable the generation of a csv report on which items would have been set to inactive.
$GenerateInactiveReport = $true
$InactiveReportName = "C:\Temp\InactiveUsersReport.csv"

# Import only licensed users
$licensedUsersOnly = $true

# Create Users missing in Autotask
$CreateUsers = $false

# Set unlicensed users as inactive in Autotask. (This can be overriden by setting the M365SyncKeepActive UDF on a contact to Y)
$InactivateUsers = $false



##########################          Script         ############################

# Get Dependencies
if (Get-Module -ListAvailable -Name AzureAD.Standard.Preview) {
    Import-Module AzureAD.Standard.Preview 
} else {
    Install-Module AzureAD.Standard.Preview -Force
    Import-Module AzureAD.Standard.Preview
}


if (Get-Module -ListAvailable -Name HaloAPI) {
    Import-Module HaloAPI 
} else {
    Install-Module HaloAPI -Force
    Import-Module HaloAPI
}

if (Get-Module -ListAvailable -Name PartnerCenterLW) {
    Import-Module PartnerCenterLW 
} else {
    Install-Module PartnerCenterLW -Force
    Import-Module PartnerCenterLW
}

# Connect to Halo
Connect-HaloAPI -URL $HaloURL -ClientId $HaloClientID -ClientSecret $HaloClientSecret -Scopes "all"

$HaloCompanies = Get-HaloClient -FullObjects
$HaloContacts = Get-HaloUser -FullObjects

$RawHaloSites = Get-HaloSite
$HaloSites = foreach ($Site in $RawHaloSites) {
    Get-HaloSite -SiteID $Site.id
}


# Prepare webAddresses for lookup
$CompanyWebDomains = foreach ($HaloCompany in $HaloCompanies) {
    if ($null -ne $HaloCompany.website) {
        $website = $HaloCompany.website
        $website = $website -replace 'https://'
        $website = $website -replace 'http://'
        $website = ($website -split '/')[0]
        $website = $website -replace 'www.'
        [PSCustomObject]@{
            companyID = $HaloCompany.id
            website   = $website
        }
    }
}

# Prepare contact domains for matching
$DomainCounts = $HaloContacts | Where-Object { ($_.emailAddress).length -gt 1 } | Select-Object client_id, @{N = 'email'; E = { $($_.emailAddress -split "@")[1] } } | group-object email, client_id | sort-object count -descending

#Connect to your Azure AD Account.
Write-Host "Connecting to Partner Azure AD"
$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)
$aadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.windows.net/.default' -ServicePrincipal -Tenant $tenantID 
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $tenantID 
Connect-AzureAD -AadAccessToken $aadGraphToken.AccessToken -AccountId $UPN -MsAccessToken $graphToken.AccessToken -TenantId $tenantID | Out-Null
$M365Customers = Get-AzureADContract -All:$true
Disconnect-AzureAD

$GlobalContactsToRemove = [System.Collections.ArrayList]@()

foreach ($customer in $M365Customers) {	
    #Check if customer should be excluded
    if (-Not ($customerExclude -contains $customer.DisplayName)) {
        write-host "Connecting to $($customer.Displayname)" -foregroundColor green
        try {
            $CustAadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes "https://graph.windows.net/.default" -ServicePrincipal -Tenant $customer.CustomerContextId
            $CustGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes "https://graph.microsoft.com/.default" -ServicePrincipal -Tenant $customer.CustomerContextId
            Connect-AzureAD -AadAccessToken $CustAadGraphToken.AccessToken -AccountId $upn -MsAccessToken $CustGraphToken.AccessToken -TenantId $customer.CustomerContextId | out-null
        } catch {
            Write-Error "Failed to get Azure AD Tokens"
            continue
        }
		
        $defaultdomain = $customer.DefaultDomainName
        $customerDomains = (Get-AzureADDomain | Where-Object { $_.IsVerified -eq $True }).Name

        # Let try to match to an Halo company
        # First lets check default domain against azuretenantid in Halo
        $matchedCompany = $HaloCompanies | Where-Object { $_.azuretenantid -eq $customer.CustomerContextId }
        if (($matchedCompany | measure-object).count -ne 1) {
            # Now lets try to match tenant names to company names
            $matchedCompany = $HaloCompanies | Where-Object { $_.name -eq $Customer.DisplayName }
            if (($matchedCompany | measure-object).count -ne 1) {
                # Now lets try to match to the web address set on the company in Halo to default domain
                $matchedWebsite = $CompanyWebDomains | Where-Object { $_.website -eq $defaultdomain }
                if (($matchedWebsite | measure-object).count -eq 1) {
                    #Populate matched company
                    $matchedCompany = $HaloCompanies | Where-Object { $_.id -eq $matchedWebsite.companyID }
                    Write-Host "Matched Default Domain to Website" -ForegroundColor green
                } else {
                    # Now to try matching any verified domain to a website
                    $matchedWebsite = $CompanyWebDomains | Where-Object { $_.website -in $customerDomains }
                    if (($matchedWebsite | measure-object).count -eq 1) {
                        $matchedCompany = $HaloCompanies | Where-Object { $_.id -eq $matchedWebsite.companyID }
                        Write-Host "Matched a verified domain to website" -ForegroundColor green
                    } else {
                        # Now try to match on contact domains
                        $matchedContactDomains = $DomainCounts | where-object { (($_.name) -split ',')[0] -in $customerDomains }
                        $matchedIDs = ($matchedContactDomains.name -split ', ')[1] | Select-Object -unique
                        if (($matchedIDs | measure-object).count -eq 1) {
                            $matchedCompany = $HaloCompanies | Where-Object { $_.id -eq $matchedIDs }
                            Write-Host "Matched a verified domain to contacts domain" -ForegroundColor green
                        } else {
                            Write-Host "$($Customer.DisplayName) Could not be matched please set the Azure Tenant Id in the Halo company to $($customer.CustomerContextId)" -ForegroundColor red
                            Disconnect-AzureAD
                            continue
                        }

                    }


                }
				
            } else {
                Write-Host "Matched on Tenant and Customer Name" -ForegroundColor green
            }

            if ($SetHuduAzureID -eq $true) {
                $ClientUpdate = @{
                    id            = $matchedCompany.id
                    azuretenantid = $customer.CustomerContextId
                }
            
                Write-Host "Setting $($Customer.DisplayName) - HaloID: $($matchedCompany.id) - TenantID $TenantID"
                $Null = Set-HaloClient -Client $ClientUpdate
            }
					
        } else {
            Write-Host "Matched on azuretenantid in Halo" -ForegroundColor green
        }
	

        Write-Host "M365 Company: $($Customer.DisplayName) Matched to Halo Company: $($matchedCompany.name)"
		
		
        if ($CheckMatchesOnly -eq $false) {
            try {
                $UsersRaw = Get-AzureADUser -All:$true
            } catch {
                Write-Error "Failed to download users"
                continue
            }

            #Grab licensed users		
            if ($licensedUsersOnly -eq $true) {
                $M365Users = $UsersRaw | where-object { $null -ne $_.AssignedLicenses.SkuId } | Sort-Object UserPrincipalName
            } else {
                $M365Users = $UsersRaw 
            }

            $HaloCompanyContacts = $HaloContacts | Where-Object { $_.client_id -eq $matchedCompany.id }
            $ContactsToCreate = $M365Users | Where-Object { $_.ObjectId -notin $HaloCompanyContacts.azureoid -and $_.UserPrincipalName -notmatch "admin" }
            $existingContacts = $M365Users | Where-Object { $_.ObjectId -in $HaloCompanyContacts.azureoid }
            $contactsToInactiveRaw = $HaloCompanyContacts | Where-Object { $_.azureoid -notin $M365Users.ObjectId -and $_.emailAddress -notin $ContactsToCreate.UserPrincipalName -and (($($_.emailAddress -split "@")[1]) -in $customerDomains) -or ($_.emailAddress -eq "" -and $_.mobilePhone -eq "" -and $_.phone -eq "") }
            $contactsToInactive = foreach ($inactiveContact in $contactsToInactiveRaw) {
                $inactiveContactUDF = $inactiveContact.customfields | Where-Object { $_.name -eq "CFM365SyncKeepActive" }
                if ($inactiveContactUDF.display -ne 'Y') {
                    $inactiveContact
                }
            }
			
            Write-Host "Existing Contacts"
            Write-Host "$($existingContacts | Select-Object DisplayName, UserPrincipalName | Out-String)"
            Write-Host "Contacts to be Created"
            Write-Host "$($ContactsToCreate | where-object {$_.UserPrincipalName -notin $HaloCompanyContacts.emailAddress} | Select-Object DisplayName, UserPrincipalName | Out-String)" -ForegroundColor Green
            Write-Host "Contacts to be Paired"
            Write-Host "$($ContactsToCreate | where-object {$_.UserPrincipalName -in $HaloCompanyContacts.emailAddress} | Select-Object DisplayName, UserPrincipalName | Out-String)" -ForegroundColor Yellow
            Write-Host "Contacts to be set inactive"
            Write-Host "$($contactsToInactive | Select-Object firstName, lastName, emailAddress, mobilePhone, phone | Format-Table | out-string)" -ForegroundColor Red

			
            if ($GenerateInactiveReport) {
                foreach ($inactiveContact in $contactsToInactive) {
                    $ReturnContact = [PSCustomObject]@{
                        'Company'    = $customer.DisplayName
                        'First Name' = $inactiveContact.firstname
                        'Last Name'  = $inactiveContact.surname
                        'Email'      = $inactiveContact.emailAddress
                        'Mobile'     = $inactiveContact.mobilenumber2
                        'Phone'      = $inactiveContact.phonenumber
                    }
                    $null = $GlobalContactsToRemove.add($ReturnContact)
                }
            }
			
            # If not in list only mode carry out changes
            if ($ListContactChangesOnly -eq $false) {
                # Inactivate Users
                if ($InactivateUsers -eq $true) {
                    foreach ($deactivateUser in $contactsToInactive) {
                        $DeactivateBody = @{
                            id       = $deactivateUser.id
                            inactive = $true
                        }
						
                        try {
                            $Result = Set-HaloUser -User $DeactivateBody
                            Write-Verbose "User Set Inactive: $($deactivateUser.firstName) $($deactivateUser.surname)"
                            Write-Debug $Result
                        } catch {
                            Write-Error "Error Inactivating:  $($deactivateUser.firstName) $($deactivateUser.surname)"
                            Write-Error "$($DeactivateBody | convertto-json | out-string)"
                            continue
                        }
						
						
                    }
                }

                # Create Users
                if ($CreateUsers -eq $true) {
                    # Get the inactive contacts for the company
                    $HaloCompanyInactiveContacts = Get-HaloUser -IncludeInactive -FullObjects -client_id $MatchedCompany.id | where-object { $_.inactive -eq $true }

                    foreach ($createUser in $ContactsToCreate) {
                        # Find the site for the contact
                        $ContactSite = $HaloSites | Where-Object { $_.delivery_address.line1 -eq $createUser.StreetAddress -and $_.client_id -eq $matchedCompany.id }
                        if (!$ContactSite) {
                            $ContactSite = $HaloSites | where-object { $_.id -eq $MatchedCompany.main_site_id }
                        }

                        # Check if there is a user who just needs azureoid to be set
                        $MatchedUnpairedUser = $HaloCompanyContacts | Where-Object { $_.emailAddress -eq $createUser.UserPrincipalName }
                        if (($MatchedUnpairedUser | measure-object).count -eq 1) {
                            $UpdateBody = @{
                                id       = $MatchedUnpairedUser.id
                                azureoid = $createUser.ObjectId
                            }
                            try {
                                $Result = Set-HaloUser -User $UpdateBody
                                Write-Verbose "User Paired to existing user $($createUser.DisplayName)"
                                Write-Debug $Result
                                Continue
                            } catch {
                                Write-Error "Error Pairing:  $($createUser.DisplayName)"
                                Write-Error "$($UpdateBody | convertto-json | out-string)"
                                continue
                            }
							
                        }

                        # Check if there is an inactive matching user
                        $MatchedInactiveUser = $HaloCompanyInactiveContacts | Where-Object { $_.emailAddress -eq $createUser.UserPrincipalName }
                        if (($MatchedInactiveUser | Measure-Object).count -eq 1) {
                            $ActivateBody = @{
                                id       = $MatchedInactiveUser.id
                                inactive = $false
                                azureoid = $createUser.ObjectId
                            }
                            try {
                                $Result = Set-HaloUser -User $ActivateBody
                                Write-Verbose "User Set Active $($createUser.DisplayName))"
                                Write-Debug $Result
                                Continue
                            } catch {
                                Write-Error "Error Activating:  $($createUser.DisplayName)"
                                Write-Error "$($ActivateBody | convertto-json | out-string)"
                                continue
                            }
							
							
                        }
								
						
                        # Get Email Addresses
                        $Email2 = ""
                        $Email3 = ""
                        $aliases = (($createUser.ProxyAddresses | Where-Object { $_ -cnotmatch "SMTP" -and $_ -notmatch ".onmicrosoft.com" }) -replace "SMTP:", " ")
                        $AliasCount = ($aliases | measure-object).count
                        if ($AliasCount -eq 1) {
                            $Email2 = $aliases
                        } elseif ($AliasCount -gt 1) {
                            $Email2 = $aliases[0]
                            $Email3 = $aliases[1]
                        }

                        # Build the body of the user
                        $CreateBody = @{
                            companyID     = $matchedCompany.id
                            name          = $createUser.DisplayName
                            firstName     = $createUser.GivenName
                            lastName      = $createUser.Surname

                            title         = $createUser.JobTitle
							
                            phonenumber   = $createUser.TelephoneNumber
                            mobilenumber2 = $createUser.Mobile
                            fax           = $createUser.FacsimileTelephoneNumber
							
                            site_id       = $ContactSite.id

                            emailAddress  = $createUser.UserPrincipalName
                            email2        = $Email2
                            email3        = $Email3
                            azureoid      = $createUser.ObjectId
							
                        }

                        # Create the user
                        try {
                            $Result = New-HaloUser -User $CreateBody
                            Write-Verbose "User Created: $($createUser.DisplayName)"
                            Write-Debug $Result

                        } catch {
                            Write-Error "Error Creating:  $($createUser.DisplayName)"
                            Write-Error "$($CreateBody | convertto-json | out-string)"
                            continue
                        }
						
						

                    }
                }
	
            }

        }

        Disconnect-AzureAD

    }		
}


if ($GenerateInactiveReport) {
    $GlobalContactsToRemove | Export-Csv $InactiveReportName
    Write-Host "Report Written to $InactiveReportName"
}

You may also like...