#Author: Jos Lieben (OGD) #Date: 15-05-2017 #Script home: www.lieben.nu #Copyright: Leave this header intact, credit the author, otherwise free to use #Purpose: sync distribution groups between Office 365 and an onprem Active Directory (Exchange not mandatory) #Requires –Version 3 #example call: .\O365GroupSync.ps1 -o365login "admin@groupsync.onmicrosoft.com" -o365password "" [-differential] [-syncDirection 1|2|3] <#Notes / features *Dynamic Distribution Lists in Active Directory are completely ignored *DisplayName is a REQUIRED attribute for groups on either side *LegacyExchangeDN is added as X500 address to O365 group to support cached outlook addressbook entries *existing X400/X500 or non-SMTP: type addresses will be ignored as they won't match existing domains in O365 *O365 Group and AD Anchor = DisplayName *Member object Anchor = proxyAddresses vs emailAddresses comparison, 1 match = total match *Selection for O365 can be based on a name prefix (non case-sensitive) *Autodetection of onprem and exchange online accepted domains *Auto elevation *membership change support (add/delete) *group membership change support (add/delete) *group rename support *group deletion support *new group detection and creation *hiding from address list supported *require authenticated sender supported *sync proxyaddress changes (only during differential sync) *automatically add display name to groups that don't have one *send email notification of results REQUIREMENTS: -Active Directory PS Module available -Properly configured parameters (see below) -Sufficient rights in AD and O365 for the accounts used -Every target user or group HAS to have an email address, and every group HAS to have displayName set correctly TODO: -memberjoin/memberdepart restrictions -cache only required properties when caching O365 objects / storing custom versions -check group managers during full sync -check permitted senders during full sync CHANGELOG: V0.13: creation of groups both ways, autodetection of available domains on both sides V0.14 02/09/2016 JosL: membership change support V0.15 02/09/2016 JosL: group rename support V0.16 02/09/2016 JosL: group deletion support V0.17 04/09/2016 JosL: improved group caching to include 1 layer of members, added full/diff option V0.18 05/09/2016 JosL: implemented ReadOnly and differential switches V0.19 06/09/2016 JosL: full sync of group membership implemented without differential / cache V0.20 09/09/2016 JosL: support for Contact and MailUser objects V0.21 09/09/2016 JosL: proxyAddresses change support V0.22 13/09/2016 JosL: delete cache after using it to prevent it being used twice V0.23 20/09/2016 JosL: write allowed senders (groups or members) to groups upon creation V0.24 23/09/2016 JosL: switched to Filter without object cache for performance V0.25 23/09/2016 JosL: automatically add a displayName to an AD group based on CN if displayName is empty V0.25 23/09/2016 JosL: visual progress indicator when caching AD or O365 groups in % and timeleft V0.26 23/09/2016 JosL: detect and process changes to allowed senders V0.27 23/09/2016 JosL: first create all groups, then process membership and sender auth V0.28 25/10/2016 JosL: no auto elevation, just terminate with an error when not running elevated V0.29 25/10/2016 JosL: added email notification function V0.30 01/11/2016 JosL: added prefix filter for AD group selection V0.31 07/11/2016 JosL: automated version check V0.32 29/11/2016 JosL: reduce severity from error to warning when member of group cannot be found V0.32 30/11/2016 JosL: 2 new functions to replace Compare-Object, as this wasn't performing well V0.33 30/11/2016 JosL: new O365 caching method, direct lookups were too slow V0.34 02/12/2016 JosL: fixed cache compare with new function names, improved lookup of AD users with smtp: prefix to avoid false positives V0.35 05/12/2016 JosL: bugfixes, improved logging (filter), new parameter: skipDisabledAccounts, better progress indicators V0.36 05/12/2016 JosL: revamped O365 cache with special fast lookup hashtable to increase performance, implemented use of this new cache hash table V0.37 06/12/2016 JosL: added debug mode, don't count version mismatch as double error V0.38 08/12/2016 JosL: re-cache O365 Objects after creating groups, so nested groups get nested right away instead of at next run V0.39 13/12/2016 JosL: returnChangedMembersInADGroups function optimized V0.40 13/12/2016 JosL: implemented .NET streamwriter vs add-content to avoid file locking issues of the logfile V0.41 13/12/2016 JosL: implemented fastSearch cache in both group retrieval functions to reduce time needed to compare group members V0.42 13/12/2016 JosL: changed full/differential switches, full = run a full, differential = run a differential, no longer exclude one another V0.43 13/12/2016 JosL: allow X500 addresses to sync at initial creation V0.44 20/12/2016 JosL: remove SMTP prefix when searching the fastsearch cache, as these are saved without a prefix V0.44 20/12/2016 JosL: removed log spam about skipping inactive accounts V0.45 29/12/2016 JosL: when creating O365 groups, set managedBy after creating all groups (in case a group is an owner this prevents the group not being found yet) V0.46 03/01/2017 JosL: adjusted mail parameters to optionally only email when there are issues, or changes, or always V0.47 17/01/2017 JosL: send error mail if log file is locked and causes a crash. Process renames before anything else in a differential sync V0.48 17/01/2017 JosL: reconnect to Exchange Online each time we're caching objects, fixed a bug in detecting Deleted Groups on both sides V0.49 17/01/2017 JosL: moved all non-creation processing steps for AD groups to post-processing function, overwrite primary smtp instead of updating when going from AD->O365 V0.50 17/01/2017 JosL: set mailNickName and LegacyExchangeDN properties when creating group in AD so exchange picks it up in the list V0.51 31/01/2017 JosL: actually send email notification if logfile is locked, prevent running twice (autokill other same script PS processes) V0.52 07/02/2017 JosL: use Set-ADGroup vs Add-ADGroupMember and Remove-AdGroupMember as the latter don't support contacts in certain situations (undocumented issue: http://stackoverflow.com/questions/28542825/powershell-add-adgroupmember-objectclasscontact-to-a-distribution-group-error) V0.53 13/03/2017 JosL: multi-delete protection, prevents deletion of all groups and forces manual action by admin V0.54 03/04/2017 JosL: retry if membership retrieval of an Office 365 group fails and remove previous sessions when reconnecting to O365 ExO V0.55 11/04/2017 JosL: clear hint about how to use multiple recipients for mail notification, custom ExO session health / reconnect function V0.56 15/05/2017 JosL: group selector expanded to optionally select based on the extensionattribute in AD/O365 as an alternative to the display name prefix. Update all your groups before using this if you're switching to this mode #> Param( [Parameter(Mandatory=$true)][String]$o365login, [Parameter(Mandatory=$true)][String]$o365password, [Parameter(Mandatory=$true)][Int]$syncDirection = 1, #specifies sync direction, #syncDirection 1: if a change originates in either location, sync it to the other #syncDirection 2: AD is leading, membership changes or new / deleted groups in O365 will not be synced to AD #syncDirection 3: O365 is leading, membership changes or new / deleted groups in AD will not be synced to O365 [Switch]$differential, #If this switch is used, a differential scan will run (will fail if cache files are missing) [Switch]$full, #If this switch is used, a full scan will run [Switch]$readOnly, #supply this switch to ensure no actual data is changed. [Switch]$debugMode #if you add this switch, a transcript log will be written to the same folder as the log folder ) #region SCRIPT CONFIGURATION: $sourceOU = "OU=Groups,OU=2 XXX Sweden,DC=XXX,DC=LOCAL" #sourceOU: LDAP path to one or more source OU's to sync to O365, seperate multiple OU's with a semicolon (;) $targetOU = "OU=Groups,OU=2 XXX Sweden,DC=XXX,DC=LOCAL" #targetOU: LDAP path to ONE OU to sync groups back to from O365, if omitted new groups will not be synced from Office 365 to AD $domainsPresentLocally = "" #domains that are configured in O365, seperate multiple entries with a comma (,), if left empty will be autodetected $domainsPresentInCloud = "" #domains that are configured in onprem Exchange, seperate multiple entries with a comma (,), if left empty will be autodetected $cloudGroupNamePrefix = "" #prefix if you wish to only sync back a subselection of groups in Office 365, e.g. NLD-The Hague, not compatible with $cloudCustomAttributeValue $localGroupNamePrefix = "" #prefix if you wish to only sync a subselection of groups to Office 365, e.g. NLD-The Hague, not compatible with $localCustomAttributeValue $cloudCustomAttributeValue = "" #value of this field if you wish to only sync back a subselection of groups in Office 365, e.g. NLD. Will customAttribute2 in selection, not compatible with $cloudGroupNamePrefix $localCustomAttributeValue = "" #value of this field if you wish to only sync a subselection of groups to Office 365, e.g. NLD. Will extensionAttribute2 in selection, not compatible with $localGroupNamePrefix $logPath = "c:\ogd\O365GroupSync.log" #the log file can contain important hints if there are issues, and can be used to roll back changes, clean up as often as you like $autoRemediateGroupDisplayNames = $True #if set, will automatically correct displayNames of groups where the group CN does not match the display name, or the group does not have a display name $cacheFolderPath = "c:\ogd" #note, if the cache is deleted, the script will not process updates/deletes until it has rebuilt the cache, this could cause deleted groups to reappear during the first run $defaultDLAdminInO365 = "admin@groupsync.onmicrosoft.com" #this user becomes owner of all Office 365 Distribution Groups the script creates that currently do not have a valid 'ManagedBy' set in Active Directory $verbose = $True #more verbose logging and on screen display $sendMailSummary = $False #enable to have the script send a summary of errors/warnings/objects changed, currently logged in user's credentials will be used $sendMailSummary_When = "Errors,Warnings,Changes" #options: Always (always send a summary), Warnings (if at least 1 warning), Errors (if at least 1 error), Changes (if at least 1 change). Seperate with Comma if choosing multiple $sendMailSummary_From = "" #email address to send email FROM $sendMailSummary_To = "" #email address to send summary email to, if multiple, use the following format: "address1@domain.com", "address2@domain.com" $sendMailSummary_Server = "" #server name/fqdn or IP address to submit mail to $sendMailSummary_Port = 25 #server port to submit mail on $sendMailSummary_SSL = $False #set to True if your server requires SSL $versionCheck = $True #does a version check and logs an error if the script is not on the most recent version $skipDisabledAccounts = $True #if set to True, the script won't add inactive accounts from the source AD to groups in Office 365, note that shared mailboxes are usually inactive accounts $maxProjectedDeletesProtection = 0 #if set to anything above 0, will crash the script with admin notification when more than this number of deletes are queued. ####END OF CONFIGURATION, DON'T TOUCH ANYTHING FROM HERE ON $totalWarnings = 0 $totalErrors = 0 $totalChanges = 0 $totalChanged = 0 $ADObjectCache = @() $version = "0.56" $scriptName = "O365GroupSync" $WarningActionPreference = "Stop" $ErrorActionPreference = "Stop" #endregion #Check if elevated If (-NOT ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator")){ $arguments = "& '" + $myinvocation.mycommand.definition + "'" Throw "This script requires elevation to run properly, please run this script as an Administrator" Read-Host "Press any key to exit" Exit } if($debugMode){ $transcriptPath = "$($logPath)-debug" Start-Transcript -Path $transcriptPath -append } function validateExOConnection{ Param( [Parameter(Mandatory=$true)]$o365login, [Parameter(Mandatory=$true)]$o365password, [switch]$retry ) if($script:Session -eq $Null -or $script:Session.State -ne "Opened"){ #There is no session, or it has gone stale log -text "Exchange Online connection not available or has gone stale, attempting to connect" -color "Green" $failed = $False try{ $secpasswd = ConvertTo-SecureString $o365password -AsPlainText -Force $Credentials = New-Object System.Management.Automation.PSCredential ($o365login, $secpasswd) $a = New-PSSessionOption $a.IdleTimeout = 432000000000 $script:Session = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri https://outlook.office365.com/powershell-liveid/ -Credential $Credentials -Authentication Basic -AllowRedirection -SessionOption $a $res = Import-PSSession $Session -AllowClobber -DisableNameChecking -WarningAction SilentlyContinue log -text "Connected to Exchange Online" -color "Green" return $Null }catch{ $failed = $True log -text "Failed to connect to Exchange Online!" -color "Red" -append -error } if($failed -and !$retry){ validateExoConnection -o365login $o365login -o365password $o365password -retry return $Null } if($failed -and $retry){ log -text "Failed to connect to Exchange Online twice! Aborting" -color "Red" -append -error abort_GS -failed } } } function preventDoubleSchedule{ try{ $scriptFileName = split-path $MyInvocation.PSCommandPath -Leaf }catch{$scriptFileName = $Null} try{ [Array]$psProcesses = @(Get-WmiObject Win32_Process -Filter "name like '%Powershell.exe%' and handle != '$pid'" | where {$_}) }catch{ Throw } if($psProcesses.Count -gt 0){ foreach($psProcess in $psProcesses){ if($psProcess.CommandLine -like "*$scriptFileName*" -and $scriptFileName){ ##we've found a Powershell process that is running this script, but does not have the same process ID, lets try to kill it try{ Stop-Process -Id $psProcess.Handle -Force -Confirm:$False }catch{ Throw } } } } } function JosL-WebRequest{ Param( $url ) $maxAttempts = 3 $attempts=0 while($true){ $attempts++ try{ $retVal = @{} $request = [System.Net.WebRequest]::Create($url) $request.TimeOut = 5000 $request.UserAgent = "Lieben Consultancy" $response = $request.GetResponse() $retVal.StatusCode = $response.StatusCode $retVal.StatusDescription = $response.StatusDescription $retVal.Headers = $response.Headers $stream = $response.GetResponseStream() $streamReader = [System.IO.StreamReader]($stream) $retVal.Content = $streamReader.ReadToEnd() $streamReader.Close() $response.Close() return $retVal }catch{ if($attempts -ge $maxAttempts){Throw}else{sleep -s 2} } } } function versionCheck{ Param( $currentVersion ) $apiURL = "http://www.lieben.nu/lieben_api.php?script=$scriptName&version=$currentVersion" $apiKeyword = "latest$($scriptName)Version" try{ $result = JosL-WebRequest -Url $apiURL }catch{ Throw "Failed to connect to API url for version check: $apiURL $($Error[0])" } try{ $keywordIndex = $result.Content.IndexOf($apiKeyword) if($keywordIndex -lt 1){ Throw "" } }catch{ Throw "Connected to API url for version check, but invalid API response" } $latestVersion = $result.Content.SubString($keywordIndex+$apiKeyword.Length+1,4) if($latestVersion -ne $currentVersion){ Throw "$scriptName version mismatch, current version: v$currentVersion, latest version: v$latestVersion" } } function abort_GS{ Param( [Switch]$failed ) try{ $htmlBody = "Execution details for O365GroupSync

Total changes detected: $($totalChanges)
Total changes processed: $($totalChanged)
Total number of warnings: $($totalWarnings)
Total number of errors: $($totalErrors)
Script windows user: $($env:USERNAME)
Script O365 user: $o365login
Source machine: $($env:COMPUTERNAME)
Logfile path on source machine: $logPath


