Synchronizing objects between tenants

A few months ago, I developed a script/tool to use for a rather large customer divesting from an Office 365 Dedicated environment.  As part of the exit, they wanted a contact object in their GAL for every user, contact, and distribution list that existed in the source Office 365 environment.

At the time, GALSync wasn't an option, as we didn't have a way to log into the directory and establish a connector.  That being said, I embarked along a path to create a point-in-time snapshot of their existing Office 365-D environment and create all of the objects not in their tenant address space as contacts.  The net result would be approximately 280,000 new contact objects in the new Office 365 tenant.

The requirements:

  1. Create a static, point-in-time snapshot of all objects in a directory
  2. Prevent objects in the destination tenant's namespace from being created as contacts
  3. Provide a way to identify objects being synchronized in for later removal (if necessary)

Nice to haves:

  1. Mechanism to do a differential (add new objects, delete objects in the destination that no longer existed in the source)
  2. Exclude particular objects from being synchronized
  3. Choose what types of objects to synchronize (Contacts, Mailboxes, MailUsers, Distribution Groups)
  4. Work with multiple environments (Office 365 Dedicated, Exchange Online/Office 365 Multitenant, Exchange On-Premises)
  5. Export-only and Import-Only modes for staged deployments
  6. Test mode to only export/import a few objects of each recipient type

The general framework:

  • Connect to Source environment and gather the objects
  • Export the objects in a format that can be parsed later
  • Connect to the Target environment and import the objects, filtering out the ones that are to be excluded
  • Cleanup

Export

Obviously, the first step is to export the data.  Here are some of the relevant snippets.

In the params section, I define a parameter for RecipientTypes:

 param(
     [ValidateSet('UserMailbox','MailUser','MailContact','MailUniversalSecurityGroup','MailUniversalDistributionGroup')]
     [array]$RecipientTypes = ('UserMailbox','MailUser','MailContact','MailUniversalSecurityGroup','MailUniversalDistributionGroup')
     )

Once that is done, I can then cycle through the various values in RecipientTypes, using it as a variable in the switch statement to designate what type of object I'm going to export.  Specifying the -RecipientTypes parameter during Export will help us control both the size of the export and meet the optional requirement of being able to control what objects to import/export.

A note from Captain Obvious: If you exclude objects on the Export, they won't be available for Import. toxXLXO

 [array]$SourceGAL = @()
