Microsoft 365 Contact Sync to Datto PSA / Autotask
At various points over the years we have had the problem of keeping our PSA contacts in sync with Microsoft 365 solved by different tools. At present we don’t currently have anything in our stack which solves this in a way I am comfortable with. Autotask does have Azure AD sync built in, but it requires a app per customer and I absolutely hate this approach, so it is not something we rolled out ever. To get this to work I had to use a mix of the AutotaskAPI module and direct API calls to talk with Autotask, as the module did not like creating / updating contacts itself.
How the script works
The script can be run in a list only mode which I highly recommend you do the first time to check exactly what it will do.
The script has some matching logic to try and figure out which company in Autotask matches which M365 Tenant. The process it follows is:
- Checks for a UDF field called M365DefaultDomain to see if this value matches the default domain name of the M365 Tenant.
- If nothing is found It will then try to match on the company name to M365 tenant name
- Next it will try to match the Web field on the Autotask company to the default domain
- Next it will try to match any of the verified domains on the M365 tenant to the website field in Autotask
- Finally it will try to match any verified domain in M365 to the domain name of any existing contacts in Autotask companies
The script can be set to work of licensed users only or all users in M365.
It will inactivate any contacts in Autotask which aren’t found in M365 who have one of the customer’s verified domain names in their email address. This will leave vendor and external contacts alone. The exception is it will inactivate any contacts who have no email address, phone number and mobile phone number.
Contacts who will be set to inactive can be overridden by adding a UDF to contacts called M365SyncKeepActive. If you set this value to Y on a contact the script will skip it and keep it active.
When creating users the script will check to see if there is an existing inactive contact that matches that it can re-enable.
When creating contacts the following fields are read from M365 and set in Autotask (It doesn’t matter if they are missing):
- First Name
- Last Name (If first name and last name are not present, it will take the display name in M365 and split this to populate the fields)
- Job Title
- Phone
- Mobile Phone
- Fax
- Address Line 1
- City
- State
- Postal Code
- Email Address (Set to user principal name)
- Email Address 2 (Set to the first proxy email address)
- Email Address 3 (Set to the second proxy email address)
The script does not currently update any existing contacts if their details change in M365. The exception to this would be is their UserPrincipalName changes. In that case it would deactivate the original contact and then create a new one. To avoid this you would need to edit the contact in Autotask to update their email address.
Instructions
- First create a company UDF called M365DefaultDomain. You can set this to the default tenant domain in M365 to manually match a company
- Create a contact UDF called M365SyncKeepActive. You can set this to Y on a contact to never set it to inactive.
- For the first run I would recommend setting $CheckMatchesOnly to $true. This will let you make sure M365 tenants are mapped to Autotask companies correctly. If you match to the wrong company it will create the contacts of the other company in Autotask and cause you lots of problems. Once you are happy set this to $false
- Once companies are correctly matched I would recommend setting $ListContactChangesOnly to $true. This will let you run the script and it will list exactly what it is going to do. Here I would recommend checking all the contacts it is going to deactivate and setting M365SyncKeepActive to Y on any you do not want to be set to inactive.
- You can then set $ListContactChangesOnly to $false. You can control if contacts are created and set to inactive with $CreateUsers = $true and $InactivateUsers = $true. You can also set if you wish to use all contacts in M365 or only licensed users with $licensedUsersOnly = $true
- You can also control if you wish to generate a csv of which users will be deactivated with $GenerateInactiveReport and its location with $InactiveReportName
The Script
View the script on GitHub here https://github.com/lwhitelock/MSP-Automation/blob/main/Autotask/M365AutotaskContactSync.ps1
#### M365 Settings ####
$customerExclude = @("Example Customer","Example Customer 2")
$ApplicationId = 'YourApplicationID'
$ApplicationSecret = 'YourApplicationSecret' | Convertto-SecureString -AsPlainText -Force
$TenantID = 'YourTenantID'
$RefreshToken = 'YourRefreshToken'
$upn = "YourUPN"
#### Autotask Settings ####
$AutotaskIntegratorID = "123456780"
$AutotaskAPIUser = "[email protected]"
$AutotaskAPISecret = "abcdefghjikl1245667799"
########################## End Secrets Management ##########################
#### Script Settings ####
# Autotask API Base, set this to the base that matches your instance.
$AutotaskAPIBase = "https://webservices16.autotask.net"
# 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
# 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 = $false
$InactiveReportName = "C:\Temp\InactiveUsersReport.csv"
# Import only licensed users
$licensedUsersOnly = $true
# Create Users missing in Autotask
$CreateUsers = $true
# Set unlicensed users as inactive in Autotask. (This can be overriden by setting the M365SyncKeepActive UDF on a contact to Y)
$InactivateUsers = $true
########################## Script ############################
# Get Dependencies
if (Get-Module -ListAvailable -Name AzureADPreview) {
Import-Module AzureADPreview
}
else {
Install-Module AzureADPreview -Force
Import-Module AzureADPreview
}
if (Get-Module -ListAvailable -Name PartnerCenter) {
Import-Module PartnerCenter
}
else {
Install-Module PartnerCenter -Force
Import-Module PartnerCenter
}
if (Get-Module -ListAvailable -Name AutotaskAPI) {
Import-Module AutotaskAPI
}
else {
Install-Module AutotaskAPI -Force
Import-Module AutotaskAPI
}
# Get Autotask Companies and Connect
$Creds = New-Object System.Management.Automation.PSCredential($AutotaskAPIUser, $(ConvertTo-SecureString $AutotaskAPISecret -AsPlainText -Force))
Write-Host "Connecting to Autotask"
Add-AutotaskAPIAuth -ApiIntegrationcode $AutotaskIntegratorID -credentials $Creds
Write-Host "Downloading Companies"
$AutotaskCompanies = Get-AutotaskAPIResource -resource Companies -SimpleSearch "isactive eq $true"
Write-Host "Downloading Contacts"
$AutotaskContacts = Get-AutotaskAPIResource -resource Contacts -SimpleSearch "isactive eq $true"
$AutotaskInactiveContacts = Get-AutotaskAPIResource -resource Contacts -SimpleSearch "isactive eq $false"
# Create a map of the company UDF fields
$AutotaskCompanyMap = foreach ($company in $AutotaskCompanies) {
$companyUDF = $company.userDefinedFields | Where-Object { $_.name -eq "M365DefaultDomain" }
[PSCustomObject]@{
M365DefaultDomain = $companyUDF.value
Company = $company
}
}
# Setup Header for manual calls
$headers = @{
'ApiIntegrationCode' = $AutotaskIntegratorID
'UserName' = $AutotaskAPIUser
'Secret' = $AutotaskAPISecret
}
# Prepare webAddresses for lookup
$CompanyWebDomains = foreach ($autocompany in $AutotaskCompanies) {
if ($null -ne $autocompany.webAddress) {
$website = $autocompany.webAddress
$website = $website -replace 'https://'
$website = $website -replace 'http://'
$website = ($website -split '/')[0]
$website = $website -replace 'www.'
[PSCustomObject]@{
companyID = $autocompany.id
website = $website
}
}
}
# Prepare contact domains for matching
$DomainCounts = $AutotaskContacts | Select-Object companyid, @{N = 'email'; E = { $($_.emailAddress -split "@")[1] } } | group-object email, companyid | sort-object count -descending
#Connect to your Azure AD Account.
Write-Host "Connecting to Partner Azure AD"
Write-Host $ApplicationId
Write-Host $ApplicationSecret
$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 Autotask company
# First lets check default domain against UDF
$matchedCompany = ($AutotaskCompanyMap | Where-Object { $_.M365DefaultDomain -eq $defaultdomain }).Company
if (($matchedCompany | measure-object).count -ne 1) {
# Now lets try to match tenant names to company names
$matchedCompany = $AutotaskCompanies | Where-Object { $_.companyName -eq $Customer.DisplayName }
if (($matchedCompany | measure-object).count -ne 1) {
# Now lets try to match to the web address set on the company in Autotask to default domain
$matchedWebsite = $CompanyWebDomains | Where-Object { $_.website -eq $defaultdomain }
if (($matchedWebsite | measure-object).count -eq 1) {
#Populate matched company
$matchedCompany = $AutotaskCompanies | 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 = $AutotaskCompanies | 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 = $AutotaskCompanies | 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 add '$defaultdomain' to a M365DefaultDomain UDF company field in Autotask" -ForegroundColor red
Disconnect-AzureAD
continue
}
}
}
}
else {
Write-Host "Matched on Tenant and Customer Name" -ForegroundColor green
}
}
else {
Write-Host "Matched on UDF" -ForegroundColor green
}
Write-Host "M365 Company: $($Customer.DisplayName) Matched to Autotask Company: $($matchedCompany.companyName)"
if (!$CheckMatchesOnly) {
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
}
$AutoTaskCompanyContacts = $AutotaskContacts | Where-Object { $_.companyID -eq $matchedCompany.ID }
$ContactsToCreate = $M365Users | Where-Object { $_.UserPrincipalName -notin $AutoTaskCompanyContacts.emailAddress -and $_.UserPrincipalName -notmatch "admin" }
$existingContacts = $M365Users | Where-Object { $_.UserPrincipalName -in $AutoTaskCompanyContacts.emailAddress }
$contactsToInactiveRaw = $AutoTaskCompanyContacts | Where-Object { $_.emailAddress -notin $M365Users.UserPrincipalName -and (($($_.emailAddress -split "@")[1]) -in $customerDomains) -or ($_.emailAddress -eq "" -and $_.mobilePhone -eq "" -and $_.phone -eq "") }
$contactsToInactive = foreach ($inactiveContact in $contactsToInactiveRaw) {
$inactiveContactUDF = $inactiveContact.userDefinedFields | Where-Object { $_.name -eq "M365SyncKeepActive" }
if ($inactiveContactUDF.value -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 | Select-Object DisplayName, UserPrincipalName | Out-String)" -ForegroundColor Green
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.lastName
'Email' = $inactiveContact.emailAddress
'Mobile' = $inactiveContact.mobilePhone
'Phone' = $inactiveContact.phone
}
$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 = @{
companyID = $deactivateUser.companyID
id = $deactivateUser.id
isActive = 0
}
$DeactivateJson = $DeactivateBody | convertto-json
try {
$Result = Invoke-WebRequest -Uri "$($AutotaskAPIBase)/ATServicesRest/V1.0/Companies/$($deactivateBody.companyID)/Contacts" -Method PATCH -body $DeactivateJson -ContentType "application/json" -Headers $headers -ea stop
}
catch {
Write-Host "Error Inactivating: $($deactivateUser.firstName) $($deactivateUser.lastName)" -ForegroundColor Red
Write-Host "$($Result | Format-List | Out-String)"
Write-Host $_
continue
}
Write-Host "User Set Inactive: $($deactivateUser.firstName) $($deactivateUser.lastName)"
}
}
# Create Users
if ($CreateUsers -eq $true) {
foreach ($createUser in $ContactsToCreate) {
# Check if there is an inactive matching user
$MatchedInactiveUser = $AutotaskInactiveContacts | Where-Object { $_.emailAddress -eq $createUser.UserPrincipalName -and $_.companyID -eq $matchedCompany.id }
if (($MatchedInactiveUser | Measure-Object).count -eq 1) {
$ActivateBody = @{
companyID = $matchedCompany.id
id = $MatchedInactiveUser.id
isActive = 1
}
$ActivateJson = $ActivateBody | convertto-json
try {
$Result = Invoke-WebRequest -Uri "$($AutotaskAPIBase)/ATServicesRest/V1.0/Companies/$($deactivateBody.companyID)/Contacts" -Method PATCH -body $ActivateJson -ContentType "application/json" -Headers $headers -ea stop
}
catch {
Write-Host "Error Activating: $($createUser.DisplayName)" -ForegroundColor Red
Write-Host "$($Result | Format-List | Out-String)"
Write-Host $_
continue
}
Write-Host "User Set Active $($createUser.DisplayName))"
}
# Generate Name
if ($null -ne $createUser.GivenName -and $null -ne $createUser.Surname) {
$firstName = $createUser.GivenName
$lastName = $createUser.Surname
}
else {
$SplitName = $createUser.DisplayName -split " "
$SplitCount = ($SplitName | measure-object).count
if ($SplitCount -eq 2) {
$firstName = $SplitName[0]
$lastName = $SplitName[1]
}
elseif ($SplitCount -eq 1) {
$firstName = $createUser.DisplayName
$lastName = "-"
}
else {
$firstName = $SplitName[0]
$lastName = $SplitName[$SplitCount - 1]
}
}
# 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
isActive = 1
firstName = $firstName
lastName = $lastName
title = $createUser.JobTitle
phone = $createUser.TelephoneNumber
mobilePhone = $createUser.Mobile
faxNumber = $createUser.FacsimileTelephoneNumber
addressLine = $createUser.StreetAddress
city = $createUser.City
state = $createUser.State
zipCode = $createUser.PostalCode
emailAddress = $createUser.UserPrincipalName
emailAddress2 = $Email2
emailAddress3 = $Email3
}
$CreateJson = $CreateBody | ConvertTo-Json
# Create the user
try {
$Result = Invoke-WebRequest -Uri "$($AutotaskAPIBase)/ATServicesRest/V1.0/Companies/$($matchedCompany.id)/Contacts" -Method POST -body $CreateJson -ContentType "application/json" -Headers $headers
}
catch {
Write-Host "Error Creating: $($createUser.DisplayName)" -ForegroundColor Red
Write-Host "$($Result | Format-List | Out-String)"
Write-Host $_
continue
}
Write-Host "User Created: $($createUser.DisplayName)"
}
}
}
}
Disconnect-AzureAD
}
}
if ($GenerateInactiveReport) {
$GlobalContactsToRemove | Export-Csv $InactiveReportName
Write-Host "Report Written to $InactiveReportName"
}