O365 GroupSync v$version by www.lieben.nu" if($sendMailSummary){ $sendMailSummary = $False if($failed){$sendMailSummary = $True} $reasons = $sendMailSummary_When.Split(",") if($reasons -contains "Warnings" -and $totalWarnings -gt 0){ $sendMailSummary = $True } if($reasons -contains "Always"){ $sendMailSummary = $True } if($reasons -contains "Changes" -and $totalChanges -gt 0){ $sendMailSummary = $True } if($reasons -contains "Errors" -and $totalErrors -gt 0){ $sendMailSummary = $True } } }catch{ write-output "Error parsing mail content before sending it $($Error[0])" } if($failed){ try{log -text "Script execution terminated prematurely because of an error, see earlier log entries for details" -color "Red"}catch{$Null} if($sendMailSummary){ sendMailSummary -subject "O365GroupSync CRITICAL ERROR" -body "O365GroupSync failed to run properly, please check the log: $($env:COMPUTERNAME) $logPath.

The last error was: $($script:Error[0])" } }else{ try{log -text "Script execution terminated normally, there were $totalErrors errors and $totalWarnings warnings" -color "Green"}catch{$Null} if($sendMailSummary){ sendMailSummary -subject "O365GroupSync ran ($totalErrors errors)" -body $htmlBody } } try{$script:logFileStreamWriter.Dispose();$script:logFileStream.Dispose();}catch{$Null} if($debugMode){Stop-Transcript} try{ Get-PSSession | Remove-PSSession -ErrorAction SilentlyContinue -WarningAction SilentlyContinue -Confirm:$False Get-Variable -Scope Local | Remove-Variable -Scope Local -ErrorAction SilentlyContinue -WarningAction SilentlyContinue -Confirm:$False -Force Get-Variable -Scope Script | Remove-Variable -Scope Script -ErrorAction SilentlyContinue -WarningAction SilentlyContinue -Confirm:$False -Force }catch{$Null} Exit } function sendMailSummary{ Param( [String]$subject, [String]$body ) try{ if($sendMailSummary_SSL){ $res = Send-MailMessage -To $sendMailSummary_To -From $sendMailSummary_From -Subject $subject -BodyAsHtml $body -UseSsl -SmtpServer $sendMailSummary_Server -Port $sendMailSummary_Port }else{ $res = Send-MailMessage -To $sendMailSummary_To -From $sendMailSummary_From -Subject $subject -BodyAsHtml $body -SmtpServer $sendMailSummary_Server -Port $sendMailSummary_Port } log -text "mail summary sent to $sendMailSummary_To" -color "Green" }catch{ log -text "failed to send mail summary to $sendMailSummary_To $res" -color "Red" -append } } function retrieveRemoteAcceptedDomains{ try{ $domains = Get-AcceptedDomain | select DomainName -ExpandProperty DomainName -ErrorAction Stop return $domains }catch{ Throw "Failed to retrieve AcceptedDomains in Exchange Online $($Error[0])" } } function retrieveOnpremAcceptedDomains{ Param( [Parameter(Mandatory=$true)]$ADObjectCache ) try{ $domains = @() foreach($object in $ADObjectCache){ foreach($email in $object.ProxyAddresses){ if($email -like "*@*"){ $email = $email.Split("@",[System.StringSplitOptions]::RemoveEmptyEntries)[1] if($email -like "*.*" -and $domains -notcontains $email){ $subdomain = $email.Split(".",[System.StringSplitOptions]::RemoveEmptyEntries)[1] if($subdomain.Length -gt 1){ $domains += $email } } } } } return $domains }catch{ Throw "Failed to retrieve unique proxy addresses from Active Directory $($Error[0])" } } function checkIfAddressIsAllowed{ Param( [Parameter(Mandatory=$true)]$address, [Parameter(Mandatory=$true)]$mode #1 = check if it is allowed in the cloud, #2 = check if it is allowed on premises ) if($address.IndexOf("X500",[System.StringComparison]::CurrentCultureIgnoreCase) -eq 0){return $True} try{ $domainPortion = $address.Split("@")[1] }catch{ return $False } if($domainPortion){ if($mode -eq 1){ if($domainsPresentInCloud -contains $domainPortion){ return $True }else{ return $False } } if($mode -eq 2){ if($domainsPresentLocally -contains $domainPortion){ return $True }else{ return $False } } }else{ return $True } } function log{ param ( [Parameter(Mandatory=$true)][String]$text, [Parameter(Mandatory=$true)][String]$color, [Switch]$append, [Switch]$error, [Switch]$warning ) if($error){ $script:totalErrors++ $text = "ERROR | $text" } elseif($warning){ $script:totalWarnings++ $text = "WARNING | $text" } else{ $text = "INFO | $text" } try{ if($append){$text = "$text, $($global:Error[0].Exception) at $($global:Error[0].InvocationInfo.ScriptLineNumber) $($global:Error[0].ErrorDetails)"} }catch{$text = "$text,failed to append error message"} try{ $script:logFileStreamWriter.WriteLine("$(Get-Date) | $text") }catch{$Null} Write-Host $text -ForegroundColor $color } function cacheADObjects{ try{ $ADObjects = @(Get-ADObject -Filter {(ObjectClass -eq "user" -or ObjectClass -eq "Group" -or ObjectClass -eq "contact")} -Properties proxyAddresses,name,DistinguishedName,DisplayName,ObjectGuid,mail,cn,managedBy,authOrig,dLMemSubmitPerms -ErrorAction Stop) }catch{ Throw "Failed to cache AD objects. $($Error[0]) $ADObjects" } return $ADObjects } function cacheO365Objects{ validateExOConnection -o365login $o365login -o365password $o365password $O365Cache = @{} $O365Cache.FastSearch = @{} $script:WarningActionPreference = "SilentlyContinue" $script:ErrorActionPreference = "SilentlyContinue" Write-Progress -Activity "Retrieving O365 Objects" -PercentComplete 0 -Status "Processing mailboxes" #first look for mailboxes try{ [Array]$O365Cache.Mailboxes = @() get-mailbox -ResultSize Unlimited -erroraction SilentlyContinue -warningAction SilentlyContinue | % { if($_){ $O365Cache.Mailboxes += $_ $O365Cache.FastSearch."$($_.primarySmtpAddress)" = "mbx_$($O365Cache.Mailboxes.Count-1)" } } }catch{$Null} Write-Progress -Activity "Retrieving O365 Objects" -PercentComplete 25 -Status "Processing contacts" #now look for contacts try{ [Array]$O365Cache.MailContacts = @() get-mailcontact -ResultSize Unlimited -erroraction SilentlyContinue -warningAction SilentlyContinue | % { if($_){ $O365Cache.MailContacts += $_ $O365Cache.FastSearch."$($_.primarySmtpAddress)" = "con_$($O365Cache.MailContacts.Count-1)" } } }catch{$Null} Write-Progress -Activity "Retrieving O365 Objects" -PercentComplete 50 -Status "Processing mail users" #now look for mailusers try{ [Array]$O365Cache.MailUsers = @() get-mailuser -ResultSize Unlimited -erroraction SilentlyContinue -warningAction SilentlyContinue | % { if($_){ $O365Cache.MailUsers += $_ $O365Cache.FastSearch."$($_.primarySmtpAddress)" = "usr_$($O365Cache.MailUsers.Count-1)" } } }catch{$Null} Write-Progress -Activity "Retrieving O365 Objects" -PercentComplete 75 -Status "Processing groups" #now look for groups try{ [Array]$O365Cache.Groups = @() Get-distributiongroup -ResultSize Unlimited -erroraction SilentlyContinue -warningAction SilentlyContinue | % { if($_){ $O365Cache.Groups += $_ $O365Cache.FastSearch."$($_.primarySmtpAddress)" = "grp_$($O365Cache.Groups.Count-1)" } } }catch{$Null} Write-Progress -Activity "Retrieving O365 Objects" -PercentComplete 100 -Status "O365 Objects cached!" $script:WarningActionPreference = "Stop" $script:ErrorActionPreference = "Stop" return $O365Cache } function retrieveADGroupsAndMembers{ Param( [Parameter(Mandatory=$true)]$sourceOU ) #retrieve current groups in AD $sourceOUs = $sourceOU.Split(";",[System.StringSplitOptions]::RemoveEmptyEntries) $adDistributionGroups = @() $adDistributionGroupsAndMembers = @() $starttime = Get-Date foreach($ou in $sourceOUs){ try{ $adDistributionGroups += Get-ADGroup -Filter {mail -like "*"} -Properties * -SearchBase $ou -SearchScope Subtree -ErrorAction Stop }catch{ Throw "Failed to load groups from $ou $($Error[0])" } } if($adDistributionGroups[0].DisplayName.Length -gt 0){ if($adDistributionGroups.Count -gt 1){ $adDistributionGroups = $adDistributionGroups.GetEnumerator() | sort -Property name } if($localGroupNamePrefix){ $localGroupNamePrefix = "$($localGroupNamePrefix)*" if($verbose){log -text "using $localGroupNamePrefix as filter in AD distribution group selection" -color "Green"} $adDistributionGroups = $adDistributionGroups | where {$_.displayName -like $localGroupNamePrefix} } if($localCustomAttributeValue){ if($verbose){log -text "using $localCustomAttributeValue as filter in AD distribution groups, only groups with this value in extensionAttribute2 will be synced" -color "Green"} $adDistributionGroups = $adDistributionGroups | where {$_.extensionAttribute2 -eq $localCustomAttributeValue} } $done=0 foreach($adDistributionGroup in $adDistributionGroups){ try{$percentComplete = ($done / ($adDistributionGroups.Count)) * 100}catch{$percentComplete = 1} $done++ if($percentComplete -gt 0){ $runtime = ((Get-Date) - $starttime).TotalSeconds $timeleft = ((100/$percentComplete)*$runtime)-$runtime $ts = [timespan]::fromseconds($timeleft) $remainingTime = "$("{0:hh\:mm\:ss}" -f $ts)" } if($adDistributionGroup.objectGuid.Guid -eq $Null){ continue } Write-Progress -Activity "Retrieving distribution groups" -PercentComplete $percentComplete -Status "Processing: $($adDistributionGroup.displayName), time left: $remainingTime" $memberObjects = @() $fastSearchCache = @{} foreach($member in $adDistributionGroup.member){ try{ $res = searchADForUserOrGroup -searchQuery $member $fastSearchCache."$($res.ObjectGuid.Guid)" = 1 $memberObjects += $res }catch{$Null} } $adDistributionGroup = $adDistributionGroup | Add-Member -MemberType NoteProperty -Name MemberObjects -Value $memberObjects -PassThru -ErrorAction Stop -Force $adDistributionGroup = $adDistributionGroup | Add-Member -MemberType NoteProperty -Name FastSearch -Value $fastSearchCache -PassThru -ErrorAction Stop -Force $adDistributionGroupsAndMembers += $adDistributionGroup } Write-Progress -Activity "Retrieving distribution groups" -PercentComplete 100 -Status "DONE" -Completed return $adDistributionGroupsAndMembers } return @() } function retrieveO365GroupsAndMembers{ #retrieve current groups and their members in Office 365 $o365DistributionGroups = @() $o365DistributionGroupsAndMembers = @() try{ $o365DistributionGroups = @(Get-DistributionGroup -ResultSize Unlimited -WarningAction Stop -ErrorAction Stop | where{$_.DisplayName.Length -gt 0}) if($cloudGroupNamePrefix){ $cloudGroupNamePrefix = "$cloudGroupNamePrefix*" if($verbose){log -text "using $cloudGroupNamePrefix as filter in O365 distribution group selection" -color "Green"} $o365DistributionGroups = @($o365DistributionGroups | Where {$_.DisplayName -like "$cloudGroupNamePrefix"}) } if($cloudCustomAttributeValue){ if($verbose){log -text "using $cloudCustomAttributeValue as filter, only o365groups with this value in customerAttribute2 will be used" -color "Green"} $o365DistributionGroups = @($o365DistributionGroups | Where {$_.CustomAttribute2 -eq "$cloudCustomAttributeValue"}) } }catch{ Throw "Get-DistributionGroup failed with $($Error[0])" } Write-Progress -Activity "Retrieving distribution groups" -PercentComplete 0 -Status "Loading $($o365DistributionGroups) from Office 365, this can take a while" if($O365DistributionGroups[0].DisplayName.Length -gt 0){ if($o365DistributionGroups.Count -gt 1){ $o365DistributionGroups = $o365DistributionGroups.GetEnumerator() | sort -Property name } $done=0 foreach($distributionGroup in $o365DistributionGroups){ validateExOConnection -o365login $o365login -o365password $o365password try{$percentComplete = ($done / ($o365DistributionGroups.Count)) * 100}catch{$percentComplete = 1} Write-Progress -Activity "Retrieving distribution groups" -PercentComplete $percentComplete -Status "Processing: $($distributionGroup.displayName)" $memberObjects = @() $fail = $False try{ $members = Get-DistributionGroupMember -resultsize Unlimited -identity $distributionGroup.Identity -ErrorAction Stop }catch{ $fail=$True } if($fail){ try{ Sleep -s 5 $members = Get-DistributionGroupMember -resultsize Unlimited -identity $distributionGroup.Identity -ErrorAction Stop }catch{ Throw "Failed to cache membership for $($distributionGroup.DisplayName) with ID $($distributionGroup.Identity) because of $members and $($Error[0])" } } $subDone = 0 $starttime = Get-Date $fastSearchCache = @{} foreach($member in $members){ try{$percentCompleteSub = ($subDone / ($members.Count)) * 100}catch{$percentCompleteSub = 1} if($percentCompleteSub -gt 0){ $runtime = ((Get-Date) - $starttime).TotalSeconds $timeleft = ((100/$percentCompleteSub)*$runtime)-$runtime $ts = [timespan]::fromseconds($timeleft) $remainingTime = "$("{0:hh\:mm\:ss}" -f $ts)" } Write-Progress -Activity "Retrieving distribution groups" -PercentComplete $percentComplete -Status "Processing: $($distributionGroup.displayName), $($member.displayName) ($subDone / $($members.Count) remaining time for this group: $remainingTime" try{ $res = searchExOForUserOrGroupV2 -O365ObjectCache $O365ObjectCache -searchQuery $member.primarySmtpAddress $fastSearchCache."$($res.guid.guid)" = 1 $memberObjects += $res }catch{$Null} $subDone++ } $distributionGroup = $distributionGroup | Add-Member -MemberType NoteProperty -Name Members -Value $memberObjects -PassThru -ErrorAction Stop -Force $distributionGroup = $distributionGroup | Add-Member -MemberType NoteProperty -Name FastSearch -Value $fastSearchCache -PassThru -ErrorAction Stop -Force $o365DistributionGroupsAndMembers += $distributionGroup $done++ } Write-Progress -Activity "Retrieving distribution groups" -PercentComplete 100 -Status "DONE" -Completed return $o365DistributionGroupsAndMembers } return @() } function returnDeletedGroups{ Param( [Parameter(Mandatory=$true)]$referenceGroups, #cache [Parameter(Mandatory=$true)]$currentGroups, #current groups [Parameter(Mandatory=$true)]$mode #1 = office 365 groups, 2 = AD groups ) if($mode -eq 1){ $compareParam = "Guid" }else{ $compareParam = "ObjectGuid" } $deletedGroups = @() foreach($group in $referenceGroups){ [Array]$found = @($currentGroups | where {$_.$compareParam -eq $group.$compareParam -and $_}) if($found.Count -le 0){ $deletedGroups += $group } } return $deletedGroups } function returnNewGroups{ Param( [Parameter(Mandatory=$true)]$referenceGroups, #cache [Parameter(Mandatory=$true)]$currentGroups, #current groups [Parameter(Mandatory=$true)]$mode #1 = office 365 groups, 2 = AD groups ) if($mode -eq 1){ $compareParam = "Guid" }else{ $compareParam = "ObjectGuid" } $newGroups = @() foreach($group in $currentGroups){ [Array]$found = @($referenceGroups | where {$_.$compareParam -eq $group.$compareParam -and $_}) if($found.Count -le 0){ $newGroups += $group } } return $newGroups } function searchExOForUserOrGroupV2{#returns Exchange Online object, when searching based on an email address, also finds if alias, but much slower Param( [Parameter(Mandatory=$true)]$O365ObjectCache, [Parameter(Mandatory=$true)]$searchQuery, #defaults to email address, but switches can modify this [Switch]$byAlias, [Switch]$byDisplayName ) if($searchQuery.Length -lt 2){Throw "Invalid query: $searchQuery"} if(!$byAlias -and !$byDisplayName) { if($searchQuery.IndexOf("smtp:",[System.StringComparison]::CurrentCultureIgnoreCase) -eq 0){ $searchQuery = $searchQuery.SubString(5) } try{ $res = $O365ObjectCache.FastSearch.$searchQuery switch($res.Split("_")[0]){ "mbx"{ $retVal = $O365ObjectCache.Mailboxes[$res.Split("_")[1]] if($retVal){return $retVal} } "con"{ $retVal = $O365ObjectCache.MailContacts[$res.Split("_")[1]] if($retVal){return $retVal} } "usr"{ $retVal = $O365ObjectCache.MailUsers[$res.Split("_")[1]] if($retVal){return $retVal} } "grp"{ $retVal = $O365ObjectCache.Groups[$res.Split("_")[1]] if($retVal){return $retVal} } default{ Throw } } }catch{$Null} $searchQuery = "smtp:$searchQuery" #default to searching by email, prepend smtp: as this is how O365 knows them } #first look for a mailbox try{ if($byAlias){[Array]$res = @($O365ObjectCache.Mailboxes | where {$_.alias -eq $searchQuery -and $_})} elseif($byDisplayName){[Array]$res = @($O365ObjectCache.Mailboxes | where {$_.DisplayName -eq $searchQuery -and $_})} else{[Array]$res = @($O365ObjectCache.Mailboxes | where {$_.EmailAddresses -Contains $searchQuery -and $_})} if($res.Count -eq 0){Throw "No Mailbox Found"} }catch{$res = $Null} #if none is found, look for a contact if($res -eq $Null){ try{ if($byAlias){[Array]$res = @($O365ObjectCache.MailContacts | where {$_.alias -eq $searchQuery -and $_})} elseif($byDisplayName){[Array]$res = @($O365ObjectCache.MailContacts | where {$_.DisplayName -eq $searchQuery -and $_})} else{[Array]$res = @($O365ObjectCache.MailContacts | where {$_.EmailAddresses -Contains $searchQuery -and $_})} if($res.Count -eq 0){Throw "No Contact Found"} }catch{$res = $Null} } #if none is found, look for a mailuser if($res -eq $Null){ try{ if($byAlias){[Array]$res = @($O365ObjectCache.MailUsers | where {$_.alias -eq $searchQuery -and $_})} elseif($byDisplayName){[Array]$res = @($O365ObjectCache.MailUsers | where {$_.DisplayName -eq $searchQuery -and $_})} else{[Array]$res = @($O365ObjectCache.MailUsers | where {$_.EmailAddresses -Contains $searchQuery -and $_})} if($res.Count -eq 0){Throw "No MailUser Found"} }catch{$res = $Null} } #if none is found, look for a Group if($res -eq $Null){ try{ if($byAlias){[Array]$res = @($O365ObjectCache.Groups | where {$_.alias -eq $searchQuery -and $_})} elseif($byDisplayName){[Array]$res = @($O365ObjectCache.Groups | where {$_.DisplayName -eq $searchQuery -and $_})} else{[Array]$res = @($O365ObjectCache.Groups | where {$_.EmailAddresses -Contains $searchQuery -and $_})} if($res.Count -eq 0){Throw "No DistributionGroup Found"} }catch{$res = $Null} } #return false if nothing found, otherwise return object $script:WarningActionPreference = "Stop" $script:ErrorActionPreference = "Stop" if($res){ return $res[0] }else{ Throw "Could not find $searchQuery in Exchange Online" } } function searchADForUserOrGroup{ param( [Parameter(Mandatory=$true)]$searchQuery, [Switch]$byMail, #search for any object that has $searchQuery defined as a proxyAddress [Switch]$anyAlias #searchQuery is not a string, but aliasses, if one of the aliasses is found on an object in AD, return that object immediately ) if($searchQuery.Length -lt 2){Throw "Invalid search Query $searchQuery"} $found = $False if($byMail){ if($anyAlias){ foreach($query in $searchQuery){ if($query.IndexOf("smtp",[System.StringComparison]::CurrentCultureIgnoreCase) -eq -1){ $query = "smtp:$($query)" } [Array]$adresult = @(Get-ADObject -Filter {(ObjectClass -eq "user" -or ObjectClass -eq "Group" -or ObjectClass -eq "contact") -and (proxyAddresses -like $query)} -Properties userAccountControl,proxyAddresses,name,DistinguishedName,DisplayName,ObjectGuid,mail,cn,managedBy,authOrig,dLMemSubmitPerms -ErrorAction Stop | where{$_}) if($adresult.Count -gt 0){ break } } }else{ if($searchQuery.IndexOf("smtp",[System.StringComparison]::CurrentCultureIgnoreCase) -eq -1){ $searchQuery = "smtp:$($searchQuery)" } [Array]$adresult = @(Get-ADObject -Filter {(ObjectClass -eq "user" -or ObjectClass -eq "Group" -or ObjectClass -eq "contact") -and (proxyAddresses -like $searchQuery)} -Properties userAccountControl,proxyAddresses,name,DistinguishedName,DisplayName,ObjectGuid,mail,cn,managedBy,authOrig,dLMemSubmitPerms -ErrorAction Stop | where{$_}) } }else{ [Array]$adresult = @(Get-ADObject -Filter {(ObjectClass -eq "user" -or ObjectClass -eq "Group" -or ObjectClass -eq "contact") -and (name -eq $searchQuery -or distinguishedName -eq $searchQuery -or displayName -eq $searchQuery)} -Properties userAccountControl,proxyAddresses,name,DistinguishedName,DisplayName,ObjectGuid,mail,cn,managedBy,authOrig,dLMemSubmitPerms -ErrorAction Stop | where{$_}) } if($adresult.Count -gt 1){ log -text "Warning, multiple objects found for $searchQuery in AD, only returning first object" -color "Red" -error $adresult = $adresult[0] }elseif($adresult.Count -eq 0){ Throw "Could not find $searchQuery in AD" } return $adresult } function returnMissingGroups{#this function searches what groups in the second parameter are not present in the first and returns those as a collection param( [Parameter(Mandatory=$false)]$referenceGroups, [Parameter(Mandatory=$false)]$differenceGroups ) $retVal = @() if(!$referenceGroups -and !$differenceGroups){ Throw "No groups were supplied to compare, this script cannot run if there are no groups in both AD and O365" } if(!$referenceGroups){return $differenceGroups} if(!$differenceGroups){return $Null} foreach($group in $differenceGroups){ if($group.DisplayName){ [Array]$res = @($referenceGroups | where{$_.DisplayName -eq $group.DisplayName}) } if($res.Count -eq 0){ $retVal += $group } } return $retVal } function returnRenamedGroups{#this function searches what groups in the second parameter are differently named in the first and returns those as a collection param( [Parameter(Mandatory=$false)]$referenceGroups, [Parameter(Mandatory=$false)]$differenceGroups, [Parameter(Mandatory=$false)]$mode #1 = AD Groups, #2 = O365 Groups ) $retVal = @() if(!$referenceGroups -or !$differenceGroups){ Throw "No groups were supplied to compare names" } foreach($group in $differenceGroups){ $res = @() if($mode -eq 1){ if($group.ObjectGuid.Guid){ [Array]$res = @($referenceGroups | where{$_.ObjectGuid.Guid -eq $group.ObjectGuid.Guid}) } }else{ if($group.Guid.Guid){ [Array]$res = @($referenceGroups | where{$_.Guid.Guid -eq $group.Guid.Guid}) } } if($res.Count -eq 0){ continue #as this group was not found, it isn't part of the renamed scope }elseif($res.Count -gt 1){ continue #multiple results found, so invalid result }else{ if($res[0].displayName -ne $group.displayName){ $obj = New-Object PSObject $obj | Add-Member NoteProperty newIdentifier($res[0].DisplayName) $obj | Add-Member NoteProperty newDisplayName($group.DisplayName) $retVal+=$obj } } } return $retVal } function returnChangedMembersInO365Groups{ param( [Parameter(Mandatory=$true)]$referenceGroups, #original cache of groups [Parameter(Mandatory=$true)]$differenceGroups #new situation ) $retVal = @() #Objects that represent actions to take #Mode 1: remove member, 2: add member #DisplayName: display name of the target group to modify #memberMail: email address of the member to add/remove Write-Progress -Activity "Comparing O365 cache and current group membership" -PercentComplete 0 -Status "Init" $done = 0 foreach($group in $differenceGroups){ validateExOConnection -o365login $o365login -o365password $o365password $originalMembers = @() $currentMembers = @() #find this new group in cached older groups $matchingGroup = $referenceGroups | where{$_.Guid.Guid -eq $group.Guid.Guid} if($matchingGroup.Guid.Guid.Length -lt 1){ continue } try{$percentComplete = ($done / ($differenceGroups.Count)) * 100}catch{$percentComplete = 1} Write-Progress -Activity "Comparing O365 cache and current group membership" -PercentComplete $percentComplete -Status "Processing: $($group.displayName)" $matchingGroup.Members | % { try{ $res = searchExOForUserOrGroupV2 -O365ObjectCache $script:O365ObjectCache -searchQuery $_.PrimarySmtpAddress $originalMembers += $res }catch{$Null} } get-distributiongroupmember -ResultSize Unlimited -Identity $group.DistinguishedName -erroraction silentlycontinue | % { try{ $res = searchExOForUserOrGroupV2 -O365ObjectCache $script:O365ObjectCache -searchQuery $_.PrimarySmtpAddress $currentMembers += $res }catch{$Null} } try{ [Array]$changes = @(Compare-Object -ReferenceObject $originalMembers -DifferenceObject $currentMembers -Property Guid -PassThru -ErrorAction Stop | where {$_}) if($changes[0].Guid.Guid.Length -lt 1){ Throw "No changes found for $($group.DisplayName)" } }catch{ continue } foreach($change in $changes){ #skip if the member doesn't have an email address if($change.primarysmtpaddress.Length -lt 1){ continue } $obj = New-Object PSObject #removed user if($change.SideIndicator -eq "<="){ $obj | Add-Member NoteProperty mode(1) } #added user if($change.SideIndicator -eq "=>"){ $obj | Add-Member NoteProperty mode(2) } $obj | Add-Member NoteProperty DisplayName($group.DisplayName) $obj | Add-Member NoteProperty memberMail($change.primarysmtpaddress) $retVal += $obj } $done++ } return $retVal } function returnChangedProxyAddresses{ param( [Parameter(Mandatory=$true)]$referenceGroups, #original cache of groups [Parameter(Mandatory=$true)]$differenceGroups, #new situation [Parameter(Mandatory=$true)]$mode #1 = O365 groups, 2 = AD groups ) $retVal = @() #Objects that represent actions to take #Mode 1: remove address, 2: add address #DisplayName: display name of the target group to modify #proxyAddress: address to add or remove foreach($group in $differenceGroups){ #find this group's corresponding group if($mode -eq 1){ $matchingGroup = $referenceGroups | where{$_.Guid.Guid -eq $group.Guid.Guid} if($matchingGroup.Guid.Guid.Length -lt 1){ continue } }else{ $matchingGroup = $referenceGroups | where{$_.ObjectGuid.Guid -eq $group.ObjectGuid.Guid} if($matchingGroup.ObjectGuid.Guid.Length -lt 1){ continue } } try{ if($mode -eq 1){ $originalProxies = $matchingGroup.EmailAddresses $currentProxies = $group.EmailAddresses }else{ $originalProxies = $matchingGroup.proxyAddresses $currentProxies = $group.proxyAddresses } foreach($address in $originalProxies){ if($address -and $currentProxies -notcontains $address){ if($mode -eq 1){#check if this address is allowed on premises, otherwise skip if(!(checkIfAddressIsAllowed -mode 2 -address $address)){ continue } } if($mode -eq 2){#check if this address is allowed in the cloud, otherwise skip if(!(checkIfAddressIsAllowed -mode 1 -address $address)){ continue } } #deleted address, add to list $obj = New-Object PSObject $obj | Add-Member NoteProperty mode(1) $obj | Add-Member NoteProperty DisplayName($group.DisplayName) $obj | Add-Member NoteProperty proxyAddress($address) $retVal += $obj } } foreach($address in $currentProxies){ if($address -and $originalProxies -notcontains $address){ if($mode -eq 1){#check if this address is allowed on premises, otherwise skip if(!(checkIfAddressIsAllowed -mode 2 -address $address)){ continue } } if($mode -eq 2){#check if this address is allowed in the cloud, otherwise skip if(!(checkIfAddressIsAllowed -mode 1 -address $address)){ continue } } #added address, add to list $obj = New-Object PSObject $obj | Add-Member NoteProperty mode(2) $obj | Add-Member NoteProperty DisplayName($group.DisplayName) $obj | Add-Member NoteProperty proxyAddress($address) $retVal += $obj } } }catch{ continue } } return $retVal } function returnChangedAuthorizedSenders{ param( [Parameter(Mandatory=$true)]$referenceGroups, #original cache of groups [Parameter(Mandatory=$true)]$differenceGroups, #new situation [Parameter(Mandatory=$true)]$mode #1 = O365 groups, 2 = AD groups ) $retVal = @() #Objects that represent actions to take #Mode 1: remove single sender, 2: add single sender, 3: remove group sender, 4: add group sender #DisplayName: display name of the target group to modify #authorizedSender: sender identifier to add or remove foreach($group in $differenceGroups){ #find this group's corresponding group if($mode -eq 1){ $matchingGroup = $referenceGroups | where{$_.Guid.Guid -eq $group.Guid.Guid} if($matchingGroup.Guid.Guid.Length -lt 1){ continue } }else{ $matchingGroup = $referenceGroups | where{$_.ObjectGuid.Guid -eq $group.ObjectGuid.Guid} if($matchingGroup.ObjectGuid.Guid.Length -lt 1){ continue } } try{ if($mode -eq 1){ $originalAllowedSingleSenders = $matchingGroup.AcceptMessagesOnlyFrom $currentAllowedSingleSenders = $group.AcceptMessagesOnlyFrom $originalAllowedGroupSenders = $matchingGroup.AcceptMessagesOnlyFromDLMembers $currentAllowedGroupSenders = $group.AcceptMessagesOnlyFromDLMembers }else{ $originalAllowedSingleSenders = $matchingGroup.authOrig $currentAllowedSingleSenders = $group.authOrig $originalAllowedGroupSenders = $matchingGroup.dLMemSubmitPerms $currentAllowedGroupSenders = $group.dLMemSubmitPerms } foreach($originalAllowedSingleSender in $originalAllowedSingleSenders){ if($originalAllowedSingleSender -and $currentAllowedSingleSenders -notcontains $originalAllowedSingleSender){ #deleted address, add to list $obj = New-Object PSObject $obj | Add-Member NoteProperty mode(1) $obj | Add-Member NoteProperty DisplayName($group.DisplayName) $obj | Add-Member NoteProperty authorizedSender($originalAllowedSingleSender) $retVal += $obj } } foreach($currentAllowedSingleSender in $currentAllowedSingleSenders){ if($currentAllowedSingleSender -and $originalAllowedSingleSenders -notcontains $currentAllowedSingleSender){ #deleted address, add to list $obj = New-Object PSObject $obj | Add-Member NoteProperty mode(2) $obj | Add-Member NoteProperty DisplayName($group.DisplayName) $obj | Add-Member NoteProperty authorizedSender($currentAllowedSingleSender) $retVal += $obj } } foreach($originalAllowedGroupSender in $originalAllowedGroupSenders){ if($originalAllowedGroupSender -and $currentAllowedGroupSenders -notcontains $originalAllowedGroupSender){ #deleted address, add to list $obj = New-Object PSObject $obj | Add-Member NoteProperty mode(3) $obj | Add-Member NoteProperty DisplayName($group.DisplayName) $obj | Add-Member NoteProperty authorizedSender($originalAllowedGroupSender) $retVal += $obj } } foreach($currentAllowedGroupSender in $currentAllowedGroupSenders){ if($currentAllowedGroupSender -and $originalAllowedGroupSenders -notcontains $currentAllowedGroupSender){ #added address, add to list $obj = New-Object PSObject $obj | Add-Member NoteProperty mode(4) $obj | Add-Member NoteProperty DisplayName($group.DisplayName) $obj | Add-Member NoteProperty authorizedSender($currentAllowedGroupSender) $retVal += $obj } } }catch{ continue } } return $retVal } function returnChangedMembersInADGroups{ param( [Parameter(Mandatory=$true)]$referenceGroups, #original cache of groups [Parameter(Mandatory=$true)]$differenceGroups #new situation ) $retVal = @() #Objects that represent actions to take #Mode 1: remove member, 2: add member #DisplayName: display name of the target group to modify #memberMail: email address of the member to add/remove foreach($group in $differenceGroups){ $originalMembers = @() $currentMembers = @() #find this new group in cached older groups $matchingGroup = $referenceGroups | where{$_.ObjectGuid -eq $group.ObjectGuid} if($matchingGroup.ObjectGuid.Guid.Length -lt 1){ continue } try{ [Array]$changes = @(Compare-Object -ReferenceObject $matchingGroup.MemberObjects -DifferenceObject $group.MemberObjects -Property ObjectGUID -PassThru -ErrorAction Stop | where {$_}) if($changes[0].ObjectGUID.Guid.Length -lt 1){ Throw "No changes found for $($group.DisplayName)" } }catch{ continue } foreach($change in $changes){ #skip if the member doesn't have an email address if($change.mail.Length -lt 1){ continue } $obj = New-Object PSObject #removed user if($change.SideIndicator -eq "<="){ $obj | Add-Member NoteProperty mode(1) } #added user if($change.SideIndicator -eq "=>"){ $obj | Add-Member NoteProperty mode(2) } $obj | Add-Member NoteProperty DisplayName($group.DisplayName) $obj | Add-Member NoteProperty memberMail($change.mail) $retVal += $obj } } return $retVal } function returnDifferentMembersBetweenGroups{ param( [Parameter(Mandatory=$true)]$o365Groups, #o365 groups object [Parameter(Mandatory=$true)]$adGroups #ad groups object ) $retVal = @() #Objects that represent actions to take #mode: 1=add to AD group, 2=add to O365 group #DisplayName: display name of the target group to modify #targetIdentifier: unique id of target group to modify #memberIdentifier: email address of the member to add/remove for O365, or DistinguishedName for AD $percentComplete = 0 $done = 0 Write-Progress -Activity "Comparing groups..." -PercentComplete $percentComplete -Status "Loading $($adGroups.Count + $o365Groups.Count) groups..." #build member lists per group and process them foreach($adGroup in $adGroups){ try{$percentComplete = (($done / $adGroups.Count) * 100)}catch{$percentComplete = 0} #find this AD group's corresponding O365 group $o365Group = $o365Groups | where{$_.DisplayName -eq $adGroup.DisplayName} if($o365Group.Guid.Guid.Length -lt 1){ continue } Write-Progress -Activity "Comparing groups..." -PercentComplete $percentComplete -Status "Working on $($adGroup.DisplayName)" #process member lists and add any changes to changes array #first TO Office 365 foreach($adGroupMember in $adGroup.MemberObjects){ Write-Progress -Activity "Comparing groups..." -PercentComplete $percentComplete -Status "Working on $($adGroup.DisplayName) - $($adGroupMember.mail)" if($adGroupMember.userAccountControl -band 2 -and $skipDisabledAccounts){ continue } #if can't find in the O365 group, add to list try{ #user has to exist in ExO, retrieve user from ExO or move to next adGroupMember $res = searchExOForUserOrGroupV2 -O365ObjectCache $script:O365ObjectCache -searchQuery $adGroupMember.mail #check if already present in O365Group if($res.Guid.Guid.Length -gt 1 -and ($o365Group.FastSearch."$($res.Guid.Guid)" -ne 1)){ $obj = New-Object PSObject $obj | Add-Member NoteProperty mode(2) $obj | Add-Member NoteProperty DisplayName($o365Group.DisplayName) $obj | Add-Member NoteProperty targetIdentifier($o365Group.DistinguishedName) $obj | Add-Member NoteProperty memberIdentifier($res.primarySmtpAddress) $retVal += $obj } }catch{ continue } } #and then TO AD foreach($o365GroupMember in $o365Group.Members){ Write-Progress -Activity "Comparing groups..." -PercentComplete $percentComplete -Status "Working on $($adGroup.DisplayName) - $($o365GroupMember.primarySmtpAddress)" #if can't find in the O365 group, add to list try{ #user has to exist in AD, retrieve user from AD or move to next o365GroupMember $res = searchADForUserOrGroup -searchQuery $o365GroupMember.primarySmtpAddress -byMail -anyAlias #check if already present in AD Group if($res.ObjectGuid.Guid.Length -gt 1 -and ($adGroup.FastSearch."$($res.ObjectGuid.Guid)" -ne 1)){ $obj = New-Object PSObject $obj | Add-Member NoteProperty mode(1) $obj | Add-Member NoteProperty DisplayName($adGroup.DisplayName) $obj | Add-Member NoteProperty targetIdentifier($adGroup.DistinguishedName) $obj | Add-Member NoteProperty memberIdentifier($res.distinguishedName) $retVal += $obj } }catch{ continue } } $done++ } Write-Progress -Activity "Comparing groups..." -PercentComplete 100 -Status "DONE!" return $retVal } function provisionADGroup{ Param( [Parameter(Mandatory=$true)]$o365GroupObject, [Parameter(Mandatory=$true)]$targetOU ) #now create the group $returnData = @{} try{ if(!$readOnly){ $res = New-ADGroup -Name $o365GroupObject.DisplayName -SamAccountName $o365GroupObject.DisplayName -DisplayName $o365GroupObject.DisplayName -Description "This group was automatically created by O365GroupSync on $(Get-Date)" -GroupScope Universal -GroupCategory Security -PassThru -Path $targetOU -ErrorAction Stop } }catch{ Throw "Failed to create $($o365GroupObject.DisplayName), error reported by command: $($Error[0]) $res" } $returnData.groupInfo = $res $returnData.oldGroupInfo = $o365GroupObject $script:totalChanged++ return $returnData } function provisionADGroups{ Param( [Parameter(Mandatory=$true)]$groups ) $groupPostProcessingDataSet = @() foreach($o365NewGroup in $groups){ [Array]$res = @($adDistributionGroups | where {$_.DisplayName -eq $o365NewGroup.DisplayName} | where {$_}) if($res.Count -gt 0){ log -text "Error: $($o365NewGroup.DisplayName) already exists in AD, it was probably already provisioned manually and will be updated next run" -color "Yellow" -warning continue } if($o365NewGroup.DisplayName){ try{ log -text "Sending command to create new group in AD (O365 group: $($o365NewGroup.DisplayName))" -color "Green" $groupPostProcessingDataSet += (provisionADGroup -o365GroupObject $o365NewGroup -targetOU $targetOU) log -text "Command completed succesfully, $($o365NewGroup.DisplayName) created" -Color "Green" }catch{ log -text "Failure reported in Group Provisioning of $($o365NewGroup.DisplayName) in AD" -Color "Red" -append -error } } } ##Process extra commands after groups creation: foreach($groupPostProcessingData in $groupPostProcessingDataSet){ #owners vertalen $managers = "" $managedBy = $groupPostProcessingData.oldGroupInfo.ManagedBy if($managedBy){ foreach($manager in $managedBy){ try{ $newManager = searchExOForUserOrGroupV2 -O365ObjectCache $script:O365ObjectCache -searchQuery $manager -byAlias if($newManager.primarySmtpAddress){ $ADRes = searchADForUserOrGroup -searchQuery $newManager.emailAddresses -byMail -anyAlias if($managers){ Throw "Active Directory only supports 1 manager per group" } $managers=$ADRes.distinguishedName if($verbose) {log -text "will add $($ADRes.distinguishedName) as manager to $($groupPostProcessingData.oldGroupInfo.DisplayName)" -color "Green"} }else{ Throw "$($newManager.UserPrincipalName) does not have an email address defined and will not be propagated to AD in group $($groupPostProcessingData.oldGroupInfo.DisplayName)" } }catch{ log -text "Invalid manager ($manager), cannot be found in Office 365 or AD or does not have an email address or maximum managers reached, will not add to group $($groupPostProcessingData.oldGroupInfo.DisplayName)" -color "Yellow" -append -warning } } } if($managers){ try{ $result = Set-ADGroup -Identity $groupPostProcessingData.groupInfo.ObjectGuid -ManagedBy $managers -ErrorAction Stop }catch{ log -text "Failed to set manager ($managers) for $($groupPostProcessingData.oldGroupInfo.DisplayName), error reported by command: $($Error[0]) $result" -color "Yellow" -warning } } #if any cleanup is required, do so here [Array]$proxyAddressesTemp = @($groupPostProcessingData.oldGroupInfo.EmailAddresses | where {$_}) #strip domains not present onprem if($domainsPresentLocally){ [Array]$proxyAddressesTemp = @($proxyAddressesTemp | where {(checkIfAddressIsAllowed -mode 2 -address $_)}) } $proxyAddresses = @() $proxyAddressesTemp | % {$proxyAddresses += $_.ToString()} #cache properties or rewrite them according to target needs if($groupPostProcessingData.oldGroupInfo.HiddenFromAddressListsEnabled -eq $True){ if($verbose) {log -text "Will hide $($groupPostProcessingData.oldGroupInfo.DisplayName)" -color "Green"} $HiddenFromAddressListsEnabled = "TRUE" }else{ $HiddenFromAddressListsEnabled = "FALSE" } if($groupPostProcessingData.oldGroupInfo.RequireSenderAuthenticationEnabled -eq $True){ if($verbose) {log -text "Will set sender authentication for $($groupPostProcessingData.oldGroupInfo.DisplayName)" -color "Green"} $RequireSenderAuthenticationEnabled = "TRUE" }else{ $RequireSenderAuthenticationEnabled = "FALSE" } #do some properties if(!$readOnly){ try{ if($proxyAddresses.Count -lt 1){ Throw "No email aliases could be used as no configured aliases of the source group are known in the target domain" } $result = Set-ADGroup -Identity $groupPostProcessingData.groupInfo.ObjectGuid -Replace @{'proxyAddresses'=$proxyAddresses} -ErrorAction Stop }catch{ log -text "Failed to set proxyAddresses for $($groupPostProcessingData.oldGroupInfo.DisplayName), error reported by command: $($Error[0]) $result" -color "Red" -error } if($localCustomAttributeValue){ try{ $result = Set-ADGroup -Identity $groupPostProcessingData.groupInfo.ObjectGuid -Replace @{'extensionAttribute2'=$localCustomAttributeValue} -ErrorAction Stop log -text "set extensionAttribute2 to $localCustomAttributeValue" -color "Green" }catch{ log -text "Failed to set extensionAttribute2 to $localCustomAttributeValue : $($Error[0]) $result" -color "Red" -error } } try{ $result = Set-ADGroup -Identity $groupPostProcessingData.groupInfo.ObjectGuid -Replace @{'mailNickname'=$groupPostProcessingData.oldGroupInfo.alias} -ErrorAction Stop $result = Set-ADGroup -Identity $groupPostProcessingData.groupInfo.ObjectGuid -Replace @{'legacyExchangeDN'=$groupPostProcessingData.oldGroupInfo.legacyExchangeDN} -ErrorAction Stop }catch{ log -text "Failed to set Exchange properties, this distributionlist will not show up in the Exchange console: $($Error[0]) $result" -color "Yellow" -warning } try{ $result = Set-ADGroup -Identity $groupPostProcessingData.groupInfo.ObjectGuid -Replace @{'msExchHideFromAddressLists'="$HiddenFromAddressListsEnabled"} -ErrorAction Stop }catch{ log -text "Failed to set hide status for $($groupPostProcessingData.oldGroupInfo.DisplayName), error reported by command: $($Error[0]) $result" -color "Red" -error } try{ $result = Set-ADGroup -Identity $groupPostProcessingData.groupInfo.ObjectGuid -Replace @{'mail'=$groupPostProcessingData.oldGroupInfo.PrimarySmtpAddress} -ErrorAction Stop }catch{ log -text "Failed to set email address $($groupPostProcessingData.oldGroupInfo.PrimarySmtpAddress) for $($groupPostProcessingData.oldGroupInfo.DisplayName), error reported by command: $($Error[0]) $result" -color "Red" -error } try{ $result = Set-ADGroup -Identity $groupPostProcessingData.groupInfo.ObjectGuid -Replace @{'msExchRequireAuthToSendTo'="$RequireSenderAuthenticationEnabled"} -ErrorAction Stop }catch{ log -text "Failed to set required sender auth status for $($groupPostProcessingData.oldGroupInfo.DisplayName), error reported by command: $($Error[0]) $result" -color "Red" -error } } #members $members = @() try{ [Array]$o365GroupObjectMembers = @($groupPostProcessingData.oldGroupInfo.Members | where {$_}) }catch{ log -text "Failed to retrieve members for $($groupPostProcessingData.oldGroupInfo.DisplayName)" -color "Red" -append -error } if($o365GroupObjectMembers.Count -gt 0){ foreach($member in $o365GroupObjectMembers){ try{ $newMember = searchADForUserOrGroup -searchQuery $member.EmailAddresses -byMail -anyAlias if($newMember.mail){ $members+=$newMember.distinguishedName if($verbose) {log -text "will add $($newMember.mail) as member to $($groupPostProcessingData.oldGroupInfo.DisplayName)" -color "Green"} }else{ Throw "$($newMember.UserPrincipalName) does not have an email address defined and will not be added as member of group $($groupPostProcessingData.oldGroupInfo.DisplayName)" } }catch{ log -text "Object $member cannot be found in Office 365 or AD or does not have an email address and will not be added to AD group $($groupPostProcessingData.oldGroupInfo.DisplayName)" -color "Red" -append -warning } } } foreach($member in $members){ Try{ if(!$readOnly){ $result = Add-ADGroupMember -Identity $groupPostProcessingData.groupInfo.ObjectGuid -Members $member -ErrorAction Stop } }catch{ log -text "Failed to add $member to $($groupPostProcessingData.oldGroupInfo.DisplayName)" -color "Yellow" -append -warning } } log -text "members added to $($groupPostProcessingData.oldGroupInfo.DisplayName)" -color "Green" #authorized senders (users, AcceptMessagesOnlyFrom property) $permittedSendingUsers = @() if($groupPostProcessingData.oldGroupInfo.AcceptMessagesOnlyFrom){ foreach($member in $groupPostProcessingData.oldGroupInfo.AcceptMessagesOnlyFrom){ try{ $newMember = searchExOForUserOrGroupV2 -O365ObjectCache $script:O365ObjectCache -searchQuery $member -byAlias if($newMember.primarySmtpAddress){ $ADRes = searchADForUserOrGroup -searchQuery $newMember.emailAddresses -byMail -anyAlias $permittedSendingUsers+=$ADRes.DistinguishedName if($verbose) {log -text "will add $($ADRes.displayName) as authorized sender to $($groupPostProcessingData.oldGroupInfo.DisplayName)" -color "Green"} }else{ Throw "$($newMember.UserPrincipalName) does not have an email address defined and will not be added as authorized sender to group $($groupPostProcessingData.oldGroupInfo.DisplayName)" } }catch{ log -text "O365 object $member cannot be found in Office 365 or AD and will not be added as authorized sender to AD group $($groupPostProcessingData.oldGroupInfo.DisplayName)" -color "Yellow" -append -warning } } } try{ if($permittedSendingUsers.Count -gt 0){ $result = $groupPostProcessingData.groupInfo | Set-ADGroup -Replace @{'authOrig'=$permittedSendingUsers} -ErrorAction Stop } }catch{ log -text "Failed to set authOrig (permitted sending users) for $($groupPostProcessingData.oldGroupInfo.DisplayName), error reported by command: $($Error[0]) $result" -color "Yellow" -warning } #authorized senders (groups, AcceptMessagesOnlyFromDLMembers property) $permittedSendingGroups = @() if($groupPostProcessingData.oldGroupInfo.AcceptMessagesOnlyFromDLMembers){ foreach($member in $groupPostProcessingData.oldGroupInfo.AcceptMessagesOnlyFromDLMembers){ try{ $newMember = searchExOForUserOrGroupV2 -O365ObjectCache $script:O365ObjectCache -searchQuery $member -byAlias if($newMember.primarySmtpAddress){ $ADRes = searchADForUserOrGroup -searchQuery $newMember.emailAddresses -byMail -anyAlias $permittedSendingGroups+=$ADRes.DistinguishedName if($verbose) {log -text "will add $($ADRes.displayName) as authorized sender to $($groupPostProcessingData.oldGroupInfo.DisplayName)" -color "Green"} }else{ Throw "$($newMember.UserPrincipalName) does not have an email address defined and will not be added as authorized sender to group $($groupPostProcessingData.oldGroupInfo.DisplayName)" } }catch{ log -text "O365 object $member cannot be found in Office 365 or AD and will not be added as authorized sender to AD group $($groupPostProcessingData.oldGroupInfo.DisplayName)" -color "Yellow" -append -warning } } } try{ if($permittedSendingGroups.Count -gt 0){ $result = Set-ADGroup -Identity $groupPostProcessingData.groupInfo.ObjectGuid -Replace @{'dLMemSubmitPerms'=$permittedSendingGroups} -ErrorAction Stop } }catch{ log -text "Failed to set authOrig (permitted sending users) for $($groupPostProcessingData.oldGroupInfo.DisplayName), error reported by command: $($Error[0]) $result" -color "Yellow" -warning } } } function provisionO365Group{ Param( [Parameter(Mandatory=$true)]$adGroupObject ) $managers = @() $managers+=$defaultDLAdminInO365 #cache properties or rewrite them according to target needs if($adGroupObject.msExchHideFromAddressLists -eq "TRUE"){ if($verbose) {log -text "Will hide $($adGroupObject.DisplayName)" -color "Green"} $HiddenFromAddressListsEnabled = $True }else{ $HiddenFromAddressListsEnabled = $False } if($adGroupObject.msExchRequireAuthToSendTo -eq "TRUE"){ if($verbose) {log -text "Will set sender authentication for $($adGroupObject.DisplayName)" -color "Green"} $RequireSenderAuthenticationEnabled = $True }else{ $RequireSenderAuthenticationEnabled = $False } $returnData = @{} #now create the group try{ if(!$readOnly){ $res = New-DistributionGroup -Name $adGroupObject.DisplayName -ManagedBy $managers -Type Security -ErrorAction Stop } }catch{ Throw "Failed to create $($adGroupObject.DisplayName), error reported by command: $($Error[0]) $res" } $returnData.groupInfo = $res $returnData.oldGroupInfo = $adGroupObject #if any cleanup is required, do so here [Array]$proxyAddresses = @($adGroupObject.ProxyAddresses | where {$_}) #strip domains not present in the Cloud [Array]$proxyAddresses = @($proxyAddresses | where {(checkIfAddressIsAllowed -mode 1 -address $_)}) #add onprem legacyExchangeDN for outlook address cache if($adGroupObject.LegacyExchangeDN){ if($verbose) {log -text "will add X500:$($adGroupObject.LegacyExchangeDN) as proxy address to $($adGroupObject.DisplayName)" -color "Green"} $proxyAddresses+="X500:$($adGroupObject.LegacyExchangeDN)" } #set proxyAddresses if(!$readOnly){ try{ if($proxyAddresses.Count -lt 1){ Throw "No email aliases could be used as no configured aliases of the source group are known in Exchange Online" } $res = Set-DistributionGroup -Identity $adGroupObject.DisplayName -EmailAddresses $proxyAddresses -ErrorAction Stop }catch{ log -text "Failed to set aliases for $($adGroupObject.DisplayName), error reported by command: $($Error[0]) $res" -color "Red" -error } } if(!$readOnly){ try{ $res = Set-DistributionGroup -Identity $adGroupObject.DisplayName -HiddenFromAddressListsEnabled $HiddenFromAddressListsEnabled -RequireSenderAuthenticationEnabled $RequireSenderAuthenticationEnabled -ErrorAction Stop }catch{ log -text "Failed to set hidefromaddresslist and requiresenderauthentication properties on $($adGroupObject.DisplayName), error reported by command: $($Error[0]) $res" -color "Red" -error } } $script:totalChanged++ return $returnData } function provisionO365Groups{ Param( [Parameter(Mandatory=$true)]$groups ) $groupPostProcessingDataSet = @() foreach($adNewGroup in $groups){ [Array]$res = @($o365DistributionGroups | where {$_.DisplayName -eq $adNewGroup.DisplayName} | where {$_}) if($res.Count -gt 0){ log -text "Error: $($adNewGroup.DisplayName) already exists in Office 365, it was probably already provisioned manually and will be updated next run" -color "Yellow" -warning continue } if($adNewGroup.DisplayName){ try{ log -text "Sending command to create new group in Office 365 (local AD group: $($adNewGroup.DisplayName))" -color "Green" $groupPostProcessingDataSet += (provisionO365Group -adGroupObject $adNewGroup) log -text "Command completed succesfully, $($adNewGroup.DisplayName) created" -Color "Green" }catch{ log -text "Failure reported in Group Provisioning of $($adNewGroup.DisplayName) in Office 365" -Color "Red" -append -error } }else{ log -text "Error, group does not have a displayname" -color "Red" -append -error } } #region cache O365 objects try{ log -text "Caching current O365 objects..." -color "Green" $script:O365ObjectCache = cacheO365Objects log -text "Cached $($O365ObjectCache.Mailboxes.Count) mailboxes and $($O365ObjectCache.Groups.Count) groups in O365" -color "Green" }catch{ log -text "Failed to cache O365 objects" -color "Red" -append -error abort_GS -failed }#endregion foreach($groupPostProcessingData in $groupPostProcessingDataSet){ #owners vertalen $managers = @() $managedBy = $groupPostProcessingData.oldGroupInfo.ManagedBy if($managedBy){ foreach($manager in $managedBy){ try{ $newManager = searchADForUserOrGroup -searchQuery $manager if($newManager.mail){ $o365Res = searchExOForUserOrGroupV2 -O365ObjectCache $script:O365ObjectCache -searchQuery $newManager.mail $managers+=$o365Res.primarySmtpAddress if($verbose) {log -text "will add $($o365Res.primarySmtpAddress) as manager to $($groupPostProcessingData.oldGroupInfo.DisplayName)" -color "Green"} }else{ Throw "$($newManager.UserPrincipalName) does not have an email address defined and will not be propagated to Office in group $($groupPostProcessingData.oldGroupInfo.DisplayName)" } }catch{ log -text "Invalid manager for group $($groupPostProcessingData.oldGroupInfo.DisplayName), cannot be found in Office 365 or AD or does not have an email address" -color "Yellow" -append -warning } } if($managers.Count -lt 1){ $managers+=$defaultDLAdminInO365 log -text "No managers set in AD for group $($groupPostProcessingData.oldGroupInfo.DisplayName), defaulting to $defaultDLAdminInO365" -color "Yellow" -append -warning } }else{ $managers+=$defaultDLAdminInO365 } #members $members = @() if($groupPostProcessingData.oldGroupInfo.MemberObjects){ foreach($member in $groupPostProcessingData.oldGroupInfo.MemberObjects){ try{ $newMember = searchADForUserOrGroup -searchQuery $member.mail -byMail -anyAlias if($newMember.mail){ if($newMember.userAccountControl -band 2 -and $skipDisabledAccounts){ continue } $o365Res = searchExOForUserOrGroupV2 -O365ObjectCache $script:O365ObjectCache -searchQuery $newMember.mail $members+=$o365Res.primarySmtpAddress if($verbose) {log -text "will add $($o365Res.primarySmtpAddress) as member to $($groupPostProcessingData.groupInfo.DisplayName)" -color "Green"} }else{ Throw "$($newMember.UserPrincipalName) does not have an email address defined and will not be added as member of group $($groupPostProcessingData.groupInfo.DisplayName)" } }catch{ log -text "AD object $($member.cn) cannot be found in Office 365 or AD or does not have an email address and will not be added to O365 group $($groupPostProcessingData.groupInfo.DisplayName)" -color "Red" -append -warning } } if($members){ try{ Update-DistributionGroupMember -Identity $groupPostProcessingData.groupInfo.DisplayName -ByPassSecurityGroupManagerCheck -Confirm:$False -Members $members log -text "Members added to $($groupPostProcessingData.groupInfo.DisplayName)" -Color "Green" }catch{ log -text "Failed to add members to $($groupPostProcessingData.groupInfo.DisplayName)" -color "Red" -append -error } } } #authorized senders (users, authOrig property) $permittedSendingUsersOrGroups = @() if($groupPostProcessingData.oldGroupInfo.authOrig){ foreach($member in $groupPostProcessingData.oldGroupInfo.authOrig){ try{ $newMember = searchADForUserOrGroup -searchQuery $member if($newMember.mail){ if($newMember.userAccountControl -band 2 -and $skipDisabledAccounts){ continue } $o365Res = searchExOForUserOrGroupV2 -O365ObjectCache $script:O365ObjectCache -searchQuery $newMember.mail $permittedSendingUsersOrGroups+=$o365Res.primarySmtpAddress if($verbose) {log -text "will add $($o365Res.primarySmtpAddress) as authorized sender to $($groupPostProcessingData.groupInfo.DisplayName)" -color "Green"} }else{ Throw "$($newMember.UserPrincipalName) does not have an email address defined and will not be added as authorized sender to group $($groupPostProcessingData.groupInfo.DisplayName)" } }catch{ log -text "AD object $($member.cn) cannot be found in Office 365 or AD or does not have an email address and will not be added as authorized sender to O365 group $($groupPostProcessingData.groupInfo.DisplayName)" -color "yellow" -append -warning } } } #authorized senders (groups, dlMemSubmitPerms) if($groupPostProcessingData.oldGroupInfo.dLMemSubmitPerms){ foreach($member in $groupPostProcessingData.oldGroupInfo.dLMemSubmitPerms){ try{ $newMember = searchADForUserOrGroup -searchQuery $member if($newMember.mail){ if($newMember.userAccountControl -band 2 -and $skipDisabledAccounts){ continue } $o365Res = searchExOForUserOrGroupV2 -O365ObjectCache $script:O365ObjectCache -searchQuery $newMember.mail $permittedSendingUsersOrGroups+=$o365Res.primarySmtpAddress if($verbose) {log -text "will add $($o365Res.primarySmtpAddress) as authorized sender to $($groupPostProcessingData.groupInfo.DisplayName)" -color "Green"} }else{ Throw "$($newMember.UserPrincipalName) does not have an email address defined and will not be added as authorized sender to group $($groupPostProcessingData.groupInfo.DisplayName)" } }catch{ log -text "AD object $($member.cn) cannot be found in Office 365 or AD or does not have an email address and will not be added as authorized sender to O365 group $($groupPostProcessingData.groupInfo.DisplayName)" -color "Yellow" -append -warning } } } #set CustomAttribute2: if($cloudCustomAttributeValue){ try{ $res = Set-DistributionGroup -Identity $groupPostProcessingData.groupInfo.DisplayName -ByPassSecurityGroupManagerCheck -CustomAttribute2 $cloudCustomAttributeValue -Confirm:$False -ErrorAction Stop log -text "Set customAttribute2 to $cloudCustomAttributeValue for $($groupPostProcessingData.groupInfo.DisplayName)" -color "Green" }catch{ log -text "Failed to set customAttribute2 to $cloudCustomAttributeValue $($Error[0]) $res" -color "Red" -error } } #add permitted senders if($permittedSendingUsersOrGroups.Count -gt 0){ try{ $res = Set-DistributionGroup -Identity $groupPostProcessingData.groupInfo.DisplayName -ManagedBy $managers -ByPassSecurityGroupManagerCheck -Confirm:$False -AcceptMessagesOnlyFromSendersOrMembers $permittedSendingUsersOrGroups -ErrorAction Stop log -text "Set authorized senders for $($groupPostProcessingData.groupInfo.DisplayName)" -color "Green" }catch{ log -text "Failed to set authorized senders $($Error[0]) $res" -color "Yellow" -warning } } } } try{ preventDoubleSchedule }catch{ write-host "FAILED TO PREVENT DOUBLE INSTANCES OF THIS SCRIPT" -ForegroundColor Red $totalErrors++ } try{ $script:logFileStream = New-Object IO.FileStream([System.IO.Path]::Combine($logPath), [System.IO.FileMode]::Append, [System.IO.FileAccess]::Write, [IO.FileShare]::Read) $script:logFileStreamWriter = New-Object System.IO.StreamWriter($script:logFileStream) log -text "O365 Group Sync v$version - $($env:USERNAME) on $($env:COMPUTERNAME) starting-----" -color "Green" log -text "Log path: $logPath" -color "Green" log -text "Cache database files should be located in: $cacheFolderPath" -color "Green" if($readOnly){log -text "Running in READ-ONLY mode, no actual changes will occur" -color "Yellow" -warning} log -text "syncDirection is set to $syncDirection" -color "Green" log -text "---------------------------------------------" -color "Green" }catch{ $totalErrors++ abort_GS -failed } #do a version check if allowed if($versionCheck){ try{ versionCheck -currentVersion $version log -text "you are running the latest (v$version) version of O365GroupSync" -color "Green" }catch{ log -text "ERROR: $($Error[0])" -color "Red" -error } } $ADCachePath = Join-Path -Path $cacheFolderPath -ChildPath "O365GroupSync_AD.cache" $O365CachePath = Join-Path -Path $cacheFolderPath -ChildPath "O365GroupSync_O365.cache" #region check parameters if(!$sourceOU -or !$targetOU){ log -text "sourceOU or targetOU not specified in script configuration, exiting" -color "Red" -error abort_GS -failed }#endregion #region load AD cache from file if it exists if([System.IO.File]::Exists($ADCachePath)){ try{ log -text "Loading data from $ADCachePath" -color "Green" [Array]$ADCache = @(Import-Clixml -Path $ADCachePath -ErrorAction Stop | where {$_}) log -text "Distribution Groups loaded from $ADCachePath, $($ADCache.Count) objects found" -color "Green" }catch{ $ADCache = $False log -text "Failed to load groups from $ADCachePath, file may have been corrupted, a new cache will be created" -color "Red" -append -error if($differential){ log -text "-differential was supplied as script argument, this is an invalid option when the Cache cannot be found" -color "Red" -error } } }else{ $ADCache = $False log -text "No cache file for Active Directory found, if this is a first run this is to be expected" -color "Green" }#endregion #region load O365 cache from file if it exists if([System.IO.File]::Exists($O365CachePath)){ try{ log -text "Loading data from $O365CachePath" -color "Green" [Array]$O365Cache = @(Import-Clixml -Path $O365CachePath -ErrorAction Stop | where {$_}) log -text "Distribution Groups loaded from $O365CachePath, $($O365Cache.Count) objects found" -color "Green" }catch{ $O365Cache = $False log -text "Failed to load groups from $O365CachePath, file may have been corrupted, a new cache will be created" -color "Red" -append -error if($differential){ log -text "-differential was supplied as script argument, this is an invalid option when the Cache cannot be found" -color "Red" -error } } }else{ $O365Cache = $False log -text "No cache file for Office 365 found, if this is a first run this is to be expected" -color "Green" }#endregion #region connect to local AD try{ ipmo ActiveDirectory -Force -ErrorAction Stop log -text "Active Directory connection: OK" -color "Green" }catch{ log -text "failed to load Active Directory Module" -color "Red" -append -error abort_GS -failed }#endregion #region cache objects in local AD try{ log -text "Caching current AD objects..." -color "Green" [Array]$ADObjectCache = @(cacheADObjects | where {$_}) if($ADObjectCache.Count -lt 1){Throw "No objects found in AD!"} log -text "Cached $($ADObjectCache.Count) objects in AD" -color "Green" }catch{ log -text "Failed to cache current objects in local AD" -color "Red" -append -error abort_GS -failed }#endregion #region evaluate local email domains if($domainsPresentLocally -eq ""){ $domainsPresentLocally = @(retrieveOnpremAcceptedDomains -ADObjectCache $ADObjectCache | where {$_}) }else{ log -text "Accepted Domains in Active Directory have been manually specified" -color "Green" $domainsPresentLocally = @($domainsPresentLocally.Split(",",[System.StringSplitOptions]::RemoveEmptyEntries) | where {$_}) } log -text "the following domains will be used as accepted domains in Active Directory: $($domainsPresentLocally -Join ",")" -color "Green" #endregion #region cache O365 objects try{ log -text "Caching current O365 objects..." -color "Green" $O365ObjectCache = cacheO365Objects log -text "Cached $($O365ObjectCache.Mailboxes.Count) mailboxes and $($O365ObjectCache.Groups.Count) groups in O365" -color "Green" }catch{ log -text "Failed to cache O365 objects" -color "Red" -append -error abort_GS -failed }#endregion #region check that our default admin actually exists try{ $res = searchExOForUserOrGroupV2 -O365ObjectCache $O365ObjectCache -searchQuery $defaultDLAdminInO365 log -text "Validated existence of $defaultDLAdminInO365 in Office 365" -color "Green" }catch{ log -text "$defaultDLAdminInO365 could not be found in Office 365, please correctly configure the defaultDLAdminInO365 variable in this script's configuration section" -Color "Red" -error abort_GS -failed }#endregion #region retrieve Exchange Online domains if($domainsPresentInCloud -eq ""){ $domainsPresentInCloud = @(retrieveRemoteAcceptedDomains | where {$_}) }else{ log -text "Accepted Domains in Exchange Online have been manually specified" -color "Green" $domainsPresentInCloud = @($domainsPresentInCloud.Split(",",[System.StringSplitOptions]::RemoveEmptyEntries) | where {$_}) } log -text "the following domains will be used as accepted domains in Exchange Online: $($domainsPresentInCloud -Join ",")" -color "Green" #endregion #region load local distribution groups try{ log -text "Loading distribution groups and their members from AD" -color "Green" [Array]$adDistributionGroups = @(retrieveADGroupsAndMembers -sourceOU $sourceOU | where {$_}) log -text "Loaded $($adDistributionGroups.Count) groups from AD" -color "Green" }catch{ log -text "There was a problem loading Distribution Groups" -color "Red" -append -error abort_GS -failed }#endregion #region autoRemediateGroupDisplayNames if necessary and set if($autoRemediateGroupDisplayNames){ log -text "autoRemediateGroupDisplayNames was set to True, checking for groups without a displayName or where the display name does not match the CN" -color "Green" $changed = $False foreach($adGroup in $adDistributionGroups){ if($adGroup.displayName.Length -lt 1 -or $adGroup.displayName -eq $Null -or $adGroup.displayName -ne $adGroup.cn){ try{ if(!$readOnly){ $res = Set-ADGroup -Identity $adGroup.objectGuid -DisplayName $adGroup.cn -ErrorAction Stop $changed = $true } log -text "set displayName for $($adGroup.distinguishedName) to $($adGroup.cn) because the display name was not properly configured" -color "Green" }catch{ log -text "Failed to set displayName for $($adGroup.distinguishedName) to $($adGroup.cn)" -color "Red" -append -error } } } if($changed){ #region load local distribution groups try{ log -text "Loading distribution groups and their members from AD" -color "Green" [Array]$adDistributionGroups = @(retrieveADGroupsAndMembers -sourceOU $sourceOU | where {$_}) log -text "Loaded $($adDistributionGroups.Count) groups from AD" -color "Green" }catch{ log -text "There was a problem loading Distribution Groups" -color "Red" -append -error abort_GS -failed }#endregion } }#endregion #region load O365 distribution groups try{ log -text "Loading distribution groups and their members from O365" -color "Green" [Array]$o365DistributionGroups = @(retrieveO365GroupsAndMembers | where {$_}) log -text "Loaded $($o365DistributionGroups.Count) groups from O365" -color "Green" }catch{ log -text "There was a problem loading Distribution Groups from O365" -color "Red" -append -error abort_GS -failed }#endregion #region validate at least 1 side has groups if($o365DistributionGroups.Count -lt 1 -and $adDistributionGroups.Count -lt 1){ log -text "No groups detected on both sides, please ensure you have selected the right source AD OU('s) and target O365 tenant" -color "Red" -error abort_GS -failed }#endregion #region if there is an AD cache file, let's see what has changed since the moment the cache was generated -SyncWindow ($adDistributionGroups.Count/2) if($ADCache -and $differential){ $adChanges = 0 try{ log -text "Comparing AD cache with current AD..." -color "Green" #detect new groups [Array]$adNewGroups = @(returnNewGroups -referenceGroups $ADCache -currentGroups $adDistributionGroups -mode 2 | where {$_}) if($adNewGroups[0].ObjectGuid.Guid.Length -gt 0){ log -text "found $($adNewGroups.Count) new groups" -color "Green" $adChanges+=$adNewGroups.Count }else{ log -text "Did not find any new groups" -color "Green" $adNewGroups = $False } #detect deleted groups [Array]$adDeletedGroups = @(returnDeletedGroups -referenceGroups $ADCache -currentGroups $adDistributionGroups -mode 2 | where {$_}) if($adDeletedGroups[0].ObjectGuid.Guid.Length -gt 0){ log -text "found $($adDeletedGroups.Count) deleted groups" -color "Green" $adChanges+=$adDeletedGroups.Count }else{ log -text "Did not find any deleted groups" -color "Green" $adDeletedGroups = $False } #stop if max deletes is triggered if($maxProjectedDeletesProtection -gt 0 -and $maxProjectedDeletesProtection -le $adDeletedGroups.Count){ log -text "You've set maxProjectedDeletesProtection to $maxProjectedDeletesProtection, while $($adDeletedGroups.Count) deletes are pending. Manually delete these groups, clear the cache and run O365GroupSync again. script terminated" -error abort_GS -failed } #detect group name changes try{ [Array]$adRenamedGroups = @(returnRenamedGroups -referenceGroups $ADCache -differenceGroups $adDistributionGroups -mode 1 | where {$_}) if($adRenamedGroups[0].newIdentifier.Length -gt 0){ log -text "found $($adRenamedGroups.Count) renamed groups" -color "Green" $adChanges+=$adRenamedGroups.Count }else{ log -text "Did not find any renamed groups" -color "Green" $adRenamedGroups = $False } }catch{ log -text "There was a problem comparing groups in the AD cache with the current AD" -color "Red" -append -error $adRenamedGroups = $False } #detect changed proxyAddresses (aliases) [Array]$adGroupProxyAddressChanges = @(returnChangedProxyAddresses -referenceGroups $ADCache -differenceGroups $adDistributionGroups -mode 2| where {$_}) if($adGroupProxyAddressChanges[0].mode -gt 0){ log -text "found $($adGroupProxyAddressChanges.Count) proxyAddress changes to process" -color "Green" $adChanges+=$adGroupProxyAddressChanges.Count }else{ log -text "Did not find any proxyAddress changes to process" -color "Green" $adGroupProxyAddressChanges = $False } #detect changed authorized senders [Array]$adGroupAuthorizedSenderChanges = @(returnChangedAuthorizedSenders -referenceGroups $ADCache -differenceGroups $adDistributionGroups -mode 2| where {$_}) if($adGroupAuthorizedSenderChanges[0].mode -gt 0){ log -text "found $($adGroupAuthorizedSenderChanges.Count) authorized sender changes to process" -color "Green" $adChanges+=$adGroupAuthorizedSenderChanges.Count }else{ log -text "Did not find an authorized sender changes to process" -color "Green" $adGroupAuthorizedSenderChanges = $False } #detect groups that have changed memberships [Array]$adGroupMemberChanges = @(returnChangedMembersInADGroups -referenceGroups $ADCache -differenceGroups $adDistributionGroups | where {$_}) if($adGroupMemberChanges[0].mode -gt 0){ log -text "found $($adGroupMemberChanges.Count) membership changes to process" -color "Green" $adChanges+=$adGroupMemberChanges.Count }else{ log -text "Did not find any membership changes to process" -color "Green" $adGroupMemberChanges = $False } $script:totalChanges+=$adChanges }catch{ log -text "Failed to compare AD cache with current AD, will ignore the cache" -color "Red" -append -error $adChanges = 0 } }else{ $adChanges = 0 log -text "No changes detected between AD cache and current AD" -color "Green" }#endregion #region if there is an O365 Cache file, let's see what has changed since the moment the cache was generated if($O365Cache -and $differential){ $O365Changes = 0 try{ log -text "Comparing O365 cache with current O365..." -color "Green" #detect new groups [Array]$O365NewGroups = @(returnNewGroups -referenceGroups $O365Cache -currentGroups $o365DistributionGroups -mode 1 | where {$_}) if($O365NewGroups[0].Guid.Guid.Length -gt 0){ log -text "found $($O365NewGroups.Count) new groups" -color "Green" $O365Changes+=$O365NewGroups.Count }else{ log -text "Did not find any new groups" -color "Green" $O365NewGroups = $False } #detect deleted groups [Array]$O365DeletedGroups = @(returnDeletedGroups -referenceGroups $O365Cache -currentGroups $o365DistributionGroups -mode 1 | where {$_}) if($O365DeletedGroups[0].Guid.Guid.Length -gt 0){ log -text "found $($O365DeletedGroups.Count) deleted groups" -color "Green" $O365Changes+=$O365DeletedGroups.Count }else{ log -text "Did not find any deleted groups" -color "Green" $O365DeletedGroups = $False } #stop if max deletes is triggered if($maxProjectedDeletesProtection -gt 0 -and $maxProjectedDeletesProtection -le $O365DeletedGroups.Count){ log -text "You've set maxProjectedDeletesProtection to $maxProjectedDeletesProtection, while $($O365DeletedGroups.Count) deletes are pending. Manually delete these groups, clear the cache and run O365GroupSync again. script terminated" -error abort_GS -failed } #detect renamed groups try{ [Array]$O365RenamedGroups = @(returnRenamedGroups -referenceGroups $O365Cache -differenceGroups $o365DistributionGroups -mode 2 | where {$_}) if($O365RenamedGroups[0].newIdentifier.Length -gt 0){ log -text "found $($O365RenamedGroups.Count) renamed groups" -color "Green" $O365Changes+=$O365RenamedGroups.Count }else{ log -text "Did not find any renamed groups" -color "Green" $O365RenamedGroups = $False } }catch{ log -text "There was a problem comparing groups in the O365 cache with the current O365 data" -color "Red" -append -error $O365RenamedGroups = $False } #detect changed emailAddresses (aliases) [Array]$o365GroupEmailAddressChanges = @(returnChangedProxyAddresses -referenceGroups $O365Cache -differenceGroups $o365DistributionGroups -mode 1| where {$_}) if($o365GroupEmailAddressChanges[0].mode -gt 0){ log -text "found $($o365GroupEmailAddressChanges.Count) emailAddress changes to process" -color "Green" $O365Changes+=$o365GroupEmailAddressChanges.Count }else{ $o365GroupEmailAddressChanges = $False log -text "Did not find any emailAddress changes to process" -color "Green" } #detect changed authorized senders [Array]$o365GroupAuthorizedSenderChanges = @(returnChangedAuthorizedSenders -referenceGroups $O365Cache -differenceGroups $o365DistributionGroups -mode 1| where {$_}) if($o365GroupAuthorizedSenderChanges[0].mode -gt 0){ log -text "found $($o365GroupAuthorizedSenderChanges.Count) authorized sender changes to process" -color "Green" $O365Changes+=$o365GroupAuthorizedSenderChanges.Count }else{ $o365AuthorizedSenderChanges = $False log -text "Did not find any authorized sender changes to process" -color "Green" } #detect groups that have changed memberships [Array]$O365GroupMemberChanges = @(returnChangedMembersInO365Groups -referenceGroups $O365Cache -differenceGroups $o365DistributionGroups | where {$_}) if($O365GroupMemberChanges[0].mode -gt 0){ log -text "found $($O365GroupMemberChanges.Count) membership changes to process" -color "Green" $O365Changes+=$O365GroupMemberChanges.Count }else{ $O365GroupMemberChanges = $False log -text "Did not find any membership changes to process" -color "Green" } $script:totalChanges+=$O365Changes }catch{ log -text "Failed to compare O365 cache with current O365, will ignore the cache" -color "Red" -append -error $O365Changes = 0 } }else{ $O365Changes = 0 log -text "No changes detected between O365 cache and current O365" -color "Green" }#endregion #region process changes from AD to Office 365 if($adChanges -gt 0){ log -text "Processing $adChanges differential changes to Office 365 from AD" -color "Green" if($syncDirection -ne 3){ ##RENAME GROUPS if($adRenamedGroups[0].newIdentifier){ foreach($renamedGroup in $adRenamedGroups){ try{ if(!$readOnly){ $res = Set-DistributionGroup -Identity $renamedGroup.newIdentifier -DisplayName $renamedGroup.newDisplayName -BypassSecurityGroupManagerCheck -Confirm:$False -ErrorAction Stop } $script:totalChanged++ ##fix cache manually so we don't need to re-cache objects $count = 0 foreach($group in $O365ObjectCache.Groups){ if($group.DisplayName -eq $renamedGroup.newIdentifier){ $O365ObjectCache.Groups[$count].DisplayName = $renamedGroup.newDisplayName break } $count++ } log -text "Renamed O365 group to $($renamedGroup.newDisplayName) (group id: $($renamedGroup.newIdentifier))" -color "Green" }catch{ log -text "There was a problem renaming $($renamedGroup.newIdentifier) to $($renamedGroup.newDisplayName) $res" -color "Red" -append -error } } } ##ADD NEW GROUPS if($adNewGroups[0].DisplayName){ log -text "Provisioning $($adNewGroups.Count) groups" -color "Green" provisionO365Groups -groups $adNewGroups } ##DELETE GROUPS if($adDeletedGroups[0].ObjectGuid.Guid){ if($adDeletedGroups.Count -ge $o365DistributionGroups.Count){ log -text "Multi-delete protection triggered, you are trying to delete ALL groups in scope, please do so manually, to protect your data this script will not delete all groups" -color "Red" -error abort_GS -failed } foreach($deletedGroup in $adDeletedGroups){ try{ if(!$readOnly){ $res = Remove-DistributionGroup -Identity $deletedGroup.DisplayName -BypassSecurityGroupManagerCheck -Confirm:$False } $script:totalChanged++ log -text "Deleted O365 group: $($deletedGroup.DisplayName)" -color "Green" }catch{ log -text "There was a problem deleting $($deletedGroup.DisplayName) $res" -color "Red" -append -error } } } ##CHANGED PROXYADDRESSES if($adGroupProxyAddressChanges[0].mode){ foreach($adGroupProxyAddressChange in $adGroupProxyAddressChanges){ try{ $targetGroup = searchExOForUserOrGroupV2 -O365ObjectCache $O365ObjectCache -searchQuery $adGroupProxyAddressChange.DisplayName -byDisplayName }catch{ log -text "Failed to find $($adGroupProxyAddressChange.DisplayName) in O365" -color "Red" -error continue } if($adGroupProxyAddressChange.mode -eq 1){ #delete try{ if(!$readOnly){ $res = Set-DistributionGroup -Identity $targetGroup.Guid.Guid -EmailAddresses @{Remove=$adGroupProxyAddressChange.proxyAddress} -Confirm:$False -ErrorAction Stop } $script:totalChanged++ log -text "Removed $($adGroupProxyAddressChange.proxyAddress) from $($adGroupProxyAddressChange.DisplayName)." -color "Green" }catch{ log -text "Failed to remove $($adGroupProxyAddressChange.proxyAddress) from $($adGroupProxyAddressChange.DisplayName). $res" -color "Red" -append -error } } if($adGroupProxyAddressChange.mode -eq 2){ #add try{ if(!$readOnly){ if($adGroupProxyAddressChange.proxyAddress -clike "SMTP:*"){ $res = Set-DistributionGroup -Identity $targetGroup.Guid.Guid -PrimarySmtpAddress $adGroupProxyAddressChange.proxyAddress.substring(5) -Confirm:$False -ErrorAction Stop }else{ $res = Set-DistributionGroup -Identity $targetGroup.Guid.Guid -EmailAddresses @{Add=$adGroupProxyAddressChange.proxyAddress} -Confirm:$False -ErrorAction Stop } } $script:totalChanged++ log -text "Added $($adGroupProxyAddressChange.proxyAddress) to $($adGroupProxyAddressChange.DisplayName)." -color "Green" }catch{ log -text "Failed to add $($adGroupProxyAddressChange.proxyAddress) to $($adGroupProxyAddressChange.DisplayName). $res" -color "Red" -append -error } } } } #CHANGED DELEGATES if($adGroupAuthorizedSenderChanges[0].mode){ foreach($adGroupAuthorizedSenderChange in $adGroupAuthorizedSenderChanges){ try{ $targetGroup = searchExOForUserOrGroupV2 -O365ObjectCache $O365ObjectCache -searchQuery $adGroupAuthorizedSenderChange.DisplayName -byDisplayName }catch{ log -text "Failed to find $($adGroupAuthorizedSenderChange.DisplayName) in O365" -color "Red" -error continue } try{ $sourceSender = searchAdForUserOrGroup -searchQuery $adGroupAuthorizedSenderChange.authorizedSender $targetSender = searchExOForUserOrGroupV2 -O365ObjectCache $O365ObjectCache -searchQuery $sourceSender.mail }catch{ log -text "Failed to find $($adGroupAuthorizedSenderChange.authorizedSender) in O365 or AD" -color "Red" -error continue } if($adGroupAuthorizedSenderChange.mode -eq 1){ #delete single user try{ if(!$readOnly){ $newValues = $targetGroup.AcceptMessagesOnlyFrom -notmatch $targetSender.Alias $res = Set-DistributionGroup -Identity $targetGroup.Guid.Guid -AcceptMessagesOnlyFrom $newValues -Confirm:$False -ErrorAction Stop } log -text "Removed $($targetSender.Alias) from $($targetGroup.DisplayName)." -color "Green" }catch{ log -text "Failed to remove $($targetSender.Alias) from $($targetGroup.DisplayName). $res" -color "Red" -append -error } } if($adGroupAuthorizedSenderChange.mode -eq 2){ #add single user try{ if(!$readOnly){ $newValues = $targetGroup.AcceptMessagesOnlyFrom + $targetSender.Alias $res = Set-DistributionGroup -Identity $targetGroup.Guid.Guid -AcceptMessagesOnlyFrom $newValues -Confirm:$False -ErrorAction Stop $script:totalChanged++ } log -text "Added $($targetSender.Alias) to $($targetGroup.DisplayName)." -color "Green" }catch{ log -text "Failed to add $($targetSender.Alias) to $($targetGroup.DisplayName). $res" -color "Red" -append -error } } if($adGroupAuthorizedSenderChange.mode -eq 3){ #delete single group try{ if(!$readOnly){ $newValues = $targetGroup.AcceptMessagesOnlyFromDLMembers -notmatch $targetSender.Alias $res = Set-DistributionGroup -Identity $targetGroup.Guid.Guid -AcceptMessagesOnlyFromDLMembers $newValues -Confirm:$False -ErrorAction Stop $script:totalChanged++ } log -text "Removed $($targetSender.Alias) from $($targetGroup.DisplayName)." -color "Green" }catch{ log -text "Failed to remove $($targetSender.Alias) from $($targetGroup.DisplayName). $res" -color "Red" -append -error } } if($adGroupAuthorizedSenderChange.mode -eq 4){ #add single group try{ if(!$readOnly){ $newValues = $targetGroup.AcceptMessagesOnlyFromDLMembers + $targetSender.Alias $res = Set-DistributionGroup -Identity $targetGroup.Guid.Guid -AcceptMessagesOnlyFromDLMembers $newValues -Confirm:$False -ErrorAction Stop $script:totalChanged++ } log -text "Added $($targetSender.Alias) to $($targetGroup.DisplayName)." -color "Green" }catch{ log -text "Failed to add $($targetSender.Alias) to $($targetGroup.DisplayName). $res" -color "Red" -append -error } } } } ##PROCESS MEMBERSHIP CHANGES if($adGroupMemberChanges[0].mode){ foreach($adGroupMemberChange in $adGroupMemberChanges){ if($adGroupMemberChange.mode -eq 1){ try{ $user = searchExOForUserOrGroupV2 -O365ObjectCache $O365ObjectCache -searchQuery $adGroupMemberChange.memberMail if(!$readOnly){ $res = Remove-DistributionGroupMember -Identity $adGroupMemberChange.DisplayName -Member $adGroupMemberChange.memberMail -BypassSecurityGroupManagerCheck -Confirm:$False -ErrorAction Stop $script:totalChanged++ } log -text "Removed $($adGroupMemberChange.memberMail) from $($adGroupMemberChange.DisplayName)." -color "Green" }catch{ log -text "Failed to remove $($adGroupMemberChange.memberMail) from $($adGroupMemberChange.DisplayName). $res" -color "Yellow" -append -warning } } if($adGroupMemberChange.mode -eq 2){ if($adGroupMemberChange.userAccountControl -band 2 -and $skipDisabledAccounts){ continue } try{ $user = searchExOForUserOrGroupV2 -O365ObjectCache $O365ObjectCache -searchQuery $adGroupMemberChange.memberMail if(!$readOnly){ $res = Add-DistributionGroupMember -Identity $adGroupMemberChange.DisplayName -Member $adGroupMemberChange.memberMail -BypassSecurityGroupManagerCheck -Confirm:$False -ErrorAction Stop $script:totalChanged++ } log -text "Added $($adGroupMemberChange.memberMail) to $($adGroupMemberChange.DisplayName)." -color "Green" }catch{ log -text "Failed to add $($adGroupMemberChange.memberMail) to $($adGroupMemberChange.DisplayName). $res" -color "Yellow" -append -warning } } } } #RESET CACHE #region cache O365 objects try{ log -text "Caching current O365 objects..." -color "Green" $O365ObjectCache = cacheO365Objects log -text "Cached $($O365ObjectCache.Mailboxes.Count) mailboxes and $($O365ObjectCache.Groups.Count) groups in O365" -color "Green" }catch{ log -text "Failed to cache O365 objects" -color "Red" -append -error abort_GS -failed }#endregion try{ log -text "Propagated all recent changes from AD to O365, re-caching data from O365" -color "Green" [Array]$o365DistributionGroups = @(retrieveO365GroupsAndMembers | where {$_}) log -text "Loaded $($o365DistributionGroups.Count) groups from O365" -color "Green" $res = Remove-Item $ADCachePath -Force -Recurse -Confirm:$False log -text "Removed AD Cache File" -color "Green" }catch{ log -text "There was a problem resetting the cache while processing changes to O365 $res" -color "Red" -append -error abort_GS -failed } }else{ log -text "Changes detected in AD since last run, but sync direction is set to O365 > AD, ignoring changes in AD" -color "Green" -warning } }#endregion #region process changes from O365 to AD if($O365Changes -gt 0){ log -text "Processing $O365Changes differential changes to AD from Office 365" -color "Green" if($syncDirection -ne 2){ ##RENAME GROUPS if($O365RenamedGroups[0].newIdentifier){ foreach($renamedGroup in $O365RenamedGroups){ try{ $targetGroup = searchADForUserOrGroup -searchQuery $renamedGroup.newIdentifier }catch{ log -text "Failed to find $($renamedGroup.newIdentifier) in AD" -color "Red" -error continue } try{ if(!$readOnly){ $res = Set-ADGroup -Identity $targetGroup.DistinguishedName -DisplayName $renamedGroup.newDisplayName -ErrorAction Stop $script:totalChanged++ } log -text "Renamed AD group to $($renamedGroup.newDisplayName) (group id: $($targetGroup.DistinguishedName))" -color "Green" }catch{ log -text "There was a problem renaming $($targetGroup.DistinguishedName) to $($renamedGroup.newDisplayName) $res" -color "Red" -append -error } } } ##ADD NEW GROUPS if($O365NewGroups[0].DisplayName){ provisionADGroups -groups $O365NewGroups } ##DELETE GROUPS if($O365DeletedGroups[0].Guid.Guid){ if($O365DeletedGroups.Count -ge $adDistributionGroups.Count){ log -text "Multi-delete protection triggered, you are trying to delete ALL groups in scope, please do so manually, to protect your data this script will not delete all groups" -color "Red" -error abort_GS -failed } foreach($deletedGroup in $O365DeletedGroups){ try{ $targetGroup = searchADForUserOrGroup -searchQuery $deletedGroup.displayName }catch{ log -text "Failed to find $($deletedGroup.displayName) in AD" -color "Red" -error continue } try{ if(!$readOnly){ $res = Remove-ADGroup -Identity $targetGroup.ObjectGuid -Confirm:$False -ErrorAction Stop } $script:totalChanged++ log -text "Deleted AD group: $($deletedGroup.displayName)" -color "Green" }catch{ log -text "There was a problem deleting $($deletedGroup.displayName) $res" -color "Red" -append -error } } } ##CHANGED PROXYADDRESSES if($o365GroupEmailAddressChanges[0].mode){ foreach($o365GroupEmailAddressChange in $o365GroupEmailAddressChanges){ try{ $targetGroup = searchADForUserOrGroup -searchQuery $o365GroupEmailAddressChange.DisplayName }catch{ log -text "Failed to find $($o365GroupEmailAddressChange.DisplayName) in AD" -color "Red" -error continue } if($o365GroupEmailAddressChange.mode -eq 1){ #delete try{ if(!$readOnly){ $res = Set-ADGroup -Identity $targetGroup.ObjectGuid.Guid -Remove @{ProxyAddresses=$o365GroupEmailAddressChange.proxyAddress} -Confirm:$False -ErrorAction Stop $script:totalChanged++ } log -text "Removed $($o365GroupEmailAddressChange.proxyAddress) from $($o365GroupEmailAddressChange.DisplayName)." -color "Green" }catch{ log -text "Failed to remove $($o365GroupEmailAddressChange.proxyAddress) from $($o365GroupEmailAddressChange.DisplayName). $res" -color "Red" -append -error } } if($o365GroupEmailAddressChange.mode -eq 2){ #add try{ if(!$readOnly){ $res = Set-ADGroup -Identity $targetGroup.ObjectGuid.Guid -Add @{ProxyAddresses=$o365GroupEmailAddressChange.proxyAddress} -Confirm:$False -ErrorAction Stop $script:totalChanged++ } log -text "Added $($o365GroupEmailAddressChange.proxyAddress) to $($o365GroupEmailAddressChange.DisplayName)." -color "Green" }catch{ log -text "Failed to add $($o365GroupEmailAddressChange.proxyAddress) to $($o365GroupEmailAddressChange.DisplayName). $res" -color "Red" -append -error } } } } ##CHANGED DELEGATES if($o365GroupAuthorizedSenderChanges[0].mode){ foreach($o365GroupAuthorizedSenderChange in $o365GroupAuthorizedSenderChanges){ try{ $targetGroup = searchADForUserOrGroup -searchQuery $o365GroupAuthorizedSenderChange.DisplayName }catch{ log -text "Failed to find $($o365GroupAuthorizedSenderChange.DisplayName) in AD" -color "Red" -error continue } try{ $sourceSender = searchExOForUserOrGroupV2 -O365ObjectCache $O365ObjectCache -searchQuery $o365GroupAuthorizedSenderChange.authorizedSender -byAlias $targetSender = searchADForUserOrGroup -searchQuery $sourceSender.primarySmtpAddress -byMail -anyAlias }catch{ log -text "Failed to find $($o365GroupAuthorizedSenderChange.authorizedSender) in O365 or AD" -color "Red" -error continue } if($o365GroupAuthorizedSenderChange.mode -eq 1){ #delete single user try{ if(!$readOnly){ $res = Set-ADGroup -Identity $targetGroup.ObjectGuid.Guid -Remove @{AuthOrig=$targetSender.distinguishedName} -Confirm:$False -ErrorAction Stop $script:totalChanged++ } log -text "Removed $($targetSender.distinguishedName) from $($targetGroup.DisplayName)." -color "Green" }catch{ log -text "Failed to remove $($targetSender.distinguishedName) from $($targetGroup.DisplayName). $res" -color "Red" -append -error } } if($o365GroupAuthorizedSenderChange.mode -eq 2){ #add single user try{ if(!$readOnly){ $res = Set-ADGroup -Identity $targetGroup.ObjectGuid.Guid -Add @{AuthOrig=$targetSender.distinguishedName} -Confirm:$False -ErrorAction Stop $script:totalChanged++ } log -text "Added $($targetSender.distinguishedName) to $($targetGroup.DisplayName)." -color "Green" }catch{ log -text "Failed to add $($targetSender.distinguishedName) to $($targetGroup.DisplayName). $res" -color "Red" -append -error } } if($o365GroupAuthorizedSenderChange.mode -eq 3){ #delete single group try{ if(!$readOnly){ $res = Set-ADGroup -Identity $targetGroup.ObjectGuid.Guid -Remove @{dLMemSubmitPerms=$targetSender.distinguishedName} -Confirm:$False -ErrorAction Stop $script:totalChanged++ } log -text "Removed $($targetSender.distinguishedName) from $($targetGroup.DisplayName)." -color "Green" }catch{ log -text "Failed to remove $($targetSender.distinguishedName) from $($targetGroup.DisplayName). $res" -color "Red" -append -error } } if($o365GroupAuthorizedSenderChange.mode -eq 4){ #add single group try{ if(!$readOnly){ $res = Set-ADGroup -Identity $targetGroup.ObjectGuid.Guid -Add @{dLMemSubmitPerms=$targetSender.distinguishedName} -Confirm:$False -ErrorAction Stop $script:totalChanged++ } log -text "Added $($targetSender.distinguishedName) to $($targetGroup.DisplayName)." -color "Green" }catch{ log -text "Failed to add $($targetSender.distinguishedName) to $($targetGroup.DisplayName). $res" -color "Red" -append -error } } } } ##PROCESS MEMBERSHIP CHANGES if($O365GroupMemberChanges[0].mode){ foreach($O365MemberChange in $O365GroupMemberChanges){ try{ $targetGroup = searchADForUserOrGroup -searchQuery $O365MemberChange.DisplayName }catch{ log -text "Failed to find $($O365MemberChange.DisplayName) in AD" -color "Yellow" -warning continue } if($O365MemberChange.mode -eq 1){ try{ $user = searchADForUserOrGroup -searchQuery $O365MemberChange.memberMail -byMail -anyAlias if(!$readOnly){ $res = Set-ADGroup -Identity $targetGroup.ObjectGuid.Guid -Remove @{'member'=$user.DistinguishedName} -ErrorAction Stop $script:totalChanged++ } log -text "Removed $($O365MemberChange.memberMail) from $($targetGroup.DistinguishedName)." -color "Green" }catch{ log -text "Failed to remove $($O365MemberChange.memberMail) from $($targetGroup.DistinguishedName). $res" -color "Yellow" -append -warning } } if($O365MemberChange.mode -eq 2){ try{ $user = searchADForUserOrGroup -searchQuery $O365MemberChange.memberMail -byMail -anyAlias if(!$readOnly){ $res = Set-ADGroup -Identity $targetGroup.ObjectGuid.Guid -Add @{'member'=$user.DistinguishedName} -ErrorAction Stop $script:totalChanged++ } log -text "Added $($O365MemberChange.memberMail) to $($targetGroup.DistinguishedName)." -color "Green" }catch{ log -text "Failed to add $($O365MemberChange.memberMail) to $($targetGroup.DistinguishedName). $res" -color "Yellow" -append -warning } } } } #RESET CACHE try{ log -text "Propagated all recent changes from O365 to AD, re-caching data from AD" -color "Green" [Array]$adDistributionGroups = @(retrieveADGroupsAndMembers -sourceOU $sourceOU | where {$_}) log -text "Loaded $($adDistributionGroups.Count) groups from AD" -color "Green" $res = Remove-Item $O365CachePath -Force -Recurse -Confirm:$False log -text "Removed O365 Cache File" -color "Green" }catch{ log -text "There was a problem resetting the cache while processing changes to AD" -color "Red" -append -error abort_GS -failed } }else{ log -text "Changes detected in O365 since last run, but sync direction is set to AD > O365, ignoring changes in O365" -color "Green" -warning } }#endregion #region do a full comparison of both environments if($full){ #now run a full sync in stages $changes = 0 #stage 1: Group Membership AD -> O365 and vice versa log -text "Performing full comparison of group membership between O365 and AD groups" -color "Green" [Array]$membersDiff = @(returnDifferentMembersBetweenGroups -o365Groups $o365DistributionGroups -adGroups $adDistributionGroups | where {$_}) if($membersDiff.Count -eq 0){ log -text "No membership differences detected between groups that exist in both locations" -color "Green" }else{ $changes+=$membersDiff.Count log -text "$($membersDiff.Count) membership differences detected and queued" -color "Green" $failed = 0 foreach($memberDiff in $membersDiff){ if($memberDiff.DisplayName.Length -lt 1 -or $memberDiff.memberIdentifier.Length -lt 1 -or $memberDiff.targetIdentifier.Length -lt 1){ log -text "Invalid member change, cannot process $memberDiff because of missing properties" -color "Yellow" -warning continue }else{ if($memberDiff.mode -eq 1 -and $syncDirection -ne 2){ try{ if(!$readOnly){ $res = Set-ADGroup -Identity $memberDiff.targetIdentifier -Add @{'member'=$memberDiff.memberIdentifier} -ErrorAction Stop $script:totalChanged++ } log -text "Added $($memberDiff.memberIdentifier) to AD Group $($memberDiff.DisplayName) $($memberDiff.targetIdentifier)" -Color "Green" }catch{ log -text "Failed to add $($memberDiff.memberIdentifier) to AD Group $($memberDiff.DisplayName) $($memberDiff.targetIdentifier) $res" -color "Yellow" -append -warning $failed++ } } if($memberDiff.mode -eq 2 -and $syncDirection -ne 3){ try{ if(!$readOnly){ $res = Add-DistributionGroupMember -Identity $memberDiff.targetIdentifier -Member $memberDiff.memberIdentifier -BypassSecurityGroupManagerCheck -Confirm:$False -ErrorAction Stop -WarningAction Stop $script:totalChanged++ } log -text "Added $($memberDiff.memberIdentifier) to O365 Group $($memberDiff.DisplayName) $($memberDiff.targetIdentifier)" -Color "Green" }catch{ log -text "Failed to add $($memberDiff.memberIdentifier) to O365 Group $($memberDiff.DisplayName) $($memberDiff.targetIdentifier) $res" -color "Yellow" -append -warning $failed++ } } } } if($failed -gt 0){ log -text "Processed $($membersDiff.Count - $failed) changes to membership, there were $failed errors, see earlier log entries for details" -color "Yellow" }else{ log -text "Processed $($membersDiff.Count) changes to membership without any errors" -color "Green" } } #stage 2: check groups that exist in AD, but don't exist in O365 and create them try{ log -text "Comparing AD groups with O365 groups..." -color "Green" if($o365DistributionGroups.Count -gt 0){ [Array]$ADNewGroups = @(returnMissingGroups -referenceGroups $o365DistributionGroups -differenceGroups $adDistributionGroups | where {$_}) }else{ [Array]$ADNewGroups = @($adDistributionGroups | where {$_}) } $changes+=$ADNewGroups.Count log -text "Found $($ADNewGroups.Count) new groups to provision in O365" -color "Green" }catch{ log -text "Failed to compare O365 groups with AD groups" -color "Red" -append -error abort_GS -failed } if($ADNewGroups.Count -gt 0){ if($syncDirection -ne 3){ provisionO365Groups -groups $ADNewGroups log -text "Group provisioning in O365 completed" -color "Green" }else{ log -text "Groups that exist in AD but not in O365 were detected, but syncDirection is set to 3 so no groups will be provisioned in O365" -color "Yellow" -warning } } $script:totalChanges+=$changes if($changes -gt 0){ #Reload O365 objects #region cache O365 objects try{ log -text "Caching current O365 objects..." -color "Green" $O365ObjectCache = cacheO365Objects log -text "Cached $($O365ObjectCache.Mailboxes.Count) mailboxes and $($O365ObjectCache.Groups.Count) groups in O365" -color "Green" }catch{ log -text "Failed to cache O365 objects" -color "Red" -append -error abort_GS -failed }#endregion try{ log -text "Re-caching distribution groups from O365" -color "Green" [Array]$o365DistributionGroups = @(retrieveO365GroupsAndMembers | where {$_}) log -text "Loaded $($o365DistributionGroups.Count) groups from O365" -color "Green" }catch{ log -text "There was a problem loading Distribution Groups from O365" -color "Red" -append -error abort_GS -failed } $changes=0 } #stage 3: check groups that exist in O365, but don't exist in AD and create them try{ log -text "Comparing O365 groups with AD groups..." -color "Green" [Array]$O365NewGroups = @(returnMissingGroups -referenceGroups $adDistributionGroups -differenceGroups $o365DistributionGroups | where {$_}) log -text "Found $($O365NewGroups.Count) new groups to provision in AD" -color "Green" }catch{ log -text "Failed to compare AD groups with O365 groups" -color "Red" -append -error abort_GS -failed } if($O365NewGroups.Count -gt 0){ $changes++ if($targetOU -and $syncDirection -ne 2){ provisionADGroups -groups $O365NewGroups }else{ log -text "Groups that exist in O365 but not in AD were detected, but either targetOU is not defined or syncDirection is set to 2 so no groups will be provisioned in AD" -color "Yellow" -warning } } $script:totalChanges+=$changes if($changes -gt 0){ log -text "Propagated all recent changes from O365 to AD, re-caching data from AD" -color "Green" [Array]$adDistributionGroups = @(retrieveADGroupsAndMembers -sourceOU $sourceOU | where {$_}) log -text "Loaded $($adDistributionGroups.Count) groups from AD" -color "Green" } }#endregion #region final stage, store cache for next run #Store AD cache try{ log -text "Writing AD cache file now..." -color "Green" if(!$readOnly){ $adDistributionGroups | Export-Clixml -Path $ADCachePath -Depth 10 -Confirm:$False -Force -Encoding UTF8 -ErrorAction Stop } log -text "Overwrote AD cache for next run" -color "Green" }catch{ log -text "Failed to write to AD cache, this will result in failures at next run, it is recommended to delete the cache files and immediately rerun this script" -color "Red" -append -error } #Store O365 cache try{ log -text "Writing O365 cache file now..." -color "Green" if(!$readOnly){ $o365DistributionGroups | Export-Clixml -Path $O365CachePath -Depth 10 -Confirm:$False -Force -Encoding UTF8 -ErrorAction Stop } log -text "Overwrote O365 cache for next run" -color "Green" }catch{ log -text "Failed to write to O365 cache, this will result in failures at next run, it is recommended to delete the cache files and immediately rerun this script" -color "Red" -append -error }#endregion abort_GS