Foreach ($Type in $RecipientTypes)
     {
     Switch ($Type)
          {
          UserMailbox
               {
               Write-Host "Exporting $($Resultsize) UserMailbox objects."
               $SourceGAL += Get-Mailbox -Resultsize $ResultSize -WarningAction SilentlyContinue -ErrorAction SilentlyContinue
               }
          MailUser
               {
               Write-Host "Exporting $($Resultsize) MailUser objects."
               $SourceGAL += Get-MailUser -Resultsize $ResultSize -WarningAction SilentlyContinue -ErrorAction SilentlyContinue
               }
          MailContact
               {
               Write-Host "Exporting $($Resultsize) MailContact objects."
               $SourceGAL += Get-MailContact -ResultSize $ResultSize -WarningAction SilentlyContinue -ErrorAction SilentlyContinue
               }
          MailUniversalSecurityGroup
               {
               Write-Host "Exporting $($Resultsize) MailUniversalSecurityGroup objects."
               $SourceGAL += Get-DistributionGroup -RecipientTypeDetails MailUniversalSecurityGroup -ResultSize $ResultSize -WarningAction SilentlyContinue -ErrorAction SilentlyContinue
               }
          MailUniversalDistributionGroup
               {
               Write-Host "Exporting $($Resultsize) MailUniversalDistributionGroup objects."
               $SourceGAL += Get-DistributionGroup -RecipientTypeDetails MailUniversalDistributionGroup -ResultSize $ResultSize -WarningAction SilentlyContinue -ErrorAction SilentlyContinue
               }
          } # End Switch

Getting data is always the easiest part.

In this one-liner, I select all of the attributes that I'm going to need to create the target contact objects, join the proxy addresses into a single column, and preserve the RecipientType (for later import processing).  I also extract the LegacyExchangeDn, since that address will be necessary to add as an x500 proxy address later on to preserve replyability.

 $SourceGAL | Select Alias,DisplayName,@{n="EmailAddresses";e={$_.EmailAddresses -join ";"}},ExternalEmailAddress,FirstName,HiddenFromAddressListsEnabled,LastName,LegacyExchangeDn,Name,PrimarySmtpAddress,RecipientType | Export-Csv -NoTypeInformation $ExportFile -Force

Now, we do the fun stuff.

Filtering

One of the mandatory requirements is to prevent objects that might exist in the target tenant namespaces from being created.  One of the optional requirements takes this a step further--specifying both a list of domains and individual contact objects that we want to exclude from provisioning.

I decided to tackle both the mandatory and optional filtering requirements together.  First, I exclude objects matching domains in the target environment from being created:

 param(
     [array]$ExcludedDomains
     )
# Build the domain-filtered list of objects to add to the GAL
If (!$ExcludedDomains)
     {
     [System.Collections.ArrayList]$DomainFilter = (Get-AcceptedDomain).DomainName | ? { $_ -notlike "*onmicrosoft.com" }
     }

Then, the first part of the optional filtering requirement was to provide a mechanism to exclude additional domains, which I achieved by:

 Else
     {
     [System.Collections.ArrayList]$DomainFilter = (Get-AcceptedDomain).DomainName | ? { $_ -notlike "*onmicrosoft.com" } $DomainFilter += $ExcludedDomains
     } # End If

Fulfilling the last part of the optional filtering requirement was achieved using the same method.  I mean, if it ain't broke...

 param(
     [array]$ExcludedObjects 
     ) 
# Build exclude list. By default, only includes the DiscoverySearchMailbox. 
If (!$ExcludedObjects)
     { 
     [array]$ExcludedObjects = (Get-Mailbox -anr DiscoverySearchMailbox).PrimarySmtpAddress 
     } # End If

Now, we have some array objects that can be passed in during the import process, and can meet both the mandatory and optional filtering requirements together.  On to the import!

Import

At this point, data can be imported.  Since the RecipientTypeDetails parameter was exported earlier, it's available to use on filtering import objects (which also helps meet an optional filtering requirement).  Using a regular expression, we can filter out objects matching the recipient types when we import the CSV for processing:

 If ($RecipientTypes)
     {
     $RecipientTypeFilter = ‘(?i)^(‘ + (($RecipientTypes |foreach {[regex]::escape($_)}) -join "|") + ‘)$’
     Write-Host "Applying RecipientTypes filter $($RecipientTypeFilter) to import list."
     $SourceGAL = Import-Csv $InputFile | ? { $_.RecipientType -match $RecipientTypeFilter }
     } # End If $RecipientTypes
Else
     {
     $SourceGAL = Import-Csv $InputFile
     } # End If/Else $RecipientTypes

And then, it's just a matter of looping through all of the recipient types, checking them against the the various object and domain exclusion filters, and then testing to see if the object already exists in the destination.  I added a pair of parameters to be used during import for flagging objects to be used as "flags" to determine if objects were imported via this script.  The parameters are -SyncAttribute and -SyncAttributeValue.

 param(    
     [ValidateSet('CustomAttribute1','CustomAttribute2','CustomAttribute3','CustomAttribute4','CustomAttribute5','CustomAttribute6','CustomAttribute7','CustomAttribute8','CustomAttribute9','CustomAttribute10','CustomAttribute11','CustomAttribute12','CustomAttribute13','CustomAttribute14','CustomAttribute15')]
        [string]$SyncAttribute,
     [string]$SyncAttributeValue = "StaticGAL"
     )
Foreach ($GALObj in $SourceGAL)
     {
     # Filter out objects whose right-hand side (after @) are contained in the $DomainFilter Array or any object in the $ExcludedObjects array
     If ($DomainFilter -notcontains $GALObj.PrimarySmtpAddress.Split("@")[1] -or $ExcludedObjects -notcontains $GALObj.PrimarySmtpAddress)
          {
          $RecipientType = $GALObj.RecipientType
          Switch ($RecipientType)
               {
               UserMailbox
                    {
                    $Alias = $GALObj.Alias
                    $DisplayName = $GALObj.DisplayName
                    $ExternalEmailAddress = $GALObj.PrimarySmtpAddress
                    $LegDN = "x500:"+$GALObj.LegacyExchangeDN
                    [array]$EmailAddresses = $GALObj.EmailAddresses.Split(";") + $LegDN
                    [array]$EmailAddresses = $EmailAddresses | Sort -Unique
                    $EmailAddressesCount = $EmailAddresses.Count
                    $FirstName = $GALObj.FirstName
                    $LastName = $GALObj.LastName
                    $Name = $GALObj.Name
                    # Needed to convert "True" or "False" value from text to Boolean
                    $HiddenFromAddressListsEnabled = [System.Convert]::ToBoolean($GALObj.HiddenFromAddressListsEnabled)
                    Write-Host "[$i/$objCount]"
                    Write-Host -NoNewline "Source object is a ";Write-Host -NoNewLine -ForegroundColor DarkCyan "mailbox ";Write-Host "for $($ExternalEmailAddress)."

                    # Provision the object
                    Try
                         {
                         Write-Host -NoNewline "Trying to create new contact for ";Write-Host -ForegroundColor Green $ExternalEmailAddress
                         New-MailContact -Alias $Alias -DisplayName $DisplayName -ExternalEmailAddress $ExternalEmailAddress -FirstName $FirstName -LastName $LastName -Name $Name -ea SilentlyContinue
                         Write-Host "   Updating $($EmailAddressesCount) proxy addresses..."
                         Set-MailContact $Alias -HiddenFromAddressListsEnabled $HiddenFromAddressListsEnabled -EmailAddresses $EmailAddresses -Name $Name -ea SilentlyContinue
                         If ($SyncAttribute)
                              {
                              $cmd = "Set-MailContact $Alias -$SyncAttribute $SyncAttributeValue"
                              Invoke-Expression $cmd
                              }
                         Write-Host "-----`n"
                         }
                    Catch
                         {
                         Write-Host -NoNewline "Trying to update contact for ";Write-Host -ForegroundColor Green $ExternalEmailAddress
                         Set-MailContact $Alias -HiddenFromAddressListsEnabled $HiddenFromAddressListsEnabled -EmailAddresses $EmailAddresses -Name $Name -ea SilentlyContinue
                         If ($SyncAttribute)
                              {
                              $cmd = "Set-MailContact $Alias -$SyncAttribute $SyncAttributeValue"
                              Invoke-Expression $cmd
                              }
                         Write-Host "-----`n"
                         }
                    Finally
                         {
                         $Mailboxes++
                         }
                    } # End UserMailbox

Lather, rinse, repeat for every RecipientType.

Cleanup

The final optional requirement was figuring out a way to process deletes (if the script was run more than once) to make sure the directories were kept somewhat in sync.  This is intended to emulate the functionality of GALSync, but is by no means a replacement.  GALSync is much, much faster.  This was really a stretch goal for me to see if it was possible.

First, I created a parameter called -ProcessTargetDeletes to indicate that the script will try to purge objects in the destination environment that don't exist in the source environment file.

 param(
     [switch]$ProcessTargetDeletes
     )

I decided to use a hash table to perform the matching (since hash tables are fast and, in my environment, we had hundreds of thousands of objects to process on both sides). I chose to populate the hash with the key being the "Alias" (since the alias is globally unique) and the value being the object's SMTP address.

While this isn't a replacement for GALSync, it can work in limited scenarios where you may only need a periodic Global Address List export between environments.  The script is built to compare based on the object's Alias (mailNickname).  If you attempt to use this to synchronize objects between two AD Forests, the alias may not be unique, depending on your sAMAccountName naming parameters, so you may end up with unexpected results.  It does leverage the SyncAttribute parameter to identify objects that were created by a previous version of the sync, but as with any software, script, tool, or process that has the ability to remove data, test in a lab that emulates your production environment before deploying code.

 If ($ProcessTargetDeletes)
     {
     # Build source environment hash table based on Alias
     $SourceHash = @{}
     $TargetHash = @{}
     Write-Host "Step [1/4] - Building source object list for processing target environment deletes."
     Foreach ($GALObj in $SourceGAL)
          {
          If ($DomainFilter -notcontains $GALObj.PrimarySmtpAddress.Split("@")[1] -or $ExcludedObjects -notcontains $GALObj.PrimarySmtpAddress)
               {
               $SourceHash[$GALObj.Alias]=$GALObj.PrimarySmtpAddress
               }
          }

In the target environment, I query for objects with the $SyncAttribute set, so that I don't include objects that were created manually.

 # Build target environment hash table based on Alias and Get-Recipient
Write-Host "Step [2/4] - Building target environment list for processing deleted items. This may take a while."
If ($SyncAttribute)
     {
     Get-MailContact -ResultSize Unlimited | ? { $_.$($SyncAttribute) -eq $($SyncAttributeValue) }
     }
Else
     {
     $TargetGAL = Get-MailContact -Resultsize Unlimited
     }
$TargetHash[$TargetGAL.Alias]=$TargetGAL.ExternalEmailAddress
$TargetHashCount = $TargetHash.Count
Write-Host "Imported $($TargetHashCount) objects from target environment."

And then, the compare to figure out which objects to delete.

 # Perform the hash compare
Write-Host "Step [3/4] - Preparing list of objects to delete. This may take a while."
$Deletes = $TargetHash.Keys | ? { $_ -notin $SourceHash.Keys }
$DeletesCount = $Deletes.Count

Once I've got the list of objects to delete, I'm ready to remove them--of course, if the $ProcessTargetDeletes and $ConfirmDeletes switches have been used. Belt and suspenders, baby. Belt and suspenders.

 # Delete target environment contacts
$i = 1
Write-Host "Step [4/4] - Processing deletes."
$DeleteCmd = "Remove-MailContact $obj -confirm:$false -whatif | out-null"
If ($ConfirmDeletes)
     {
     $DeleteCmd = "Remove-MailContact $obj -confirm:$false | out-null"
     }
Foreach ($obj in $Deletes)
     {
     Write-Host "Processing [$i/$DeletesCount] - $($obj) for deletion."
     Invoke-Expression $DeleteCmd
     $i++
     }
} # End If ($ProcessTargetDeletes)

You can get the entire script from https://gallery.technet.microsoft.com/Copy-or-Synchronize-Static-1bf0d4c8.