ADSyncTools.psm1

<#
Disclaimer: The scripts are not supported under any Microsoft standard support program or service.
The scripts are provided AS IS without warranty of any kind. Microsoft further disclaims all implied
warranties including, without limitation, any implied warranties of merchantability or of fitness for a
particular purpose. The entire risk arising out of the use or performance of the scripts and
documentation remains with you. In no event shall Microsoft, its authors, or anyone else involved in the
creation, production, or delivery of the scripts be liable for any damages whatsoever (including, without
limitation, damages for loss of business profits, business interruption, loss of business information, or
other pecuniary loss) arising out of the use of or inability to use the scripts or documentation,
even if Microsoft has been advised of the possibility of such damages.
#>


#----------------------------------------------------------------------------------------------------------
#
# Copyright © 2024 Microsoft Corporation. All rights reserved.
#
#----------------------------------------------------------------------------------------------------------
#
# NAME: Azure AD Connect ADSyncTools PowerShell Module
#
#----------------------------------------------------------------------------------------------------------
#
# RELEASE NOTES
#
# Version 1.5.0 - 2024-10-04
# - Introduced Microsoft Graph PowerShell SDK base functions
# - Introduced new functions for managing onpremises attributes using Microsoft Graph
# Get/Set/Clear-ADSyncToolsOnPremisesAttribute
#
# Version 1.5.1 - 2024-10-07
# - Get/Set/Clear-ADSyncToolsOnPremisesAttribute: Improved support for pipelining cmdlets
#
# Version 1.5.2 - 2024-10-24
# - Excluded onPremisesImmutableId from Clear-ADSyncToolsOnPremisesAttribute -All
#
#----------------------------------------------------------------------------------------------------------

#=======================================================================================
#region Internal Variables
#=======================================================================================

[string[]] $defaultMsolObjProperties = @('DisplayName',
                                         'ObjectId',
                                         'isLicensed',
                                         'LastDirSyncTime',
                                         'ImmutableId',
                                         'UserPrincipalName',
                                         'MobilePhone',
                                         'AlternateMobilePhones')
[string[]] $defaultAADobjProperties = @('ProxyAddresses',
                                        'SourceAnchor',
                                        'DisplayName',
                                        'Mail')
[string[]] $defaultADobjProperties = @('DistinguishedName',
                                       'ObjectClass',
                                       'Name',
                                       'ObjectGUID',
                                       'ObjectSID',
                                       'mS-DS-ConsistencyGuid',
                                       'sAMAccountName',
                                       'CanonicalName',
                                       'msDS-PrincipalName',
                                       'UserPrincipalName')
[string[]] $defaultGraphOnPremisesProperties = @('onPremisesDistinguishedName',
                                                 'onPremisesDomainName',
                                                 'onPremisesImmutableId',
                                                 'onPremisesSamAccountName',
                                                 'onPremisesSecurityIdentifier',
                                                 'onPremisesUserPrincipalName')
[string] $msGraphInstallMsg = "See https://learn.microsoft.com/en-us/powershell/microsoftgraph/installation on how to install Microsoft Graph SDK module. "
[string] $adSyncInstallMsg = "Microsoft Entra Connect Sync needs to be installed and running. "
[string] $addsInstallMsg = "This function requires Active Directory PowerShell module. To install this module type: Install-WindowsFeature RSAT-AD-Tools -IncludeAllSubFeature "
[regex] $distinguishedNameRegex = '^(?:(?<cn>CN=(?<name>[^,]*)),)?(?:(?<path>(?:(?:CN|OU)=[^,]+,?)+),)?(?<domain>(?:DC=[^,]+,?)+)$'
[regex] $upnRegex = "^[a-zA-Z0-9.!£#$%&'^_`{}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$"
[regex] $guidRegex = '\w{8}-\w{4}-\w{4}-\w{4}-\w{12}'
[regex] $netbiosDomainRegex = "w*\\\w+"



#endregion
#=======================================================================================

#=======================================================================================
#region class definitions
#=======================================================================================

class DuplicateUserSourceAnchorInfo
{
    [string] $UserName

    [string] $DistinguishedName

    [string] $ADDomainName

    [Byte[]] $CurrentMsDsConsistencyGuid

    [Byte[]] $ExpectedMsDsConsistencyGuid
}

#endregion
#=======================================================================================


#=======================================================================================
#region Internal Functions
#=======================================================================================


<#
.SYNOPSIS
    Checks if <ModuleName> PowerShell module is present and imports it
#>

Function Import-ADSyncToolsModule
{
    [CmdletBinding()]
    Param 
    (
        [Parameter(Mandatory=$true,
                   ValueFromPipelineByPropertyName=$true,
                   ValueFromPipeline=$true,
                   Position=0)]
        [string] $ModuleName,

        [Parameter(Mandatory=$false,
                   ValueFromPipelineByPropertyName=$true,
                   Position=1)]
        [string] $InstallMessage,

        [Parameter(Mandatory=$false,
                   ValueFromPipelineByPropertyName=$true,
                   Position=2)]
        [switch] $CheckOnly        

    )
    
    if ($CheckOnly)
    {
        # Check if module is available
        If (-not (Get-Module $ModuleName -ListAvailable))
        {       
            Write-Warning "$ModuleName PowerShell module is required for some functions to work. ADSyncTools will run with limited functionality.`n$InstallMessage"
        }
    }
    else 
    {
        # Import Module
        If (-not (Get-Module $ModuleName))
        {
            Try
            {
                # Import powershell module
                Import-Module $ModuleName -ErrorAction Stop
            }
            Catch
            {
                    Throw "Unable to import $ModuleName PowerShell module. $($InstallMessage)Error Details: $($_.Exception.Message)"
            }
        }
    }
}


<#
.SYNOPSIS
    Checks if AADConnector PowerShell Module is present and imports it
#>

Function Import-ADSyncToolsAADConnectorBinaries
{
    [CmdletBinding()]
    Param ()

    $binariesPath = Get-ADSyncToolsADsyncFolder
    
    If ($binariesPath -eq '')
    {
        Write-Warning "Azure AD Connect installation was not found, some functionality may be unavailable. Using current directory '$PSScriptRoot'."
        $binariesPath = $PSScriptRoot
    }

    Write-Verbose "Importing binaries from '$binariesPath'..."
    Try
    {
        Import-Module  $(Join-Path -Path $binariesPath -ChildPath "Bin\ADSync\ADSync.psd1")
        Add-Type -Path $(Join-Path -Path $binariesPath -ChildPath "Bin\ADSync\Microsoft.Online.Coexistence.Schema.Ex.dll")
        Add-Type -Path $(Join-Path -Path $binariesPath -ChildPath "Bin\Assemblies\Microsoft.MetadirectoryServicesEx.dll")
        Add-Type -Path $(Join-Path -Path $binariesPath -ChildPath "Bin\Microsoft.MetadirectoryServices.PasswordHashSynchronization.Types.dll")
        Add-Type -Path $(Join-Path -Path $binariesPath -ChildPath "Extensions\Microsoft.Azure.ActiveDirectory.Connector.dll")
    }
    Catch
    {
        Throw  "Unable to import AADConnector binaries. Error Details: $($_.Exception.Message)"
    }
}


<#
.SYNOPSIS
    Gets Azure AD Connect installed folder location from the registry.
    To be used with [string]::IsNullOrEmpty($(Get-ADSyncToolsADsyncFolder))
#>

Function Get-ADSyncToolsADsyncFolder
{
    [CmdletBinding()]
    Param ()

    $path = ''
    $paramsRegKey = 'HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\ADSync\Parameters\'
    Try
    {
        Write-verbose "Reading AADConnect config from registry..."
        $adSyncReg = Get-ItemProperty -Path Registry::$paramsRegKey -ErrorAction Stop
    }
    Catch
    {
        Write-Verbose "Azure AD Connect path not found. Error Details: $($_.Exception.Message)"
    }

    If ($adSyncReg -ne $null)
    {
        $path = $adSyncReg.Path
    }

    # Returns the absolute path or an empty string if AADConnect is not found.
    Return $path
}


<#
.SYNOPSIS
    Checks if Microsoft Graph is connected
#>

Function Confirm-ADSyncToolsGraphConnection
{
    [CmdletBinding()]
    Param(
        [Parameter(Mandatory = $true,
                    Position = 0)]
        [String] $RequiredScope
    )
    
    Import-ADSyncToolsModule -ModuleName Microsoft.Graph.Authentication -InstallMessage $msGraphInstallMsg
                             
    $session = Get-MgContext
    Write-Verbose "Scopes: $($session.Scopes -join ',')"
    if ($null -eq $session)
    {
        # TODO: limit the Scopes to specific tools/scenarios
        Throw "Microsoft Graph authentication needed. Please call: Connect-MgGraph -Scopes '$RequiredScope'"
    }
    else 
    {
        # Optimizatoin Note: make scope evaluation smarter (resource/operation/contraint)
        # I.e., if Scopes already contains "User.ReadWrite.All", then a requesting a scope of "User.Read.Create", shouldn't ask to Connect Graph again for this specific scope.
        # More information: https://learn.microsoft.com/en-us/graph/permissions-overview
        $reqScopesList = $RequiredScope.Replace(" ","") -split ','
        ForEach ($scope in $reqScopesList)
        {
            If ($session.Scopes -notcontains $scope)
            {
                Throw "Microsoft Graph authentication needed. Please call: `nConnect-MgGraph -Scopes '$RequiredScope'"
            }
            Write-Verbose "$scope scope is present."
        }
    }
}


<#
.SYNOPSIS
    Checks if Azure AD Connect is present and if it have a min/max version.
#>

Function IsAADConnectPresent
{
    [CmdletBinding()]
    Param 
    (
        [Parameter(Mandatory = $false,
            HelpMessage = 'Minimum accepted version',
            Position = 0)]
        [System.Version] $MinVersion,

        [Parameter(Mandatory = $false,
            HelpMessage = 'Maximum accepted version',
            Position = 0)]
        [System.Version] $MaxVersion
    )

    $adSyncFolder = Get-ADSyncToolsADsyncFolder
    If ([string]::IsNullOrEmpty($adSyncFolder))
    {
        Throw "Entra Connect installation not found."
    }
    
    [string] $miiserverPath = $adSyncFolder + 'Bin\miiserver.exe'
    Write-Verbose "Miiserver.exe absolute path is '$miiserverPath'"

    Try
    {
        $miiserver = Get-ItemProperty -Path $miiserverPath -ErrorAction Stop
        [System.Version] $miiserverVersion = $miiserver.VersionInfo.FileVersion
    }
    Catch
    {
        Throw "Azure AD Connect version not found. Error Details: $($_.Exception.Message)"
    }
    
    Write-Verbose "Current Azure AD Connect version is '$miiserverVersion'"

    If (-not [string]::IsNullOrEmpty($MinVersion))
    {
        Write-Verbose "MinVersion: $MinVersion - Check if current version ($miiserverVersion) >= version $MinVersion"
        If (-not ($miiserverVersion -ge $MinVersion))
        {
            Throw "Function not supported in Azure AD Connect version '$miiserverVersion'. Minimum version to run this function is '$MinVersion'."
        }

    }
    ElseIf (-not [string]::IsNullOrEmpty($MaxVersion))
    {
        Write-Verbose "MaxVersion: $MaxVersion - Check if current version ($miiserverVersion) <= $MaxVersion version"
        If (-not ($miiserverVersion -le $MaxVersion))
        {
            Throw "Function not supported in Azure AD Connect version '$miiserverVersion'. Oldest version to run this function is '$MaxVersion'."
        }
    }
    Else
    {
        If (-not ($miiserverVersion -gt "1.0.0.0"))
        {
            Throw "Function not supported in Azure AD Connect version '$miiserverVersion'. Minimum version to run this function is '1.0.0.0'."
        }
    }
}


<#
.SYNOPSIS
    Checks for PowerShell version 7
#>

Function Confirm-ADSyncToolsPowerShellV7
{
    $version = $PSVersionTable.PSVersion
    if ($version.Major -lt 7) 
    {
        Write-Warning "PowerShell version 7 or later is required for some functions to work. Please upgrade your PowerShell version."
    }
}


<#
.SYNOPSIS
    Checks if PowerShell session is running with Administrator privileges
#>

Function IsPowerShellSessionElevated
{
    [CmdletBinding()]
    Param ()

    If (([Security.Principal.WindowsPrincipal] `
        [Security.Principal.WindowsIdentity]::GetCurrent() `
    ).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) -eq $false)
    {
        Throw "To use this function you need Administrator privileges. Please start PowerShell with 'Run As Administrator'."
    }
}


Function InstallModuleDepedency
{
    [CmdletBinding()]
    Param
    (
        # UserprincipalName
        [Parameter(Mandatory=$True, Position=0)] 
        [string]
        $ModuleName
    )
    
    $module = Get-InstalledModule $ModuleName -ErrorAction Ignore
    If ($null -eq $module)
    {
        Write-Host "Installing '$ModuleName' Module. Please wait..." -ForegroundColor Cyan
        Try
        {    
            Install-Module $ModuleName -Force -ErrorAction Stop
        }
        Catch
        {
            Throw "There was a problem installing '$ModuleName' Module. Error Details: $($_.Exception.Message)"
        }
    }
}

<#
.SYNOPSIS
    Installs all PowerShell depedencies
#>

Function Install-ADSyncToolsPrerequisites
{
    [CmdletBinding()]
    Param ()

    IsPowerShellSessionElevated

    # PowerShellGet Module
    $powerShellGetModule = @(Get-Module PowerShellGet -ListAvailable)
    $powerShellGetInstalled = $false
    [System.Version] $minVersion = "2.2.4.1"
    ForEach ($m in $powerShellGetModule)
    {
        Write-Verbose "PowerShellGet current version: $($m.Version) | PowerShellGet minimum version: $minVersion"
        If ($m.Version -ge $minVersion)
        {
            $powerShellGetInstalled = $true
            Write-Verbose "PowerShellGet module is already installed."
        }
    }

    If (-not $powerShellGetInstalled)
    {
        Write-Host "Installing 'PowerShellGet' Module. Please wait..." -ForegroundColor Cyan
        Try
        {
            [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
            Install-Module PowerShellGet -Force -ErrorAction Stop
        }
        Catch
        {
            Throw "There was a problem installing 'PowerShellGet' Module. Error Details: $($_.Exception.Message)"
        }
    }

    # RSAT Tools
    Try
    {

        $winFeature = Get-WindowsFeature RSAT-AD-Tools
    }
    Catch
    {
        Throw "There was a problem checking Windows Features. Error Details: $($_.Exception.Message)"
    }

    If (-not $winFeature.Installed)
    {
        Write-Host "Installing Windows Feature 'RSAT-AD-Tools'. Please wait..." -ForegroundColor Cyan
        Try
        {
            Install-WindowsFeature RSAT-AD-Tools -ErrorAction Stop
        }
        Catch
        {
            Throw "There was a problem installing Windows Feature 'RSAT-AD-Tools'. Error Details: $($_.Exception.Message)"
        }
    }
    
    # MSOnline module
    InstallModuleDepedency -ModuleName MSOnline

    # AzureAD module
    InstallModuleDepedency -ModuleName AzureAD
}

<#
.SYNOPSIS
    Connects ADSyncTools Module to Azure AD and Exchange Online
#>

Function Connect-ADSyncTools
{
    [CmdletBinding()]
    Param 
    (
        [Parameter(Mandatory = $false,
            #ParameterSetName = 'Username',
            HelpMessage = 'Enter Azure AD Global Administrator username',
            Position = 0)]
        [String] $UserName,

        [Parameter(Mandatory = $false,
            ParameterSetName = 'PSCredential',
            HelpMessage = 'Enter Azure AD Global Administrator credential',
            Position = 0)]
        [PSCredential] $Credential
    )
        
    If (-not [string]::IsNullOrEmpty($UserName))
    {
        $UserCredential = Get-Credential -UserName $UserName -Message 'Global Administrator sign-in:'
    }
    ElseIf ($Credential)
    {
        $UserCredential = $Credential
    }
    Else
    {
        Write-Verbose "No UserName, no Credential, prompting for Credentials..."
        $UserCredential = Get-Credential -Message 'Global Administrator sign-in:'
    }
    
    If ($null -eq $UserCredential)
    {
        Write-Warning "Global Administrator credential not provided, you'll be prompted multiple times for authentication. Do you want to continue?" -WarningAction Inquire
    }

    Write-Host "`nConnecting to Exchange Online..." -ForegroundColor Cyan
    $Session = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri https://outlook.office365.com/powershell-liveid/ -Credential $UserCredential -Authentication Basic -AllowRedirection
    Import-PSSession $Session -DisableNameChecking

    Write-Host "`nConnecting MSOnline Module..." -ForegroundColor Cyan
    Import-Module MSOnline
    Connect-MsolService -Credential $UserCredential
    
    Write-Host "`nConnecting AzureAD Module..." -ForegroundColor Cyan
    Import-Module AzureAD
    Connect-AzureAD -Credential $UserCredential
}

<#
.SYNOPSIS
    Generates a new file name path based on current time
#>

Function Get-ADsyncToolsLogFilename
{
    [CmdletBinding()]
    Param
    (
        # Name
        [Parameter(Mandatory=$true, 
                   Position=0,
                   ValueFromPipelineByPropertyName=$true)]
        [string] 
        $Name,

        # Extension
        [Parameter(Mandatory=$false, 
                   Position=1)]
        [string] 
        $Extension = 'log'


    )
        # Generate a new filename
        $prefix = "ADSyncTools-$Name"
        $currentTime = Get-Date -Format yyyyMMdd-HHmmss
        $location = Get-Location
        $filename = Join-Path -Path $location -ChildPath "$($prefix)_$currentTime.$Extension"
        Return $filename
}


<#
.SYNOPSIS
    Gets the tenant name from a user's UPN suffix
#>

Function Get-ADSyncToolsUPNsuffix
{
    [CmdletBinding()]
    Param
    (
        # UserprincipalName
        [Parameter(Mandatory=$True, Position=0)] 
        [string]
        $UserPrincipalName
    )

    # Get tenant name from user's upn suffix
    $tenant = $UserPrincipalName.Split('@')[1]
    If ([string]::IsNullOrEmpty($tenant))
    {
        Throw "Invalid Azure AD user name. Please provide the user name in UserPrincipalName format (user@contoso.com)."
    }
    Return $tenant
}


<#
.SYNOPSIS
   Helper function to get which Azure environment the user belongs.
.DESCRIPTION
    This function will call Oauth discovery endpoint to get CloudInstance and
    tenant_region_scope to determine the Azure environment.
    https://login.microsoftonline.com/{tenant}/.well-known/openid-configuration
 
    cloud_instance_name azure_environment
    =================== =================
    microsoftonline.de AzureGermanyCloud
    chinacloudapi.cn AzureChinaCloud
    microsoftonline.com AzureCloud/USGovernment
 
    tenant_region_scope azure_environment
    =================== =================
    USG USGovernment
.EXAMPLE
   Get-ADSyncToolsTenantAzureEnvironment -Credential (Get-Credential)
.INPUTS
   The user's PS Credential object
.OUTPUTS
   The Azure environment (string)
#>

Function Get-ADSyncToolsTenantAzureEnvironment
{
    [CmdletBinding()]
    Param
    (
        # The user's PS Credential object
        [Parameter(Mandatory=$True, Position=0)] 
        [System.Management.Automation.PSCredential] 
        $Credential
    )

    # Set default Azure environment to public
    $environment = "AzureCloud"

    # Get tenant name from user's upn suffix
    $tenant = Get-ADSyncToolsUPNsuffix -UserPrincipalName $Credential.UserName

    # Oauth discovery endpoint
    $url = "https://login.microsoftonline.com/$tenant/.well-known/openid-configuration"

    # Get CloudInstance from Oauth discovery endpoint
    try
    {
        $response = Invoke-RestMethod -Uri $url -Method Get
    }
    catch [Exception]
    {
        # We failed, but that is possible if the tenant is in PPE. So check against PPE before failing.
        try
        {
            # Oauth discovery endpoint for PPE
            $url = "https://login.windows-ppe.net/$tenant/.well-known/openid-configuration"

            $response = Invoke-RestMethod -Uri $url -Method Get
        }
        catch [Exception]
        {
            Write-Output "$_.Exception.Message"
            Write-Output "[ERROR]`t OAuth2 discovery failed. Please contact system administrator for more information."
            break
        }
    }

    # Determine AzureEnvironment from tenant_region_scope and cloud_instance_name
    if ($response.tenant_region_scope.ToLower().equals("usg"))
    {
        $environment = "USGovernment"
    }
    elseif ($response.cloud_instance_name.ToLower().equals("chinacloudapi.cn"))
    {
        $environment = "AzureChinaCloud"
    }
    elseif ($response.cloud_instance_name.ToLower().equals("microsoftonline.de"))
    {
        $environment = "AzureGermanyCloud"
    }
    elseif ($response.cloud_instance_name.ToLower().equals("windows-ppe.net"))
    {
        $environment = "AzurePPE"
    }

    return $environment
}


<#
.SYNOPSIS
    Encodes reserved characters in DistinguishedName to Hexadecimal values based on MSDN documentation:
    The following table lists reserved characters that cannot be used in an attribute value without being escaped.
    From: https://msdn.microsoft.com/en-us/windows/desktop/aa366101
#>

Function Format-ADSyncToolsDistinguishedName
{
    [CmdletBinding()]
    Param
    (
        [Parameter(Mandatory=$True,
                   Position=0)] 
        [String] 
        $DistinguishedName
    )

    $escapedDN = $DistinguishedName -replace '\\#','\23' `
                                    -replace '\\,','\2C' `
                                    -replace '\\"','\22' `
                                    -replace '\\<','\3C' `
                                    -replace '\\>','\3E' `
                                    -replace '\\;','\3B' `
                                    -replace '\\=','\3D' `
                                    -replace '\\/','\2F'
    Return $escapedDN
}


<#
.Synopsis
   Gets a domain controller in the Forest for a given DistinguishedName.
.DESCRIPTION
   Returns one target Domain Controller
#>

Function Get-ADSyncToolsADtargetDC
{
    [CmdletBinding()]
    Param 
    (
        # Domain Controller type
        [Parameter(Mandatory = $true, Position = 0)]
        [ValidateSet('GlobalCatalog', 'Readable', 'Writable')]
        $Service,
                
        # Target AD Domain
        [Parameter(Mandatory=$false, Position=1)]
        [ValidateNotNullOrEmpty()]
        $DomainName
    )
    
    If ($Service -ne 'GlobalCatalog' -and ($DomainName -eq "" -or $DomainName -eq $null))
    {
        Throw "A DomainName in FQDN format must be provided to get a $Service Domain Controller"
    }

    Import-ADSyncToolsModule -ModuleName ActiveDirectory -InstallMessage $addsInstallMsg

    switch ($Service)
    {
        'GlobalCatalog' 
        {
            # Find a target Global Catalog Domain Controller
            Try
            {
                [string] $domainCtrlName = (Get-ADDomainController -Discover -Service GlobalCatalog -ErrorAction Stop).HostName | select -First 1
                Write-Verbose "Target DC (Global Catalog): $domainCtrlName"
            }
            Catch
            {
                Throw "Cannot find a $Service Domain Controller: $($_.Exception.Message)"
            }
        }
        'Readable' 
        {
            # Find a target Readable Domain Controller for AD domain
            Try
            {
                [string] $domainCtrlName = (Get-ADDomainController -Discover -DomainName $DomainName -ErrorAction Stop).HostName | select -First 1
                Write-Verbose "Target DC for Domain '$DomainName': $domainCtrlName"
            }
            Catch
            {
                Throw "Cannot find a $Service Domain Controller: $($_.Exception.Message)"
            }            
        }
        'Writable' 
        {
            # Find a target Writable Domain Controller for AD domain
            Try
            {
                [string] $domainCtrlName = (Get-ADDomainController -Discover -DomainName $DomainName -Writable -ErrorAction Stop).HostName | select -First 1
                Write-Verbose "Target DC for Domain '$DomainName': $domainCtrlName"
            }
            Catch
            {
                Throw "Cannot find a $Service Domain Controller: $($_.Exception.Message)"
            }            
        }
    }

    If ($domainCtrlName -eq "" -or $domainCtrlName -eq $null)
    {
        Throw "Unable to find a Domain Controller."
    }

    Return $domainCtrlName
}


<#
.Synopsis
   Get Active Directory Domain DistinguishedName
.DESCRIPTION
   Returns the DistinguishedName of the Active Directory Domain for a given Active Directory object.
#>

Function Get-ADSyncToolsDomainDN
{
    [CmdletBinding()]
    Param 
    (
        # Target User in AD to set ConsistencyGuid
        [Parameter(Mandatory = $true, Position = 0)]
        [ValidateNotNullOrEmpty()]
        $DistinguishedName
    )

    # Get the Domain portion of the object's DN
    Try
    {
        $domainDN =  $DistinguishedName.Substring($DistinguishedName.IndexOf('DC='))
        Write-Verbose "Object's Domain DN: $domainDN"
    }
    Catch
    {
        Throw "DistinguishedName '$DistinguishedName' is invalid."
    }

    Return $domainDN
}


<#
.Synopsis
   Get Active Directory Domain FQDN from DistinguishedName
.DESCRIPTION
   Returns the respective FQDN of the Active Directory Domain for a given Active Directory object.
#>

Function Get-ADSyncToolsDomainDns
{
    [CmdletBinding()]
    Param 
    (
        # Target User in AD to set ConsistencyGuid
        [Parameter(Mandatory = $true, Position = 0)]
        [ValidateNotNullOrEmpty()]
        $DistinguishedName
    )

    Import-ADSyncToolsModule -ModuleName ActiveDirectory -InstallMessage $addsInstallMsg
    
    $domainDN = Get-ADSyncToolsDomainDN $DistinguishedName

    # Get the Domains in the Forest
    $domains = @((Get-ADForest).Domains | %{Get-ADDomain -Identity $_})
    Write-Verbose "Domains in AD Forest: $($domains | Select -ExpandProperty DistinguishedName)"

    # Select the object's domain
    $domain = $domains | Where-Object {$_.DistinguishedName -eq $domainDN}
    Write-Verbose "Domain FQDN: $($domain.DNSRoot)"
    If ($domain -eq $null)
    {
        Throw "Cannot find Domain for object '$DistinguishedName'."
    }

    Return $domain.DNSRoot
}

#endregion
#=======================================================================================


#=======================================================================================
#region Troubleshooting Functions
#=======================================================================================

<#
.Synopsis
   Search an Active Directory object in Active Directory Forest by its UserPrincipalName, sAMAccountName or DistinguishedName
.DESCRIPTION
   Supports multi-domain queries and returns all the required properties including mS-DS-ConsistencyGuid.
.EXAMPLE
   Search-ADSyncToolsADobject 'CN=user1,OU=Sync,DC=Contoso,DC=com'
.EXAMPLE
   Search-ADSyncToolsADobject -Identity "user1@Contoso.com"
.EXAMPLE
   Get-ADUser 'CN=user1,OU=Sync,DC=Contoso,DC=com' | Search-ADSyncToolsADobject
#>

Function Search-ADSyncToolsADobject
{
    [CmdletBinding()]
    Param 
    (
        # Target User identity to search in AD
        [Parameter(Mandatory=$true,
                   Position=0,
                   ValueFromPipeline=$true,
                   ValueFromPipelineByPropertyName=$true)]
        [ValidateNotNullOrEmpty()]
        $Identity,

        # Properties to retrieve from AD
        [Parameter(Mandatory=$false,
                   Position=1)]
        [string[]] 
        $Properties = @()
    )

    Import-ADSyncToolsModule -ModuleName ActiveDirectory -InstallMessage $addsInstallMsg

    [string[]] $Properties = $defaultADobjProperties + @($Properties)

    Try
    {
        If ($Identity.GetType()  -like "Microsoft.ActiveDirectory.Management*")
        {
            # User input is AD object, but it might not contain mS-DS-ConsistencyGuid property
            Write-Verbose "Identity input is an AD object - Getting AD user '$Identity' with required properties"
            $seachResult = Search-ADSyncToolsADobjectByDN $Identity.DistinguishedName -Properties $Properties
        }
        Else
        {
            If ($Identity -match $upnRegex)
            {
                # User input is in UPN format
                Write-Verbose "Identity input is an UserPrincipalName - Getting AD user '$Identity' with required properties '$Properties'"
                
                $seachResult = Search-ADSyncToolsADobjectByUPN -UserPrincipalName $Identity -Properties $Properties
            }
            Else
            {
                $escapedDN = Format-ADSyncToolsDistinguishedName -DistinguishedName $Identity
                If ($escapedDN -match $distinguishedNameRegex)
                {
                    # User input is in DistinguishedName format
                    Write-Verbose "Identity input is an DistinguishedName - Getting AD user '$Identity' with required properties"

                    $seachResult = Search-ADSyncToolsADobjectByDN -DistinguishedName $Identity -Properties $Properties
                }
                Else
                {
                    # Unknown format, try to seach on sAMAccountName on local domain
                    Write-Verbose "Identity input is a string - Searching for sAMAccountName '$Identity' on current AD Domain only"
                    $seachResult = Get-ADObject -Filter 'sAMAccountName -eq $Identity' -Properties $Properties -ErrorAction Stop
                    Write-Warning "Searching for sAMAccountName '$Identity' is limited the current AD Domain only. Please use a DistinguishedName or UserPrincipalName to search for objects across the entire AD Forest."
                }
            }
        }
    }
    Catch
    {
        Throw "Unable to search in Active Directory: $($_.Exception.Message)"
    }

    If ($seachResult)
    {
        # Create Custom Object to hold all the required properties
        $result = $seachResult | Select $Properties
        
        <#
        $result.Name = $seachResult.Name
        $result.ObjectClass = $seachResult.ObjectClass
        $result.DistinguishedName = $seachResult.DistinguishedName
        $result.ObjectGUID = $seachResult.ObjectGUID
        $result.ObjectSID = $seachResult.ObjectSID
        $result.sAMAccountName = $seachResult.sAMAccountName
        $result.UserPrincipalName = $seachResult.UserPrincipalName
        #>


        # Add mS-DS-ConsistencyGuid in Guid-string format
        If ($null -ne $seachResult.'mS-DS-ConsistencyGuid')
        {
            Try
            {
                $result.'mS-DS-ConsistencyGuid' = [Guid] $seachResult.'mS-DS-ConsistencyGuid'
            }
            Catch
            {
                Write-Error "Unable to convert mS-DS-ConsistencyGuid to GUID: $($_.Exception.Message)"
            }
        }
        Else
        {
            Write-Verbose "Object '$($result.Name)' does not have a mS-DS-ConsistencyGuid value"
        }
    }
    Else
    {
        Throw "Unable to find object in Active Directory."
    }
    Return $result
}


<#
.Synopsis
   Find an Active Directory object in the Forest by its DistinguishedName.
.DESCRIPTION
   Supports multi-domain queries and returns all the required properties including mS-DS-ConsistencyGuid.
   DistinguishedName value must be validated by the caller
#>

Function Search-ADSyncToolsADobjectByDN
{
    [CmdletBinding()]
    Param 
    (
        # Target DistinguishedName to search
        [Parameter(Mandatory=$true,
                   Position=0,
                   ValueFromPipeline=$true,
                   ValueFromPipelineByPropertyName=$true)]
        [ValidateNotNullOrEmpty()]
        $DistinguishedName,

        # Properties to retrieve from AD
        [Parameter(Mandatory=$true,
                   Position=1)]
        $Properties        
    )

    Import-ADSyncToolsModule -ModuleName ActiveDirectory -InstallMessage $addsInstallMsg

    $domainDNS = Get-ADSyncToolsDomainDns -DistinguishedName $DistinguishedName
    $targetDC = Get-ADSyncToolsADtargetDC -Service Readable -DomainName $domainDNS
    $domainDN = Get-ADSyncToolsDomainDN -DistinguishedName $DistinguishedName

    # Get the AD object from target DC
    Write-Verbose "Executing: Get-ADObject -Filter `"distinguishedName -eq '$DistinguishedName'`" -Properties $Properties -SearchBase $domainDN -SearchScope Subtree -Server $targetDC"           
    Try
    {
        $seachResult = Get-ADObject -Filter "distinguishedName -eq '$DistinguishedName'" -Properties $Properties -SearchBase $domainDN -SearchScope Subtree -Server $targetDC  -ErrorAction Stop
    }
    Catch
    {
        Throw "Cannot find user '$DistinguishedName': $($_.Exception.Message)"
    }

    Return $seachResult
}


<#
.Synopsis
   Find an Active Directory object in the Forest by its UserPrincipalName.
.DESCRIPTION
   Supports multi-domain queries and returns all the required properties including mS-DS-ConsistencyGuid.
#>

Function Search-ADSyncToolsADobjectByUPN
{
    [CmdletBinding()]
    Param 
    (
        # Target UserPrincipalName to search
        [Parameter(Mandatory=$true,
                   Position=0,
                   ValueFromPipeline=$true,
                   ValueFromPipelineByPropertyName=$true)]
        [ValidateNotNullOrEmpty()]
        $UserPrincipalName,

        # Properties to retrieve from AD
        [Parameter(Mandatory=$true,
                   Position=1)]
        [string[]] 
        $Properties        

    )

    Import-ADSyncToolsModule -ModuleName ActiveDirectory -InstallMessage $addsInstallMsg

    $targetDC = Get-ADSyncToolsADtargetDC -Service GlobalCatalog
    
    # Get the AD object from target DC
    Write-Verbose "Executing: Get-ADObject -Filter `"UserPrincipalName -eq '$UserPrincipalName'`" -Properties `"$Properties`" -Server `"$($targetDC):3268`""
    Try
    {
        $globalCatalogObj = Get-ADObject -Filter "UserPrincipalName -eq '$UserPrincipalName'" -Properties $Properties -Server "$($targetDC):3268" -ErrorAction Stop
    }
    Catch
    {
        Throw "Cannot find user '$UserPrincipalName': $($_.Exception.Message)"
    }

    # Get all the required properties of the object including mS-DS-ConsistencyGuid
    If ($globalCatalogObj)
    {
        $seachResult = Search-ADSyncToolsADobjectByDN $globalCatalogObj.DistinguishedName -Properties $Properties
    }    

    Return $seachResult
}


<#
.Synopsis
   Sets an object's attribute in Active Directory Forest
.DESCRIPTION
   Supports multi-domain queries
.EXAMPLE
   Set-ADSyncToolsADobject -ADObject <ADObject> -AttributeName 'Mobile' -AttributeValue '09998887'
.EXAMPLE
   Set-ADSyncToolsADobject -ADObject <ADObject> -OtherMobile '0987654','1234567' -Server DC1.Contoso.com -Credential <PSCredential>
#>

Function Set-ADSyncToolsADobject
{
    [CmdletBinding()]
    Param 
    (
        # Target object in AD to update
        [Parameter(Mandatory=$true,
                   Position=0)]
        $ADObject,

        # Attribute Name
        [Parameter(Mandatory=$true,
                   Position=1)]
        [string] 
        $AttributeName,

        # Attribute Value
        [Parameter(Mandatory=$true,
                   Position=2)]
        $AttributeValue,

        # Credential for target AD Domain
        [Parameter(Mandatory=$false,
                   Position=3)]
        [pscredential]
        $Credential,

        # Server
        [Parameter(Mandatory=$false,
                   Position=4)]
        [string] 
        $Server
    )
    
    $oldValue = $ADObject.$AttributeName

    If ([string]::IsNullOrEmpty($Server))
    {
        # Get the target Writable DC
        $domainDNS = Get-ADSyncToolsDomainDns -DistinguishedName $ADObject.DistinguishedName
        $targetDC = Get-ADSyncToolsADtargetDC -Service Writable -DomainName $domainDNS
    }
    Else
    {
        $targetDC = $Server
    }

    Try
    {
        # Set new value on target object in AD
        #If ($Credential.UserName -eq 'ADCredentialNotProvided')
        If ($null -eq $Credential)
        {
            Set-ADObject -Identity $ADObject.DistinguishedName -Replace @{$AttributeName=$AttributeValue} -Server $targetDC
        }
        Else
        {
            Write-Verbose "Using Credential '$($Credential.UserName)'"
            Set-ADObject -Identity $ADObject.DistinguishedName -Replace @{$AttributeName=$AttributeValue} -Server $targetDC -Credential $Credential
        }
        Write-Verbose "Attribute '$AttributeName' updated from '$oldValue' to '$AttributeValue' in '$($ADObject.DistinguishedName)' object successfully"
    }
    Catch
    {
        # Unable to update user
        Throw "Unable to update '$AttributeName' with '$AttributeValue' in '$($ADObject.DistinguishedName)' object: $($_.Exception.Message)"
    }
}



<#
.Synopsis
   Clears an object's attribute in Active Directory Forest
.DESCRIPTION
   Supports multi-domain queries
.EXAMPLE
   Clear-ADSyncToolsADobject -ADObject <ADObject> -AttributeName 'Mobile'
.EXAMPLE
   Clear-ADSyncToolsADobject -ADObject <ADObject> -OtherMobile -Server DC1.Contoso.com -Credential <PSCredential>
#>

Function Clear-ADSyncToolsADobject
{
    [CmdletBinding()]
    Param 
    (
        # Target object in AD to update
        [Parameter(Mandatory=$true,
                   Position=0)]
        $ADObject,

        # Attribute Name
        [Parameter(Mandatory=$true,
                   Position=1)]
        [string] 
        $AttributeName,

        # Credential for target AD Domain
        [Parameter(Mandatory=$false,
                   Position=2)]
        [pscredential]
        $Credential,

        # Server
        [Parameter(Mandatory=$false,
                   Position=3)]
        [string] 
        $Server
    )
    
    $oldValue = $ADObject.$AttributeName

    # Get target DC for updating user
    If ([string]::IsNullOrEmpty($Server))
    {
        # Get the target Writable DC
        $domainDNS = Get-ADSyncToolsDomainDns -DistinguishedName $ADObject.DistinguishedName
        $targetDC = Get-ADSyncToolsADtargetDC -Service Writable -DomainName $domainDNS
    }
    Else
    {
        $targetDC = $Server
    }
    Write-Verbose "Using target DC: '$targetDC'"

    Try
    {
        # Set new value on target object in AD
        #If ($Credential.UserName -eq 'ADCredentialNotProvided')
        If ($null -eq $Credential)
        {
            Write-Verbose "No credential provided, using current user."
            Set-ADObject -Identity $ADObject.DistinguishedName -Clear $AttributeName -Server $targetDC
        }
        Else
        {
            Write-Verbose "Using Credential '$($Credential.UserName)'"
            Set-ADObject -Identity $ADObject.DistinguishedName -Clear $AttributeName -Server $targetDC -Credential $Credential
        }
        Write-Verbose "Attribute '$AttributeName' cleared in '$($ADObject.DistinguishedName)' object successfully"
    }
    Catch
    {
        # Unable to update user
        Throw "Unable to clear '$AttributeName' in '$($ADObject.DistinguishedName)' object: $($_.Exception.Message)"
    }
}


<#
.Synopsis
   Get an Active Directory object ms-ds-ConsistencyGuid
.DESCRIPTION
   Returns the value in mS-DS-ConsistencyGuid attribute of the target Active Directory object in GUID format.
   Supports Active Directory objects in multi-domain forests.
.EXAMPLE
   Get-ADSyncToolsMsDsConsistencyGuid -Identity 'CN=User1,OU=Sync,DC=Contoso,DC=com'
.EXAMPLE
   Get-ADSyncToolsMsDsConsistencyGuid -Identity 'User1@Contoso.com'
.EXAMPLE
   'User1@Contoso.com' | Get-ADSyncToolsMsDsConsistencyGuid
#>

Function Get-ADSyncToolsMsDsConsistencyGuid
{
    [CmdletBinding()]
    Param 
    (
        # Target object in AD to get
        [Parameter(Mandatory=$true,
                   Position=0,
                   ValueFromPipeline=$true,
                   ValueFromPipelineByPropertyName=$true)]
        [ValidateNotNullOrEmpty()]
        $Identity
    )
    
    # Get object from AD
    $adObject = Search-ADSyncToolsADobject -Identity $Identity

    # Get mS-DS-ConsistencyGuid value
    Write-Verbose "Object '$($adObject.Name)' | mS-DS-ConsistencyGuid: $($adObject.'mS-DS-ConsistencyGuid')"

    Return $adObject.'mS-DS-ConsistencyGuid'
}



<#
.Synopsis
   Set an Active Directory object ms-ds-ConsistencyGuid
.DESCRIPTION
   Sets a value in mS-DS-ConsistencyGuid attribute for the target Active Directory user.
   Supports Active Directory objects in multi-domain forests.
.EXAMPLE
   Set-ADSyncToolsMsDsConsistencyGuid -Identity 'CN=User1,OU=Sync,DC=Contoso,DC=com' -Value '88666888-0101-1111-bbbb-1234567890ab'
.EXAMPLE
   Set-ADSyncToolsMsDsConsistencyGuid -Identity 'CN=User1,OU=Sync,DC=Contoso,DC=com' -Value 'GGhsjYwBEU+buBsE4sqhtg=='
.EXAMPLE
   Set-ADSyncToolsMsDsConsistencyGuid 'User1@Contoso.com' '8d6c6818-018c-4f11-9bb8-1b04e2caa1b6'
.EXAMPLE
   Set-ADSyncToolsMsDsConsistencyGuid 'User1@Contoso.com' 'GGhsjYwBEU+buBsE4sqhtg=='
.EXAMPLE
   '88666888-0101-1111-bbbb-1234567890ab' | Set-ADSyncToolsMsDsConsistencyGuid -Identity User1
.EXAMPLE
   'GGhsjYwBEU+buBsE4sqhtg==' | Set-ADSyncToolsMsDsConsistencyGuid User1
 
#>

Function Set-ADSyncToolsMsDsConsistencyGuid
{
    [CmdletBinding()]
    Param 
    (
        # Target object in AD to set mS-DS-ConsistencyGuid
        [Parameter(Mandatory=$true,
                   Position=0,
                   ValueFromPipeline=$true,
                   ValueFromPipelineByPropertyName=$true)]
        [ValidateNotNullOrEmpty()]
        $Identity,

        # Value to set (ImmutableId, Byte array, GUID, GUID string or Base64 string)
        [Parameter(Mandatory=$true,
                   Position=1,
                   ValueFromPipeline=$true,
                   ValueFromPipelineByPropertyName=$true)]
        $Value,

        # Credential to set target object
        [Parameter(Mandatory=$false,
                   Position=2)]
        [pscredential]
        $Credential
    )
    
    # Parse ConsistencyGuid value to set
    Switch ($Value.GetType().Name)
    {
        'Guid'     # Value is a GUID
        {
            $keepTrying = $false
            $targetValue = $Value
        }
        'String'   # Value is a string, either a GUID string or a Based64 encoded GUID
        {   
            $keepTrying = $false
            Try
            {
                # Try to decode from Base64
                $targetValueDecoded = [system.convert]::FromBase64String($Value)
                Write-Verbose "Value converted from Base64 string"
            }
            Catch
            {
                # Could not convert from Base64
                $keepTrying = $true
                Write-Verbose "Value cannot be converted from Base64 string"
            }

            if (-not $keepTrying)
            {
                # Value decoded from Base64 successfully
                Try
                {
                    # Try to convert to GUID
                    $targetValue = [GUID] $targetValueDecoded
                    Write-Verbose "Value converted from decoded Base64 string"
                }
                Catch
                {
                    # Fatal, could not convert Base64 to GUID
                    Throw "$Value is not recognized as a valid GUID value: $($_.Exception.Message)"
                }
            }
        }
        Default 
        {
            $keepTrying = $true
        }
    }
    
    # Continue parsing ConsistencyGuid value
    If ($keepTrying)
    {
        # Still not a GUID value
        Write-Verbose "Still trying to convert Value to GUID. - Value Type = $($Value.getType())"
        Try
        {
            # Try to convert to GUID directy
            $targetValue = [GUID] $Value
            Write-Verbose "Converted mS-DS-ConsistencyGuid value successfully: $targetValue"
        }
        Catch
        {
            # Fatal, could not convert from Base64
            Throw "'$Value' is not recognized as a valid GUID: $($_.Exception.Message)"
        }
    }

    # Get the target object from AD
    $adObject = Search-ADSyncToolsADobject -Identity $Identity
    Write-Verbose "Found Object '$($adObject.Name)' | mS-DS-ConsistencyGuid: $($adObject.'mS-DS-ConsistencyGuid')"

    # Get the target Writable DC
    $domainDNS = Get-ADSyncToolsDomainDns -DistinguishedName $adObject.DistinguishedName
    $targetDC = Get-ADSyncToolsADtargetDC -Service Writable -DomainName $domainDNS

    Try
    {
        # Set the target mS-DS-ConsistencyGuid
        If ($Credential)
        {
            Set-ADObject -Identity $adObject.DistinguishedName -Replace @{'mS-DS-ConsistencyGuid'=$targetValue} -Server $targetDC -Credential $Credential
        }
        Else
        {
            Set-ADObject -Identity $adObject.DistinguishedName -Replace @{'mS-DS-ConsistencyGuid'=$targetValue} -Server $targetDC
        }
        Write-Verbose "New mS-DS-ConsistencyGuid set: $targetValue"
    }
    Catch
    {
        # Fatal, could not set user
        Throw "Unable to set mS-DS-ConsistencyGuid on '$($adObject.Name)': $($_.Exception.Message)"
    }
}


<#
.Synopsis
   Clear an Active Directory object mS-DS-ConsistencyGuid
.DESCRIPTION
   Clears the value in mS-DS-ConsistencyGuid for the target Active Directory object.
   Supports Active Directory objects in multi-domain forests.
.EXAMPLE
   Clear-ADSyncToolsMsDsConsistencyGuid -Identity 'CN=User1,OU=Sync,DC=Contoso,DC=com'
.EXAMPLE
   Clear-ADSyncToolsMsDsConsistencyGuid -Identity 'User1@Contoso.com'
.EXAMPLE
   'User1@Contoso.com' | Clear-ADSyncToolsMsDsConsistencyGuid
#>

Function Clear-ADSyncToolsMsDsConsistencyGuid
{
    [CmdletBinding()]
    Param 
    (
        # Target object in AD to clear mS-DS-ConsistencyGuid
        [Parameter(Mandatory=$true,
                   Position=0,
                   ValueFromPipeline=$true,
                   ValueFromPipelineByPropertyName=$true)]
        [ValidateNotNullOrEmpty()]
        $Identity
    )
    
    # Get target object from AD
    $adObject = Search-ADSyncToolsADobject -Identity $Identity
    Write-Verbose "Found Object '$($adObject.Name)' | mS-DS-ConsistencyGuid: $($adObject.'mS-DS-ConsistencyGuid')"

    # Get the target Writable DC
    $domainDNS = Get-ADSyncToolsDomainDns -DistinguishedName $adObject.DistinguishedName
    $targetDC = Get-ADSyncToolsADtargetDC -Service Writable -DomainName $domainDNS

    If ($adObject)
    {
        Set-ADObject -Identity $adObject.DistinguishedName -Clear 'mS-DS-ConsistencyGuid' -Server $targetDC
    }
}


<#
.Synopsis
   Convert Base64 ImmutableId (SourceAnchor) to GUID value
.DESCRIPTION
   Converts value of the ImmutableID from Base64 string and returns a GUID value
   In case Base64 string cannot be converted to GUID, returns a Byte Array.
.EXAMPLE
   ConvertFrom-ADSyncToolsImmutableID 'iGhmiAEBERG7uxI0VniQqw=='
.EXAMPLE
   'iGhmiAEBERG7uxI0VniQqw==' | ConvertFrom-ADSyncToolsImmutableID
#>

Function ConvertFrom-ADSyncToolsImmutableID
{
    [CmdletBinding()]
    Param 
    (
        # ImmutableId in Base64 format
        [Parameter(Mandatory=$true,
                   Position=0,
                   ValueFromPipeline=$true,
                   ValueFromPipelineByPropertyName=$true)]
        [ValidateNotNullOrEmpty()]
        [string] $Value
    )

    Try
    {
        # Try to decode from Base64
        $targetValueFromB64 = [system.convert]::FromBase64String($Value)
        Write-Verbose "Value converted from Base64 string."
    }
    Catch
    {
        # Could not convert from Base64
        Throw "Value '$Value' is not a valid Base64 string."
    }

    If ($targetValueFromB64 -ne $null)
    {
        Try
        {
            # Try to convert to GUID
            $targetValue = [GUID] $targetValueFromB64
            Write-Verbose "Value converted from Base64 string to Guid."
        }
        Catch
        {
            # Could not convert Base64 to GUID
            Write-Error "$Value cannot be converted to a GUID value: $($_.Exception.Message)"
            Write-Warning "Returning result as a byte array:"
            $targetValue = $targetValueFromB64
        }
    }    
    Return $targetValue
}


<#
.Synopsis
   Convert GUID (ObjectGUID / ms-Ds-Consistency-Guid) to a Base64 string
.DESCRIPTION
   Converts a value in GUID, GUID string or byte array format to a Base64 string
.EXAMPLE
   ConvertTo-ADSyncToolsImmutableID '88888888-0101-3333-cccc-1234567890cd'
.EXAMPLE
   '88888888-0101-3333-cccc-1234567890cd' | ConvertTo-ADSyncToolsImmutableID
#>

Function ConvertTo-ADSyncToolsImmutableID
{
    [CmdletBinding()]
    Param 
    (
        # GUID, GUID string or byte array
        [Parameter(Mandatory=$true,
                   Position=0,
                   ValueFromPipeline=$true,
                   ValueFromPipelineByPropertyName=$true)]
        $Value
    )

    # Value ValidateNotNullOrEmpty
    If ($Value -eq $null -or $Value -eq "")
    {
        Throw "ConvertTo-ADSyncToolsImmutableID : Cannot validate argument on parameter 'Value'. The argument is null or empty. Provide an argument that is not null or empty, and then try the command again."
    }

    # Convert Value to Byte Array
    Switch ($Value.GetType().Name)
    {
        'Guid'     
        {
            # Value is a GUID
            Write-Verbose "Input value is a GUID object. Converting to byte array..."
            Try
            {            
                $valueByteArray = $Value.ToByteArray()
            }
            Catch
            {
                # Failed convertion to byte array
                Throw "$Value is not recognized as a valid GUID: $($_.Exception.Message)"
            }
        }
        'String'   
        {   
            # Value is a GUID string
            Write-Verbose "Input value is a GUID string. Converting to byte array..."
            Try
            {
                $valueByteArray = $([GUID] $Value).ToByteArray()
                
            }
            Catch
            {
                # Failed convertion to byte array
                Throw "$Value is not recognized as a valid GUID: $($_.Exception.Message)"
            }

        }
        'Byte[]' 
        {
            # Value is a Byte Array
            Write-Verbose "Input value is a byte array. Convertion is not required."
            $valueByteArray = $Value
        }
        Default  
        {
            # Unknown format
            Throw "$Value is not recognized as a valid GUID."
        }
    }
    Return [system.convert]::ToBase64String($valueByteArray)
}


<#
.Synopsis
   Export Azure AD Connect Objects to XML files
.DESCRIPTION
   Exports internal ADSync objects from Metaverse and associated connected objects from Connector Spaces
.EXAMPLE
   Export-ADSyncToolsObjects -ObjectId '9D220D58-0700-E911-80C8-000D3A3614C0' -Source Metaverse
.EXAMPLE
   Export-ADSyncToolsObjects -ObjectId '9e220d58-0700-e911-80c8-000d3a3614c0' -Source ConnectorSpace
.EXAMPLE
   Export-ADSyncToolsObjects -DistinguishedName 'CN=User1,OU=ADSync,DC=Contoso,DC=com' -ConnectorName 'Contoso.com'
#>

Function Export-ADSyncToolsObjects
{
    [CmdletBinding()]
    Param
    (
        # ObjectId is the unique identifier of the object in the respective connector space or metaverse
        [Parameter(ParameterSetName='ObjectId',
                    Mandatory=$true,
                    ValueFromPipelineByPropertyName=$true,
                    Position=0)]
        $ObjectId,

        # Source is the table where the object resides which can be either ConnectorSpace or Metaverse
        [Parameter(ParameterSetName='ObjectId',
                    Mandatory=$true,
                    ValueFromPipelineByPropertyName=$true,
                    Position=1)]
        [ValidateSet('ConnectorSpace','Metaverse')]
        $Source,

        # DistinguishedName is the identifier of the object in the respective connector space
        [Parameter(ParameterSetName='DistinguishedName',
                    Mandatory=$true,
                    ValueFromPipelineByPropertyName=$true,
                    Position=0)]
        $DistinguishedName,

        # ConnectorName is the name of the connector space where the object resides
        [Parameter(ParameterSetName='DistinguishedName',
                    Mandatory=$true,
                    ValueFromPipelineByPropertyName=$true,
                    Position=1)]
        $ConnectorName,

        # ExportSerialized exports additional XML files
        [Parameter(Mandatory=$false,
                    Position=2)]
        [switch] 
        $ExportSerialized
    )

    # Function init
    IsAADConnectPresent -MinVersion '1.5.20.0'
    [string] $functionMsg = "Export-ADSyncToolsObjects :"
    [string] $paramSetName = $PSCmdlet.ParameterSetName
    [string] $dateStr = '.\' + (Get-Date).toString('yyyyMMdd-HHmmss') + '_'

    Write-Verbose "$functionMsg ParameterSetName: $paramSetName"

    if ($Source -eq 'Metaverse')
    {
        # Export objects based on metaverse ObjectId
        Export-ADSyncMVObject -MVObjectId $ObjectId -Prefix $dateStr -ExportSerialized $ExportSerialized
    }
    Else
    {
        Switch ($paramSetName)
        {
            'ObjectId' 
            {        
                # Find object based on connector space ObjectId
                $csObject = Get-ADSyncToolsMVObjFromCSID -CSObjectId $ObjectId
            }
            'DistinguishedName' 
            {
                # Find object based on distinguished name and connector name
                $csObject = Get-ADSyncToolsMVObjFromCSDN -DistinguishedName $DistinguishedName -ConnectorName $ConnectorName
            }
        }

        If ($csObject.ConnectedMVObjectId -eq '00000000-0000-0000-0000-000000000000')
        {
            # Object is a disconnector - Export CS object only
            Write-Verbose "$functionMsg Object is not connected to the Metaverse (Disconnector)."
            Export-ADsyncCSObject -CSObjectId $csObject.ObjectId -Prefix $dateStr -ExportSerialized $ExportSerialized
        }
        Else
        {
            # Export objects based on metaverse ObjectId
            Export-ADSyncMVObject -MVObjectId $csObject.ConnectedMVObjectId -Prefix $dateStr -ExportSerialized $ExportSerialized
        }
    }
}



<#
.Synopsis
   Import Azure AD Connect Object from XML file
.DESCRIPTION
   Imports an internal ADSync object from XML file that was exported using Export-ADSyncToolsObjects
.EXAMPLE
   Import-ADSyncToolsObjects -Path .\20210224-003104_81275a23-0168-eb11-80de-00155d188c11_MV.xml
#>

Function Import-ADSyncToolsObjects
{
    [CmdletBinding()]
    Param
    (
        # Path for the XML file to import
        [Parameter(Mandatory=$true,
                   Position=0,
                   ValueFromPipeline=$true,
                   ValueFromPipelineByPropertyName=$true)]
        [ValidateNotNullOrEmpty()]
        [string] $Path
    )

    If (Test-Path $Path)
    {
        Try
        {
            Import-Clixml $Path
        }
        Catch
        {
            Write-Error "Unable to import file '$Path'. Error Details: $($_.Exception.Message)"
        }
    }
    Else
    {
        Write-Error "File not found."
    }
}


<#
.SYNOPSIS
    Find object in Metaverse based on Connector Space ObjectId
#>

Function Get-ADSyncToolsMVObjFromCSID
{
    [CmdletBinding()]
    Param
    (
        # CSObjectId is the Id of the object in the respective Connector Space
        [Parameter(Mandatory=$true,
                   ValueFromPipelineByPropertyName=$true,
                   Position=0)]
        $CSObjectId
    )

    # Function init
    IsAADConnectPresent -MinVersion '1.5.20.0'
    [string] $functionMsg = "Get-ADSyncToolsMVObjFromCSID :"

    Write-Verbose "$functionMsg Searching Connector Space object $CSObjectId ..."

    Try
    {
        # Read object from Connector Space
        $csObj = Get-ADSyncCSObject -Identifier $CSObjectId
    }
    Catch
    {
        Throw "$functionMsg Unable to find object in Connector Space. Error Details: $($_.Exception.Message)"
    }

    # Return result
    If ($csObj -ne $null)
    {
        Write-Verbose "$functionMsg Found Connector Space ObjectId $($csObj.ObjectId) connected to MV ObjectId $($csObj.ConnectedMVObjectId)."
        Return $csObj
    }
    Else
    {
        Throw "$functionMsg Unable to find object '$CSObjectId' in Connector Space."
    }

}


<#
.SYNOPSIS
    Find object in Metaverse based on Connector Space DN
#>

Function Get-ADSyncToolsMVObjFromCSDN
{
    [CmdletBinding()]
    Param
    (
        # DistinguishedName is the identifier of the object in the respective connector space
        [Parameter(Mandatory=$true,
                   ValueFromPipelineByPropertyName=$true,
                   Position=0)]
        $DistinguishedName,

        # ConnectorName is the name of the connector space where the object resides
        [Parameter(Mandatory=$true,
                   ValueFromPipelineByPropertyName=$true,
                   Position=1)]
        $ConnectorName
    )

    # Function init
    IsAADConnectPresent -MinVersion '1.5.20.0'
    [string] $functionMsg = "Get-ADSyncToolsMVObjFromCSDN :"
    Write-Verbose "$functionMsg Searching for '$DistinguishedName' in '$ConnectorName' ..."

    Try
    {
        # Read object from Connector Space
        $csObj = Get-ADSyncCSObject -DistinguishedName $DistinguishedName -ConnectorName $ConnectorName
    }
    Catch
    {
        Throw "$functionMsg Unable to find object in Connector Space. Error Details: $($_.Exception.Message)"
    }

    # Return result
    If ($csObj -ne $null)
    {
        Write-Verbose "$functionMsg Found Connector Space ObjectId $($csObj.ObjectId) connected to MV ObjectId $($csObj.ConnectedMVObjectId)."
        Return $csObj
    }
    Else
    {
        Throw "$functionMsg Unable to find object '$DistinguishedName' in Connector Space $ConnectorName."
    }

}


<#
.SYNOPSIS
    Export an object from Connector Space
#>

Function Export-ADsyncCSObject
{
    [CmdletBinding()]
    Param
    (
        # CSObjectId is the Id of the object in the respective Connector Space
        [Parameter(Mandatory=$true,
                   ValueFromPipelineByPropertyName=$true,
                   Position=0)]
        $CSObjectId,

        # Prefix is a string value which will be used to prefix the filename (Optional)
        [Parameter(Mandatory=$false,
                   ValueFromPipelineByPropertyName=$true,
                   Position=1)]
        $Prefix,

        # ExportSerialized exports additional XML files
        [Parameter(Mandatory=$false,
                    Position=2)]
        [bool] 
        $ExportSerialized = $false
    )

    # Function init
    IsAADConnectPresent -MinVersion '1.5.20.0'
    [string] $functionMsg = "Export-ADsyncCSObject :"

    Write-Verbose "$functionMsg Exporting Connector Space object $CSObjectId ..."

    Try
    {
        # Read object from Connector Space
        $csObj = Get-ADSyncCSObject -Identifier $CSObjectId
    }
    Catch
    {
        Throw "$functionMsg Unable to find object '$CSObjectId' in Connector Space. Error Details: $($_.Exception.Message)"
    }

    # If object found
    If ($csObj -ne $null)
    {
        If ($ExportSerialized)
        {
            # Export SerializedXml data
            $Filename = $Prefix + $CSObjectId + "_CS-Serialized.xml"
            $csObj.SerializedXml | Out-File $Filename
        }

        # Export all properties
        $Filename = $Prefix + $CSObjectId + "_CS.xml"
        $csObj | Select ObjectId,`
                        ConnectorId,`
                        ConnectorName,`
                        ConnectorType,`
                        PartitionId,`
                        DistinguishedName,`
                        AnchorValue,`
                        ObjectType,`
                        IsConnector,`
                        HasSyncError,`
                        HasExportError,`
                        ConnectedMVObjectId,`
                        Lineage,`
                        Attributes | Export-Clixml $Filename

        Write-Verbose "$functionMsg Exported Connector Space object to file '$Filename'."
    }
    Else
    {
        Throw "$functionMsg Unable to find object in Connector Space."
    }

}

<#
.SYNOPSIS
    Export object from Metaverse
#>

Function Export-ADSyncMVObject
{
    [CmdletBinding()]
    Param
    (
        # MVObjectId is the Id of the object in the Metaverse
        [Parameter(Mandatory=$true,
                   ValueFromPipelineByPropertyName=$true,
                   Position=0)]
        $MVObjectId,

        # Prefix is a string value which will be used to prefix the filename (Optional)
        [Parameter(Mandatory=$false,
                   ValueFromPipelineByPropertyName=$true,
                   Position=1)]
        $Prefix,

        # ExportSerialized exports additional XML files
        [Parameter(Mandatory=$false,
                    Position=2)]
        [bool] 
        $ExportSerialized = $false

    )

    # Function init
    IsAADConnectPresent -MinVersion '1.5.20.0'
    [string] $functionMsg = "Export-ADSyncMVObject :"

    Write-Verbose "$functionMsg Exporting MV object $MVObjectId ..."

    Try
    {
        # Read object from Metaverse
        $mvObj = Get-ADSyncMVObject -Identifier $MVObjectId
    }
    Catch
    {
        Throw "$functionMsg Unable to find object in Metaverse. Error Details: $($_.Exception.Message)"
    }

    # If object found
    If ($mvObj -ne $null)
    {
        If ($ExportSerialized)
        {
            # Export SerializedXml data
            $Filename = $Prefix + $MVObjectId + "_MV-Serialized.xml"
            $mvObj.SerializedXml | Out-File $Filename
        }

        # Export Lineage data
        $Filename = $Prefix + $MVObjectId + "_MV.xml"
        $mvObj | Select ObjectId, Lineage, Attributes | Export-Clixml $Filename

        Write-Verbose "$functionMsg Exported MV object to file '$Filename'."

        # Export all Connected objects from the respective Connector Spaces
        ForEach ($connector in $mvObj.Lineage)
        {
            Export-ADsyncCSObject -CsObjectId $connector.ConnectedCsObjectId -Prefix $Prefix -ExportSerialized $ExportSerialized
        }
    }
    Else
    {
        Throw "$functionMsg Unable to find object '$MVObjectId' in Metaverse."
    }
}


<#
.Synopsis
   Convert AAD Connector DistinguishedName to ImmutableId
.DESCRIPTION
   Takes an AAD Connector DistinguishedName like CN={514635484D4B376E38307176645973555049486139513D3D}
   and converts to the respective base64 ImmutableID value, e.g. QF5HMK7n80qvdYsUPIHa9Q==
.EXAMPLE
   ConvertFrom-ADSyncToolsAadDistinguishedName 'CN={514635484D4B376E38307176645973555049486139513D3D}'
#>

Function ConvertFrom-ADSyncToolsAadDistinguishedName
{
    Param
    (
        # Azure AD Connector Space DistinguishedName
        [Parameter(Mandatory=$true,
                   Position=0,
                   ValueFromPipeline=$true,
                   ValueFromPipelineByPropertyName=$true)]
        [string] $DistinguishedName
    )

    Import-ADSyncToolsAADConnectorBinaries

    Try
    {
        $result = [Microsoft.Online.DirSync.Extension.Utilities.DNEncoding]::SafeRdnToString($DistinguishedName);
    }
    Catch
    {
        Throw "Unable to convert DistinguishedName to ImmutableId (SourceAnchor). Error Details: $($_.Exception.Message)"
    }
    $result
}


<#
.Synopsis
   Convert ImmutableId to AAD Connector DistinguishedName
.DESCRIPTION
   Takes an ImmutableId (SourceAnchor) like QF5HMK7n80qvdYsUPIHa9Q== and converts to the respective
   AAD Connector DistinguishedName value, e.g. CN={514635484D4B376E38307176645973555049486139513D3D}
.EXAMPLE
   ConvertTo-ADSyncToolsAadDistinguishedName 'QF5HMK7n80qvdYsUPIHa9Q=='
#>

Function ConvertTo-ADSyncToolsAadDistinguishedName
{
    Param
    (
        # ImmutableId (SourceAnchor)
        [Parameter(Mandatory=$true,
                   Position=0,
                   ValueFromPipeline=$true,
                   ValueFromPipelineByPropertyName=$true)]
        [string] $ImmutableId
    )

    Import-ADSyncToolsAADConnectorBinaries

    Try
    {
        $result = [Microsoft.Online.DirSync.Extension.Utilities.DNEncoding]::StringToSafeRdn($ImmutableId);
    }
    Catch
    {
        Throw "Unable to convert ImmutableId (SourceAnchor) to AAD Connector DistinguishedName. Error Details: $($_.Exception.Message)"
    }  
    $result
}


<#
.Synopsis
   Convert Base64 Anchor to CloudAnchor
.DESCRIPTION
   Takes a Base64 Anchor like VAAAAFUAcwBlAHIAXwBjADcAMgA5ADAAMwBlAGQALQA3ADgAMQA2AC0ANAAxAGMAZAAtADkAMAA2ADYALQBlAGEAYwAzADMAZAAxADcAMQBkADcANwAAAA==
   and converts to the respective CloudAnchor value, e.g. User_abc12345-1234-abcd-9876-ab0123456789
.EXAMPLE
   ConvertTo-ADSyncToolsCloudAnchor "VAAAAFUAcwBlAHIAXwBjADcAMgA5ADAAMwBlAGQALQA3ADgAMQA2AC0ANAAxAGMAZAAtADkAMAA2ADYALQBlAGEAYwAzADMAZAAxADcAMQBkADcANwAAAA=="
.EXAMPLE
   "VAAAAFUAcwBlAHIAXwBjADcAMgA5ADAAMwBlAGQALQA3ADgAMQA2AC0ANAAxAGMAZAAtADkAMAA2ADYALQBlAGEAYwAzADMAZAAxADcAMQBkADcANwAAAA==" | ConvertTo-ADSyncToolsCloudAnchor
#>

Function ConvertTo-ADSyncToolsCloudAnchor
{
    Param
    (
        # Base64 Anchor
        [Parameter(Mandatory=$true,
                   Position=0,
                   ValueFromPipeline=$true,
                   ValueFromPipelineByPropertyName=$true)]
        [string] $Anchor
    )

    $encodedRawAnchor =  [System.Convert]::FromBase64String($Anchor);
    $rawAnchor = $encodedRawAnchor[4..($encodedRawAnchor.Length - 3)]
    $cloudAnchor = [System.Text.Encoding]::Unicode.GetString($rawAnchor)
    $cloudAnchor
}


<#
.Synopsis
   Export Azure AD Disconnector objects
.DESCRIPTION
   Executes CSExport tool to export all Disconnectors to XML and then takes this XML output and converts it to a CSV file
   with: UserPrincipalName, Mail, SourceAnchor, DistinguishedName, CsObjectId, ObjectType, ConnectorId, CloudAnchor
.EXAMPLE
   Export-ADSyncToolsDisconnectors -SyncObjectType 'PublicFolder'
   Exports to CSV all PublicFolder Disconnector objects
.EXAMPLE
   Export-ADSyncToolsDisconnectors
   Exports to CSV all Disconnector objects
.INPUTS
   Use ObjectType argument in case you want to export Disconnectors for a given object type only
.OUTPUTS
   Exports a CSV file with Disconnector objects containing:
   UserPrincipalName, Mail, SourceAnchor, DistinguishedName, CsObjectId, ObjectType, ConnectorId and CloudAnchor
#>

Function Export-ADSyncToolsAadDisconnectors
{
    [CmdletBinding()]
    Param
    (
        # ObjectType to include in output
        [Parameter(Mandatory=$false,
                   Position=0,
                   ValueFromPipelineByPropertyName=$true)]
        [ValidateNotNullOrEmpty()]
        [ValidateSet("User", "Group", "Contact", "PublicFolder", "Device")]
        $SyncObjectType
    )

    IsAADConnectPresent

    # Get Azure AD Connector name
    Try
    {
        $aadConnectorName = (Get-ADSyncConnector | Where-Object {$_.Identifier -eq 'b891884f-051e-4a83-95af-2544101c9083'}).Name
    }
    Catch
    {
        Throw "Error: Unable to retrieve Azure AD Connector name. Make sure ADSync service is running. `nDetails: $($_.Exception.Message)"
    }

    # Export Disconnectors to XML with CSExport tool
    $targetFilename = [string] "$(Get-Date -Format yyyyMMdd-HHmmss)_Disconnectors"
    $cmd = Join-Path -Path $(Get-ADSyncToolsADsyncFolder) -ChildPath 'Bin\csexport.exe'
    Write-Verbose "Executing command : $cmd"
    $result = & $cmd $($aadConnectorName) $($targetFilename + '.xml') '/f:s /o:h'
    If ($lastexitcode -eq 0)
    {
        $result
    }
    Else
    {
        Throw "Error: Unable to retrieve Disconnector objects with CSExport tool. `nDetails: $result"
    }

    # Process Disconnector objects from XML output
    Try
    {
        [xml] $disconnectors = Get-Content $($targetFilename + '.xml')
    }
    Catch
    {
            Throw "Error: Unable to read Disconnector XML file. Error Details: $($_.Exception.Message)"
    }

    # Filter out ObjectType
    If ($SyncObjectType -eq $null)
    {
        $disconnectorObjs = $disconnectors.'cs-objects'.'cs-object'
        Write-Host "Exporting $($disconnectorObjs.Count) Disconnector objects..." 
    }
    Else
    {
        $disconnectorObjs = $disconnectors.'cs-objects'.'cs-object' | Where-Object {$_.'object-type' -eq $SyncObjectType}
        Write-Host "Exporting $($disconnectorObjs.Count) Disconnector ($SyncObjectType) objects..." 
        
    }

    # Export to CSV file
    $results = @()
    ForEach ($obj in $disconnectorObjs)
    {
        $row = "" | select UserPrincipalName, Mail, SourceAnchor, DistinguishedName, CsObjectId, ObjectType, ConnectorId, CloudAnchor
        $row.UserPrincipalName =  ($obj.'pending-import-hologram'.entry.attr | Where-Object {$_.name -eq 'userPrincipalName'}).Value
        $row.Mail = ($obj.'pending-import-hologram'.entry.attr | Where-Object {$_.name -eq 'mail'}).Value
        $row.SourceAnchor = ($obj.'pending-import-hologram'.entry.attr | where {$_.name -eq 'sourceAnchor'}).Value
        $row.DistinguishedName = $obj.'cs-dn'
        $row.CsObjectId = $obj.id
        $row.ObjectType = $obj.'object-type'
        $row.ConnectorId = $obj.'ma-id'
        $row.CloudAnchor = ($obj.'pending-import-hologram'.entry.attr | Where-Object {$_.name -eq 'cloudAnchor'}).Value
        $results += $row

    }
    $results | Export-Csv -Path $($targetFilename + '.csv') -NoTypeInformation
}


<#
.Synopsis
   Get synced objects for a given SyncObjectType
.DESCRIPTION
   Reads from Azure AD all synced objects for a given object class (SyncObjectType).
.EXAMPLE
   Get-ADSyncToolsAadObject -SyncObjectType 'publicFolder' -Credential $(Get-Credential)
.OUTPUTS
   This cmdlet returns the "Shadow" properties that are synchronized by the sync client,
   which might be different than the actual value stored in the respective property of Azure AD.
   For instante, a user's UPN that is synchronized with a non-verified domain suffix 'user@nonverified.domain',
   will have the UPN suffix in Azure AD converted to the tenant's default domain, 'user@tenantname.onmicrosoft.com'
   In this case, Get-ADSyncToolsAadObject will return the "Shadow" value of 'user@nonverified.domain',
   and not the actual value in Azure AD 'user@tenantname.onmicrosoft.com'
#>

Function Get-ADSyncToolsAadObject
{
    [CmdletBinding()]
    Param
    (
        # Object Type
        [Parameter(Mandatory=$true,
                   Position=0,
                   ValueFromPipelineByPropertyName=$true)]
        [ValidateNotNullOrEmpty()]
        [ValidateSet("User", "Group", "Contact", "PublicFolder", "Device")]
        $SyncObjectType,

        # Azure AD Global Admin Credential
        [Parameter(Mandatory=$true, 
                   Position=1,
                   ValueFromPipelineByPropertyName=$true)]
        [PSCredential] 
        $Credential,

        # Properties from Azure AD to output
        [Parameter(Mandatory=$false, 
                   Position=2)]
        $Properties

    )

    # BEGIN
    Import-ADSyncToolsAADConnectorBinaries

    # Check user's UserPrincipalName format
    Get-ADSyncToolsUPNsuffix -UserPrincipalName $Credential.UserName | Out-Null

    Try
    {
        $enumerator = [Microsoft.Azure.ActiveDirectory.Connector.Diagnostics.DiagnosticsFactory]::CreateChangeEnumerator(`
            $Credential.UserName, `
            $Credential.Password, `
            $SyncObjectType, `
            $null, `
            'Full', `
            2)
    }
    Catch
    {
        Throw "There was a problem initiating Enumerator component. Error Details: $($_.Exception.Message)"
    }

    # Define object properties
    If ($null -ne $Properties)
    {
        $propertyNames = @($Properties)
    }
    Else
    {
        # Default Properties. Also Supports CloudLegacyExchangeDN and CloudMSExchRecipientDisplayType properties
        $propertyNames = $defaultAADobjProperties
        
        # Add UserPrincipalName for User objects
        If ($SyncObjectType -eq 'User')
        {
            
            $propertyNames += 'UserPrincipalName'
        }
    }
    
    $baseProperties = @('ObjectClass', 'ObjectId', 'CloudAnchor')
    $propertyNames = $propertyNames | where {$_ -notin $baseProperties}
                        
    $results = [System.Collections.ArrayList]@()
    $counter = 0
    Write-Host $("`rReading DirSyncEnabled objects from Azure AD AdminWebServices:") -ForegroundColor Cyan
    # PROCESS
    Do
    {
        Try
        {
            $batch = $enumerator.EnumerateNextBatch()
        }
        Catch
        {
            Throw "There was a problem with the Enumerator component. Error Details: $($_.Exception.Message)"
        }

        # Show progress
        $counter += $batch.AadBatch.ResultObjects.Count
        Write-Host -NoNewline $("`r==> $counter ") -ForegroundColor Cyan

        If ($batch.AadBatch.ResultObjects.Count -gt 0)
        {
            ForEach($entry in $batch.AadBatch.ResultObjects)
            {
                # Skip deleted objects
                If ($entry.SyncOperation -ne 'Delete')
                {
                    $r = "" | select $baseProperties

                    $r.CloudAnchor = $entry.PropertyValues.CloudAnchor
                    $cloudAnchoraSplit = $r.CloudAnchor -split '_'
                    $r.ObjectClass = $cloudAnchoraSplit[0]
                    $r.ObjectId = $cloudAnchoraSplit[1]

                    # Add all properties as a string value or array of strings
                    $entryValues = $entry.PropertyValues

                    ForEach ($propName in $propertyNames)
                    {
                        $entryPropValue = $entryValues[$propName]
                        If ($null -ne $entryPropValue)
                        {
                            # Check if property is multi-valued or single-valued string
                            If ($entryPropValue -is [System.Collections.Generic.IEnumerable[string]])
                            {
                                $propValue = @()
                                ForEach ($stringValue in $entryPropValue)
                                {
                                    $propValue += $stringValue.ToString()
                                }
                            }
                            Else
                            {
                                $propValue = $entryPropValue.ToString()
                            }
                        }
                        Else
                        {
                            # Note: It's not possible to determine if the attribute is multi-valued or single-value from a null value,
                            # hence a null multi-valued attribute will be always returned as an empty single-value string.
                            $propValue = ""
                        }

                        # Add property/value
                        Add-Member -InputObject $r -MemberType NoteProperty -Name $propName -Value $propValue -Force
                    }
                    $null = $results.Add($r)
                }
            }
        }
    }
    Until ($batch.AadBatch.MoreToRead -eq $false)
    Write-Host

    #END
    $enumerator.Dispose()
    $results
}


<#
.Synopsis
   Exports all synced Mail-Enabled Public Folder objects from AzureAD to a CSV file
.DESCRIPTION
   Reads all synced Mail-Enabled PublicFolder objects from Azure AD and exports the data to a CSV file.
.EXAMPLE
   Export-ADSyncToolsAadPublicFolders -Credential $(Get-Credential) -Path <filename>
.OUTPUTS
   This cmdlet creates the <filename> proficed containing all synced Mail-Enabled PublicFolder objects
   in CSV format.
#>

Function Export-ADSyncToolsAadPublicFolders
{
    [CmdletBinding()]
    Param
    (
        # Azure AD Global Admin Credential
        [Parameter(Mandatory=$true, 
                   Position=0,
                   ValueFromPipelineByPropertyName=$true)]
        [PSCredential] 
        $Credential,

        # Path for output file
        [Parameter(Mandatory=$true, 
                   Position=1)]
        $Path

    )

    $results = Get-ADSyncToolsAadObject -SyncObjectType PublicFolder -Credential $Credential
    if ($results.count -gt 0)
    {
        Try
        {
            $results | Export-Csv -Path $Path -NoTypeInformation -ErrorAction Stop
            Write-Host "Results exported to file '$Path' successfully." -ForegroundColor Green
        }
        Catch
        {
            Throw "There was a problem exporting data to file '$Path'. Error Details: $($_.Exception.Message)"
        }
    }
}


<#
.Synopsis
   Removes orphaned Mail-Enabled Public Folder object(s) from Azure AD
.DESCRIPTION
   Deletes synced Public Folder object(s) from Azure AD based on a CSV file or a single SourceAnchor
.EXAMPLE
   Remove-ADSyncToolsAadPublicFolders -InputCsvFilename .\DeleteObjects.csv -Credential (Get-Credential)
.EXAMPLE
   Remove-ADSyncToolsAadPublicFolders -SourceAnchor '2epFRNMCPUqhysJL3SWL1A==' -Credential (Get-Credential)
.INPUTS
   The CSV input file can be generated using Export-ADSyncToolsAadPublicFolders
   Path parameters must point to a CSV file with at least 2 columns: SourceAnchor, SyncObjectType
.OUTPUTS
   Shows results from ExportDeletions operation
.NOTES
   DISCLAIMER: Synced Mail-Enabled Public Folder objects deleted with this function cannot be RECOVERED!
#>

Function Remove-ADSyncToolsAadPublicFolders
{
    [CmdletBinding(SupportsShouldProcess=$true, 
                   PositionalBinding=$false,
                   ConfirmImpact='High')]
    Param
    (
        # Azure AD Global Admin Credential
        [Parameter(Mandatory=$true, 
                   Position=0,
                   ValueFromPipelineByPropertyName=$true)]
        [PSCredential] 
        $Credential,


        # CSV Input filename - Use Export-ADSyncToolsAadPublicFolders
        [Parameter(ParameterSetName='CsvInput',
                   Mandatory=$true,
                   Position=1,
                   ValueFromPipelineByPropertyName=$true)]
        [ValidateNotNullOrEmpty()]
        $InputCsvFilename,
        
        # Object SourceAnchor
        [Parameter(ParameterSetName='ObjectInput',
                   Mandatory=$true,
                   Position=1,
                   ValueFromPipelineByPropertyName=$true)]
        [ValidateNotNullOrEmpty()]
        $SourceAnchor

    )
    Write-Verbose "ParameterSetName: $($PSCmdlet.ParameterSetName)"

    switch ($PSCmdlet.ParameterSetName)
    {
        'CsvInput' 
        {
            # SoA seizure
            Set-ADSyncToolsAadObject -Credential $Credential -SyncOperation Add -InputCsvFilename $InputCsvFilename
            # Delete MEPF
            Set-ADSyncToolsAadObject -Credential $Credential -SyncOperation Delete -InputCsvFilename $InputCsvFilename
        }
        'ObjectInput' 
        {
            # SoA seizure
            Set-ADSyncToolsAadObject -Credential $Credential -SyncOperation Add -SourceAnchor $SourceAnchor -SyncObjectType "PublicFolder"
            # Delete MEPF
            Set-ADSyncToolsAadObject -Credential $Credential -SyncOperation Delete -SourceAnchor $SourceAnchor -SyncObjectType "PublicFolder"
        }
        Default 
        {
            Throw "Invalid Parameter set. Please try again."
        }
    }

}


<#
.Synopsis
   Remove orphaned synced object from Azure AD
.DESCRIPTION
   Deletes from Azure AD a synced object(s) based on SourceAnchor and ObjecType in batches of 10 objects
   The CSV file can be generated using Export-ADSyncToolsAadDisconnectors
.EXAMPLE
   Remove-ADSyncToolsAadObject -InputCsvFilename .\DeleteObjects.csv -Credential (Get-Credential)
.EXAMPLE
   Remove-ADSyncToolsAadObject -SourceAnchor '2epFRNMCPUqhysJL3SWL1A==' -SyncObjectType 'publicFolder' -Credential (Get-Credential)
.INPUTS
   InputCsvFilename must point to a CSV file with at least 2 columns: SourceAnchor, SyncObjectType
.OUTPUTS
   Shows results from ExportDeletions operation
.NOTES
   DISCLAIMER: Other than User objects that have a Recycle Bin, any other object types DELETED with this function cannot be RECOVERED!
#>

Function Remove-ADSyncToolsAadObject
{
    [CmdletBinding(SupportsShouldProcess=$true, 
                   PositionalBinding=$false,
                   ConfirmImpact='High')]
    Param
    (
        # Azure AD Global Admin Credential
        [Parameter(Mandatory=$true, 
                   Position=0,
                   ValueFromPipelineByPropertyName=$true)]
        [PSCredential] 
        $Credential,


        # CSV Input filename - Must contain header: ObjectClass,SourceAnchor
        [Parameter(ParameterSetName='CsvInput',
                   Mandatory=$true,
                   Position=1,
                   ValueFromPipelineByPropertyName=$true)]
        [ValidateNotNullOrEmpty()]
        $InputCsvFilename,
        
        # Object SourceAnchor
        [Parameter(ParameterSetName='ObjectInput',
                   Mandatory=$true,
                   Position=1,
                   ValueFromPipelineByPropertyName=$true)]
        [ValidateNotNullOrEmpty()]
        $SourceAnchor,

        # Object Type
        [Parameter(ParameterSetName='ObjectInput',
                   Mandatory=$true,
                   Position=2,
                   ValueFromPipelineByPropertyName=$true)]
        [ValidateNotNullOrEmpty()]
        [ValidateSet("User", "Group", "Contact", "PublicFolder")]
        $SyncObjectType

    )
    Write-Verbose "ParameterSetName: $($PSCmdlet.ParameterSetName)"

    switch ($PSCmdlet.ParameterSetName)
    {
        'CsvInput' 
        {
            Set-ADSyncToolsAadObject -Credential $Credential -SyncOperation Delete -InputCsvFilename $InputCsvFilename
        }
        'ObjectInput' 
        {
            Set-ADSyncToolsAadObject -Credential $Credential -SyncOperation Delete -SourceAnchor $SourceAnchor -SyncObjectType $SyncObjectType
        }
        Default 
        {
            Throw "Invalid Parameter set. Please try again."
        }
    }
}


<#
.SYNOPSIS
    Adds or Deletes object(s) in Azure AD
#>


Function Set-ADSyncToolsAadObject
{
    [CmdletBinding()]
    Param
    (
        # Azure AD Global Admin Credential
        [Parameter(Mandatory=$true, 
                   Position=0,
                   ValueFromPipelineByPropertyName=$true)]
        [PSCredential] 
        $Credential,

        # Sync Operation
        [Parameter(Mandatory=$true, 
                   Position=1)]
        [ValidateNotNullOrEmpty()]
        [ValidateSet("Add", "Delete")]
        $SyncOperation,

        # CSV Input filename - Must contain header: ObjectClass,SourceAnchor
        [Parameter(ParameterSetName='CsvInput',
                   Mandatory=$true,
                   Position=2,
                   ValueFromPipelineByPropertyName=$true)]
        [ValidateNotNullOrEmpty()]
        $InputCsvFilename,
        
        # Object SourceAnchor
        [Parameter(ParameterSetName='ObjectInput',
                   Mandatory=$true,
                   Position=2,
                   ValueFromPipelineByPropertyName=$true)]
        [ValidateNotNullOrEmpty()]
        $SourceAnchor,

        # Object Type
        [Parameter(ParameterSetName='ObjectInput',
                   Mandatory=$true,
                   Position=3,
                   ValueFromPipelineByPropertyName=$true)]
        [ValidateNotNullOrEmpty()]
        [ValidateSet("User", "Group", "Contact", "PublicFolder")]
        $SyncObjectType
    )

    # BEGIN
    Write-Verbose "ParameterSetName: $($PSCmdlet.ParameterSetName)"

    # Check user's UserPrincipalName format
    Get-ADSyncToolsUPNsuffix -UserPrincipalName $Credential.UserName | Out-Null

    If ($($PSCmdlet.ParameterSetName) -eq 'CsvInput')
    {
        # Import CSV file
        Try
        {
            $objects = @(Import-Csv $InputCsvFilename)
            Write-Verbose "CsvInput: $InputCsvFilename | ObjectCount = $($objects.Count)"
        }
        Catch
        {
            Throw "There was a problem importing and processing CSV input file. Error Details: $($_.Exception.Message)"
        }
        
        # Set objectClass camel-case
        Try
        {
            for ($i = 0; $i -lt $objects.count; $i++)
            { 
                [string] $syncObjectType = $objects[$i].ObjectClass
                $objects[$i].ObjectClass = $syncObjectType[0].ToString().ToLower() + $syncObjectType.Substring(1)
            }
        }
        Catch
        {
            Throw "There was a problem preparing 'ObjectClass' property. Error Details: $($_.Exception.Message)"
        }

    }
    Else
    {
        $object = "" | Select ObjectClass,SourceAnchor
        $object.ObjectClass = $SyncObjectType[0].ToString().ToLower() + $SyncObjectType.Substring(1)
        $object.SourceAnchor = $SourceAnchor
        Write-Verbose "ObjectInput: $($object.ObjectClass) | $($object.SourceAnchor)"
        $objects = @($object)
    }

    Switch ($SyncOperation)
    {
        'Add' 
        {
            Set-ADSyncToolsAadObjectAdd -Credential $Credential -Objects $objects
        }
        'Delete' 
        {
            Set-ADSyncToolsAadObjectDelete -Credential $Credential -Objects $objects
        }
        Default 
        {
            Throw "Invalid 'SyncOperation' input. Please try again."
        }
    }

}

<#
.SYNOPSIS
    Adds object(s) to Azure AD
#>

Function Set-ADSyncToolsAadObjectAdd
{
    [CmdletBinding(SupportsShouldProcess=$true, 
                   PositionalBinding=$false,
                   ConfirmImpact='High')]
    Param
    (
        # Azure AD Global Admin Credential
        [Parameter(Mandatory=$true, 
                   Position=0,
                   ValueFromPipelineByPropertyName=$true)]
        [PSCredential] 
        $Credential,

        # Sync Operation
        [Parameter(Mandatory=$true, 
                   Position=1)]
        [ValidateNotNullOrEmpty()]
        $Objects


    )
    # BEGIN
    Import-ADSyncToolsAADConnectorBinaries

    Try
    {
        $exporter = `
        [Microsoft.Azure.ActiveDirectory.Connector.Diagnostics.DiagnosticsFactory]::CreateDirectoryChangeExporter( `
            $Credential.UserName, `
            $Credential.Password)
    }
    Catch
    {
        Throw "There was a problem initiating Exporter component. Error Details: $($_.Exception.Message)"
    }

    Try
    {
        $entries = [System.Collections.ArrayList]@()
        ForEach ($obj in $objects)
        {
            Write-Verbose "Processing: SyncEntry = $($obj.ObjectClass) | $($obj.SourceAnchor)"
            # Add SyncEntry
            $syncObject = New-Object Microsoft.Online.Coexistence.Schema.AzureADSyncObject
            $syncObject.SyncObjectType = [Microsoft.Online.Coexistence.Schema.SyncObjectType]::PublicFolder
            $syncObject.SyncOperation = [Microsoft.Online.Coexistence.Schema.SyncObjectOperation]::Add
            $syncObject.SetValue([Microsoft.Online.Coexistence.Schema.SyncObjectAttributes]::SourceAnchor, $obj.SourceAnchor)
            $entries.Add($syncObject) | Out-Null
        }
    }
    Catch
    {
        Throw "There was a problem creating Sync entries. Error Details: $($_.Exception.Message)"
    }

    $objsCount = $objects.Count
    $objsProcessed = 0
    $batchSize = 10
    $nextBatch = @()
    # PROCESS

    Try 
    {
        While ($objsProcessed -lt $objsCount) 
        {
            # Progress bar
            $percent = [math]::Round($(($objsProcessed * 100) / $objsCount), 1)
            Write-Progress -Activity "Exporting objects to Azure AD" -Status "$($percent)% Complete:" -PercentComplete $percent;
            
            # Process batch
            $nextBatch = $entries | Select-Object -First $batchSize
            $entries = $entries | Select-Object -Skip $batchSize
            #$nextBatch | Out-String

            # Export objects
            $results = $exporter.Export([Microsoft.Online.Coexistence.Schema.AzureADSyncObject[]]$nextBatch, 2)
            Write-Output $results | FT ResultCode, ResultErrorDescription, ObjectType, SourceAnchor
            $objsProcessed += $nextBatch.Count

        }
    }
    Catch
    {
        Throw "There was a problem processing the batch. Error Details: $($_.Exception.Message)"
    }
    
    #END
    $exporter.Dispose()
}


<#
.SYNOPSIS
    Deletes object(s) from Azure AD
#>

Function Set-ADSyncToolsAadObjectDelete
{
    [CmdletBinding(SupportsShouldProcess=$true, 
                   PositionalBinding=$false,
                   ConfirmImpact='High')]
    Param
    (
        # Azure AD Global Admin Credential
        [Parameter(Mandatory=$true, 
                   Position=0,
                   ValueFromPipelineByPropertyName=$true)]
        [PSCredential] 
        $Credential,

        # Sync Operation
        [Parameter(Mandatory=$true, 
                   Position=1)]
        [ValidateNotNullOrEmpty()]
        $Objects


    )
    # BEGIN
    Import-ADSyncToolsAADConnectorBinaries

    Try
    {
        $exporter = `
        [Microsoft.Azure.ActiveDirectory.Connector.Diagnostics.DiagnosticsFactory]::CreateDirectoryChangeExporter( `
            $Credential.UserName, `
            $Credential.Password)
    }
    Catch
    {
        Throw "There was a problem initiating Exporter component. Error Details: $($_.Exception.Message)"
    }

    Try
    {
        $entries = [System.Collections.ArrayList]@()
        ForEach ($obj in $objects)
        {
            Write-Verbose "Processing: DeleteEntry = $($obj.ObjectClass) | $($obj.SourceAnchor)"
            # Add DeleteEntry
            $entries.Add([Microsoft.Azure.ActiveDirectory.Connector.Diagnostics.DeletionEntry]::FromSourceAnchor($obj.ObjectClass, $obj.SourceAnchor)) | Out-Null
        }
    }
    Catch
    {
        Throw "There was a problem creating Deletion entries. Error Details: $($_.Exception.Message)"
    }

    $objsCount = $objects.Count
    $objsProcessed = 0
    $batchSize = 10
    $nextBatch = @()

    # PROCESS
    Try 
    {
        While ($objsProcessed -lt $objsCount) 
        {
            # Progress bar
            $percent = [math]::Round($(($objsProcessed * 100) / $objsCount), 1)
            Write-Progress -Activity "Deleting objects from Azure AD" -Status "$($percent)% Complete:" -PercentComplete $percent;
            
            # Process batch
            $nextBatch = $entries | Select-Object -First $batchSize
            $entries = $entries | Select-Object -Skip $batchSize
            #$nextBatch | Out-String

            if ($pscmdlet.ShouldProcess("$($nextBatch.Count) objects", "Delete from Azure AD"))
            {
                $results = $exporter.ExportDeletions([Microsoft.Azure.ActiveDirectory.Connector.Diagnostics.DeletionEntry[]]$nextBatch)
                Write-Output $results | FT ResultCode, ResultErrorDescription, ObjectType, SourceAnchor
            }
            else
            {
                Write-Output "SKIPPED: Operation canceled. `n`n"
            }
            $objsProcessed += $nextBatch.Count
        }
    }
    Catch
    {
        Throw "There was a problem processing the batch. Error Details: $($_.Exception.Message)"
    }

    #END
    $exporter.Dispose()
}


<#
.Synopsis
   Get Azure AD Connnect Run History
.DESCRIPTION
   Function that returns the Azure AD Connect Run History in XML format
.EXAMPLE
   Get-ADSyncToolsRunHistory
.EXAMPLE
   Get-ADSyncToolsRunHistory -Days 3
#>

Function Get-ADSyncToolsRunHistory 
{
    Param
    (
        # Number of days back to collect History (default = 1)
        [Parameter(Mandatory=$false)]
        [int]
        $Days = 1
    )

    IsAADConnectPresent -MinVersion '1.4.18.0'

    # Read Run Profile
    Try  
    {
        If ($Days -eq 0)
        {
            $runProfile = Get-ADSyncRunProfileResult -ErrorAction Stop
        }
        Else
        {
            $startDate = (Get-Date).AddDays(-$Days).ToUniversalTime()
            $runProfile = Get-ADSyncRunProfileResult -ErrorAction Stop | where {$_.StartDate -gt $startDate}
        }
    }
    Catch  
    {
        Throw "There was a problem calling 'Get-ADSyncRunProfileResult': $($_.Exception.Message)"
    }

    $runProfile | select ConnectorName, RunProfileName, Result, StartDate, EndDate, CurrentStepNumber, RunStepResults, RunHistoryId
}


<#
.Synopsis
   Shows the Run Profile history merged with Run Step results
.DESCRIPTION
   Gets ADSync Run Profile history including each Run Step result
.EXAMPLE
   Get-ADSyncToolsRunStepHistory | FT
.EXAMPLE
   Get-ADSyncToolsRunStepHistory -FromStartDate "9/25/2021 7:38" | FT
#>

Function Get-ADSyncToolsRunStepHistory
{
    [CmdletBinding()]
    Param(
        # Filter from start date
        [Parameter(Mandatory=$false,
                   ValueFromPipelineByPropertyName=$true,
                   Position=0)]
        [datetime]
        $FromStartDate
    )

    IsAADConnectPresent -MinVersion '1.4.18.0'

    # BEGIN
    $profileProperties = @('StartDate','EndDate','ConnectorName','RunProfileName','RunNumber','RunHistoryId')
    $stepProperties = @('StepNumber','StepResult','StepHistoryId')
    $allProperties = @('StartDate','EndDate','ConnectorName','RunProfileName','StepResult','StepNumber','RunNumber','RunHistoryId','StepHistoryId')
    $results = @()

    If ($FromStartDate -ne $null)
    {
        $runHistory = Get-ADSyncRunProfileResult | Where StartDate -gt $FromStartDate | select $profileProperties
    }
    Else
    {
        $runHistory = Get-ADSyncRunProfileResult | select $profileProperties
    }

    # PROCESS
    ForEach ($runProfile in $runHistory)
    {
        $r = "" | select $allProperties

        ForEach ($p in $profileProperties)
        {
            $r.$p = $runProfile.$p
        }
        ForEach ($runStep in (Get-ADSyncRunStepResult -RunHistoryId $r.RunHistoryId | select $stepProperties))
        {
            ForEach ($p in $stepProperties)
            {
                $r.$p = $runStep.$p
            }
            $results += ($r | select *)
        }
    }

    # END
    $results
}


<#
.Synopsis
   Export Azure AD Connnect Run History
.DESCRIPTION
   Function to export Azure AD Connect Run Profile and Run Step results to CSV and XML format respectively.
   The resulting Run Profile CSV file can be imported into a spreadsheet and the Run Step XML file can be imported with Import-Clixml
.EXAMPLE
   Export-ADSyncToolsRunHistory -TargetName MyADSyncHistory
#>

Function Export-ADSyncToolsRunHistory 
{
    Param
    (
        # Name of the output file
        [Parameter(Mandatory=$true,
                   Position=0,
                   ValueFromPipeline=$true,
                   ValueFromPipelineByPropertyName=$true)]
        [ValidateNotNullOrEmpty()]
        [String] 
        $TargetName
    )
    
    IsAADConnectPresent -MinVersion '1.4.18.0'

    # Read Run Profile and Run Step results
    Try  
    {
        $runProfile = Get-ADSyncRunProfileResult -ErrorAction Stop
        $runSteps = Get-ADSyncRunStepResult -ErrorAction Stop
    }
    Catch  
    {
        Throw "There was a problem calling 'Get-ADSyncRunProfileResult' and 'Get-ADSyncRunStepResult': $($_.Exception.Message)"
    }

    # Export Run Profile results
    Try  
    {
        $runProfile | 
            Where-Object {$_.IsRunComplete -eq 'True'} | 
                select ConnectorName, RunProfileName, Result, StartDate, EndDate, CurrentStepNumber, RunStepResults, RunHistoryId | 
                    Export-Csv ".\$TargetName-RunProfile.csv" -NoTypeInformation -ErrorAction Stop
    }
    Catch  
    {
        Throw "There was a problem exporting Run Profile results: $($_.Exception.Message)"
    }

    # Export Run Step results
    Try  
    {
        $runSteps | 
            Export-Clixml ".\$TargetName-RunStep.xml" -ErrorAction Stop
    }
    Catch  
    {
        Throw "There was a problem exporting Run Step results: $($_.Exception.Message)"
    }
}


<#
.Synopsis
   Import Azure AD Connnect Run History
.DESCRIPTION
   Function to Import Azure AD Connect Run Step results from XML created using Export-ADSyncToolsRunHistory
.EXAMPLE
   Export-ADSyncToolsRunHistory -Path .\RunHistory-RunStep.xml
#>

Function Import-ADSyncToolsRunHistory 
{
    [CmdletBinding()]
    Param
    (
        # Path for the XML file to import
        [Parameter(Mandatory=$true,
                   Position=0,
                   ValueFromPipeline=$true,
                   ValueFromPipelineByPropertyName=$true)]
        [ValidateNotNullOrEmpty()]
        [string] 
        $Path
    )

    If (Test-Path $Path)
    {
        Try
        {
            Import-Clixml $Path
        }
        Catch
        {
            Write-Error "Unable to import file '$Path'. Error Details: $($_.Exception.Message)"
        }
    }
    Else
    {
        Write-Error "File not found."
    }
}


<#
.Synopsis
   Get Azure AD Connect Run History for older versions of AADConnect (WMI)
.DESCRIPTION
   Function that returns the Azure AD Connect Run History in XML format
.EXAMPLE
   Get-ADSyncToolsRunHistory
.EXAMPLE
   Get-ADSyncToolsRunHistory -Days 3
#>

Function Get-ADSyncToolsRunHistoryLegacyWmi
{
    Param
    (
        # Number of days back to collect History (default = 1)
        [Parameter(Mandatory=$false)]
        [int]
        $Days = 1
    )
    
    IsAADConnectPresent -MaxVersion '1.4.0.0'
    $runStartDate=(Get-Date (Get-Date).AddDays(-$Days) -Format yyyy-MM-dd) 
    $getRunStartTime="RunStartTime >'"+$runStartDate+"'"
    $namespace = 'root\MicrosoftIdentityintegrationServer'

    Try  
    {
        $miis_RunHistory = Get-WmiObject -class "MIIS_RunHistory" -namespace $namespace -Filter $getRunStartTime -ErrorAction Stop
    }
    Catch  
    {
        $errorMsg = "There was a problem calling WMI Namespace '$namespace': $($_.Exception.Message)"
        Write-Error $errorMsg
        return @($errorMsg)
    }

    If ($miis_RunHistory -ne $null)   
    { 
        $xmlData = @()
    
        ForEach ($entry in $miis_RunHistory)   
        { 
            $xmlData += $entry.RunDetails()
        }
        Return $xmlData 
    }
}


<#
.Synopsis
   Script to Remove Expired Certificates from UserCertificate Attribute
.DESCRIPTION
    This script takes all the objects from a target Organizational Unit in your Active Directory domain - filtered by Object Class (User/Computer)
    and deletes all expired certificates present in the UserCertificate attribute.
    By default (BackupOnly mode) it will only backup expired certificates to a file and not do any changes in AD.
    If you use -BackupOnly $false then any Expired Certificate present in UserCertificate attribute for these objects will be removed from Active Directory after being copied to file.
    Each certificate will be backed up to a separated filename: ObjectClass_ObjectGUID_CertThumprint.cer
    The script will also create a log file in CSV format showing all the users with certificates that either are valid or expired including the actual action taken (Skipped/Exported/Deleted).
.EXAMPLE
   Check all users in target OU - Expired Certificates will be copied to separated files and no certificates will be removed
   Remove-ADSyncToolsExpiredCertificates -TargetOU "OU=Users,OU=Corp,DC=Contoso,DC=com" -ObjectClass user
.EXAMPLE
   Delete Expired Certs from all Computer objects in target OU - Expired Certificates will be copied to files and removed from AD
   Remove-ADSyncToolsExpiredCertificates -TargetOU "OU=Computers,OU=Corp,DC=Contoso,DC=com" -ObjectClass computer -BackupOnly $false
#>

Function Remove-ADSyncToolsExpiredCertificates
{
    [CmdletBinding()]
    Param
    ( 
        # Target OU to lookup for AD objects
        [Parameter(Mandatory=$True)]
        [string]$TargetOU,

        # BackupOnly will not delete any certificates from AD
        [Bool]$BackupOnly = $True,

        # Object Class filter
        [Parameter(Mandatory=$True)]
        [ValidateSet('user','computer')] 
        [String] 
        $ObjectClass
    )

    Import-ADSyncToolsModule -ModuleName ActiveDirectory -InstallMessage $addsInstallMsg
    
    # Query AD object class = $ObjectClass that contain UserCerts in OU = $TargetOU
    $ldapFilter = [string] "(objectClass=$ObjectClass)"
    $adObjectsInOU = @(Get-ADObject -LDAPFilter $ldapFilter -SearchBase $TargetOU -Properties userCertificate | where {$_.userCertificate -ne $null})
    Write-Output "Processing $($adObjectsInOU.Count) AD objects with UserCertificate..."

    # Backup removed certificates to a file
    [bool] $BackupCertificates = $True

    # For each user and each cert check validity, backup to a file (if $BackupCertificates = $True) and remove cert it if Expired
    $resultsTable = @()
    $today = Get-Date
    foreach ($adObject in $adObjectsInOU)  {
    
        $objCerts = @($adObject.UserCertificate)

        Write-Output "Checking AD Object: $($adObject.Name) | Total Certs: $($objCerts.Count)"
        $certIndex = 0 

        foreach ($cert in $objCerts) 
        {
            $row = "" | select ADobjectDN,CertificateIndex,CertificateName,CertificateTemplate,CertificateIssuingDate,CertificateExpireDate,CertificateStatus,Export-Action-Reason
            $certObj = [System.Security.Cryptography.X509Certificates.X509Certificate2] $cert
        
            $certName = $certObj.GetName()
            $certTemplate = $certObj.Extensions| foreach {
                $_.Format($false) |  Select-String "Template="
            }
            Write-Debug $certObj
            $templateName = @($($certTemplate -split ','))[0]
            if ($templateName -eq $null)  {
                $templateName = "N/A"
            }

    
            if ($certObj.NotAfter -lt $($today))  {
                $certStatus = "Expired"
                $deleteCert = $true

                if ($BackupCertificates)  {

                    $filename = [string] ".\$($ObjectClass)_$($adObject.ObjectGUID)_$($certObj.Thumbprint).cer"
                    Try {
                        $exportResult = Export-Certificate -Cert $certObj -FilePath $filename
                        $row.'Export-Action-Reason' = "Exported"
                        Write-Output "Expired Certificate exported to file: $($exportResult.Name)"
                    }
                    Catch {
                        $row.'Export-Action-Reason' = "ExportFailed-NotRemoved-Error"
                        $deleteCert = $false
                    }
                }
            }
            else {
                $certStatus = "Valid"
                $deleteCert = $false
                $row.'Export-Action-Reason' += "Skipped-NotRemoved-ValidCert)"
            }
        
            if ($deleteCert)  {
                Try  {
                    if (!$BackupOnly) {
                
                        Set-ADObject -Identity $adObject.DistinguishedName -Remove @{UserCertificate=$certObj}
                        $row.'Export-Action-Reason' += "-Removed-Expired"
                    }
                    else  {
                        $row.'Export-Action-Reason' += "-ToBeRemoved-BackupOnly"
                    }
                }
                Catch {
                    $row.'Export-Action-Reason' += "-NotRemoved-Error"
                }
            }

            $row.ADobjectDN =             [string] $adObject.DistinguishedName.ToString()
            $row.CertificateIndex =       [string] $certIndex.ToString()
            $row.CertificateName =        [string] $certObj.GetName()
            $row.CertificateTemplate =    [string] $templateName.ToString()
            $row.CertificateIssuingDate = [string] $certObj.NotBefore.ToString()
            $row.CertificateExpireDate =  [string] $certObj.NotAfter.ToString()
            $row.CertificateStatus =      [string] $certStatus.ToString()
        
            Write-Verbose $row
            $resultsTable += $row | Select *
            $certIndex++
        } 
    }

    # Export results to a file
    $date = [string] $(Get-Date -Format yyyyMMddHHmmss)
    $filename = [string] ".\ExpiredCertsResults-$date.txt"
    $resultsTable | Export-Csv -Path $filename -Delimiter "`t" -NoTypeInformation

}


<#
.Synopsis
   Creates a trace file from an Active Directory Import Step
.DESCRIPTION
   Traces all LDAP queries of an Active Directory Import run from a given Active Directory watermark checkpoint (aka. partition cookie).
   Creates a trace file '.\ADimportTrace_yyyyMMddHHmmss.log' on the current folder.
   To use -ADConnectorXML, go to the Synchronization Service Manager, right-click your AD Connector and select "Export Connector..."
.EXAMPLE
   Trace Active Directory Import for user objects by providing an AD Connector XML file
   Trace-ADSyncToolsADImport -DC 'DC1.contoso.com' -RootDN 'DC=Contoso,DC=com' -Filter '(&(objectClass=user))' -ADConnectorXML .\ADConnector.xml
.EXAMPLE
   Trace Active Directory Import for all objects by providing the Active Directory watermark (cookie) and AD Connector credential
   $creds = Get-Credential
   Trace-ADSyncToolsADImport -DC 'DC1.contoso.com' -RootDN 'DC=Contoso,DC=com' -Credential $creds -ADwatermark "TVNEUwMAAAAXyK9ir1zSAQAAAAAAAAAA(...)"
#>

Function Trace-ADSyncToolsADImport
{
    [CmdletBinding()]
    Param
    (
        # Target Domain Controller
        [Parameter( Mandatory=$True, 
                    Position=0)]
        [string] $DC,

        # Forest Root DN
        [Parameter( Mandatory=$True, 
                    Position=1)]
        [string] $RootDN,

        # AD objects type to trace. Use '(&(objectClass=*))' for all object types
        [Parameter( Mandatory=$False, 
                    Position=2)]
        [string] $Filter = '(&(objectClass=*))',

        # Provide the credential to run LDAP query against AD
        [Parameter( Mandatory=$false, 
                    Position=3)]
        [PSCredential] $Credential,

        # SSL Connection
        [Parameter( Mandatory=$false, 
                    Position=4)]
        [switch] $SSL = $false,

        # AD Connector Export XML file - Right-click AD Connector and select "Export Connector..."
        [Parameter( Mandatory=$True, 
                    Position=5,
                    ParameterSetName = "ADConnectorXML")]
        [string] $ADConnectorXML,
        
        # Manual input of watermark, instead of XML file e.g. $ADwatermark = "TVNEUwMAAAAXyK9ir1zSAQAAAAAAAAAA(...)"
        [Parameter( Mandatory=$True, 
                    Position=5,
                    ParameterSetName = "ADwatermarkInput")]
        [string] $ADwatermark
    )

    # Read AD watermark value
    If ($ADwatermark -eq "" -or $ADwatermark -eq $null)  
    {
        # Read AD Connector XMl file
        If ($ADConnectorXML -notlike "" -and (Test-Path $ADConnectorXML))  
        {
            # Parse the Cookie (AD watermark) from the XML data
            Try  
            {
                $adcsXMLdata = [xml] (Get-Content $ADConnectorXML)
                $maPartitionDataList = @($adcsXMLdata.'saved-ma-configuration'.'ma-data'.'ma-partition-data'.partition)
                $maPartitionData = $maPartitionDataList | Where-Object {$_.Name -like $RootDN}
                $ADwatermark = $maPartitionData.'custom-data'.'adma-partition-data'.cookie
                Write-Verbose "AD watermark from AD Connector XML file '$ADConnectorXML': `n$ADwatermark `n"
            }
            Catch  
            {
                Throw "Error reading AD Connector XML export file: $($_.Exception.Message)"
            }
        }
        Else    
        {
            Throw "Please provide a valid AD Connector XML export file."
        }
    }


    # Parse AD watermark value
    Write-Host "`Parsing AD watermark for '$RootDN' partition: `n$ADwatermark `n"    
    Try
    {
        [byte[]] $dirSyncCookie = [System.Convert]::FromBase64String($ADwatermark)
    }
    Catch
    {
        Throw "Error parsing AD watermark: $($_.Exception.Message)"
    }
    
    # Importing from AD
    Write-Host "`nImporting from AD ..."
    Try
    {
        [void] ([System.Reflection.Assembly]::LoadWithPartialName('System.DirectoryServices.Protocols'))
    }
    Catch
    {
        Throw "Error loading assemblies: $($_.Exception.Message)"
    }

    
    If (($SSL) -and ($DC -notlike '*:636')) 
    { 
        $DC = '{0}:636' -f $DC 
    }

    If ($Credential -eq $null)
    {
        Write-Verbose "Credential not passed in - Using security context of the current logged-on user."
        # Use Current Logged on User credential
        [DirectoryServices.Protocols.LdapConnection] $ldapConn = New-Object DirectoryServices.Protocols.LdapConnection($DC)
    }
    Else  
    {
        # Use provided Credential
        Write-Verbose "Credential passed in - Using provided credential"
        Try
        {
            [DirectoryServices.Protocols.LdapConnection] $ldapConn = New-Object DirectoryServices.Protocols.LdapConnection($DC, $Credential.GetNetworkCredential())    
        }
        Catch
        {
            Throw "LDAP connection failure: $($_.Exception.Message)"
        }
    }

    # Generate AD Import trace file
    $d = "`t" # Delimiter
    $logfilename = [string] ".\ADimportTrace_$(Get-Date -Format yyyyMMddHHmmss).log"
    $header = [string] "Timestamp" + $d + "ldapResult" + $d + "ldapCount" + $d + "AttributeCount" + $d + "DistinguishedName" + $d + "Attributes(ValuesCount)"
    Out-File -FilePath $logfilename -InputObject $header

    # Setup LDAP request
    If (-not $SSL) 
    {
        $ldapConn.SessionOptions.Sealing = $true
    } 
    Else 
    {
        $ldapConn.SessionOptions.SecureSocketLayer = $true
    }

    [string[]] $attributesToFetch = $null
    $ldapConn.AuthType = [DirectoryServices.Protocols.AuthType]::Kerberos
    [DirectoryServices.Protocols.SearchRequest] $ldapRequest = New-Object DirectoryServices.Protocols.SearchRequest($RootDN, $Filter, 'SubTree', $attributesToFetch)
    [DirectoryServices.Protocols.DirSyncRequestControl] $dirSyncCtr = New-Object DirectoryServices.Protocols.DirSyncRequestControl($dirSyncCookie, [DirectoryServices.Protocols.DirectorySynchronizationOptions]::None, [Int32]::MaxValue)
    [void] $ldapRequest.Controls.Add($dirSyncCtr)
    [bool] $hasMore = $false

    # Process LDAP Request/Response
    Do 
    {
        [DirectoryServices.Protocols.SearchResponse] $ldapResponse = $null
        $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
        Try  
        {
            $ldapResponse = $ldapConn.SendRequest($ldapRequest)
        }
        Catch    
        {
            Throw "Problem sending LDAP request. Try without SSL or to provide the sync cookie with -ADwatermark <based64> or -ADConnectorXML <file>. Error Details: $($_.Exception.Message)"
        }

        # Show/ Log LDAP Response
        Write-Host ('Search response code: {0}, Result Count {1}' -f $ldapResponse.ResultCode, $ldapResponse.Entries.Count)
        $logldapResponse = [string] $timestamp + $d + $($ldapResponse.ResultCode) + $d + $($ldapResponse.Entries.Count)
    
        ForEach ($entry in $ldapResponse.Entries)  
        {
            # Show/ Log Entry from LDAP Response
            Write-Host ('Entry: {0} | Attribute Count = {1}' -f $entry.DistinguishedName, $entry.Attributes.Count)
            $logdata = [string] $logldapResponse + $d + $($entry.Attributes.Count) + $d + $($entry.DistinguishedName)
            $attributeData = ""
        
            foreach ($attributeName in $entry.Attributes.AttributeNames)
            {
                $attributeData += $attributeName + "(" + $($entry.Attributes[$attributeName].Count) + ")" + ","
                Write-Host ("Attribute {0}, ValueCount {1}" -f $attributeName, $entry.Attributes[$attributeName].Count)
            }
            Write-Host
            $logdata += $d + $attributeData.Substring(0, $attributeData.Length -1)
            Out-File -FilePath $logfilename -InputObject $logdata -Append
        }

        $hasMore = $false
        If (-not ([object]::Equals($ldapResponse, $null))) 
        {
            ForEach ($oneLdapResponseControl in $ldapResponse.Controls) 
            {
                If ($oneLdapResponseControl -is [DirectoryServices.Protocols.DirSyncResponseControl]) 
                {
                    [DirectoryServices.Protocols.DirSyncResponseControl] $dirSyncCtrResponse = [DirectoryServices.Protocols.DirSyncResponseControl] $oneLdapResponseControl
                    $dirSyncCtr.Cookie = $dirSyncCtrResponse.Cookie
                    $hasMore = $dirSyncCtrResponse.MoreData
                    Break
                }
            }
        }
    }
    While ($hasMore)
}


<#
.Synopsis
   Trace LDAP queries
.DESCRIPTION
   Helper function for troubleshooting Active Directory LDAP queries
.EXAMPLE
   Trace-ADSyncToolsLdapSchemaQuery -RootDN "DC=Contoso,DC=com" -Credential $Credential
#>

Function Trace-ADSyncToolsLdapSchemaQuery
{
    [CmdletBinding()]
    Param
    (
        # Forest/Domain DistinguishedName
        [Parameter(Mandatory=$true,
                   Position=0,
                   ValueFromPipelineByPropertyName=$true)]
        [ValidateNotNullOrEmpty()]
        [String] $RootDN,

        # AD Credential
        [Parameter(Mandatory=$true,
                   Position=1,
                   ValueFromPipelineByPropertyName=$true)]
        [ValidateNotNullOrEmpty()]
        [pscredential] $Credential,

        # Domain Controller Name (optional)
        [Parameter(Mandatory=$false,
                   Position=2,
                   ValueFromPipelineByPropertyName=$true)]
        [String] $Server,

        # Domain Controller port (default: 389)
        [Parameter(Mandatory=$false,
                   Position=2,
                   ValueFromPipelineByPropertyName=$true)]
        [Int] $Port = 389,

        # LDAP filter (default: objectClass=*)
        [Parameter(Mandatory=$false)]
        [String]
        $Filter = "(objectClass=*)"
    )

    [Reflection.Assembly]::LoadWithPartialName("System.Directoryservices.Protocols")
    [Reflection.Assembly]::LoadWithPartialName("System.Directoryservices")

    $ldapDirectoryId = New-Object System.DirectoryServices.Protocols.LdapDirectoryIdentifier($Server, $Port)
    $ldapConnection = New-Object System.DirectoryServices.Protocols.LdapConnection($ldapDirectoryId, $Credential.GetNetworkCredential())

    $rootDSEReq = New-Object System.DirectoryServices.Protocols.SearchRequest
    $rootDSEReq.DistinguishedName = $RootDN
    $rootDSEReq.Filter = $Filter
    $rootDSEReq.Scope = [System.DirectoryServices.Protocols.SearchScope]("Base")
    $rootDSEReq.SizeLimit = 1
    $rootDSEReq.TimeLimit = [System.Timespan]::FromMinutes(2)
    $rootDSEReq.Attributes.Add("SubschemaSubentry") | Out-Null

    Try
    {
        $searchResponse = [System.DirectoryServices.Protocols.SearchResponse]$ldapConnection.SendRequest($rootDSEReq)
    }
    Catch
    {
        Throw "There was an error searching Active Directory. Error Details: $($_.Exception.Message). `nInnerException: $($_.Exception.InnerException)"
    }

    $subentryDN = $searchResponse.Entries[0].Attributes["SubschemaSubentry"][0]
    Write-Host "Sub entry DN '$subentryDN' from '$($($searchResponse.Entries[0]).DistinguishedName)'" -ForegroundColor Cyan

    $seReq = New-Object System.DirectoryServices.Protocols.SearchRequest
    $seReq.DistinguishedName = $subentryDN
    $seReq.Filter = $Filter
    $seReq.Scope = [System.DirectoryServices.Protocols.SearchScope]("Base")
    $seReq.SizeLimit = 1
    $seReq.TimeLimit = [System.Timespan]::FromMinutes(2)
    $seReq.Attributes.Add("extendedAttributeInfo") | Out-Null
    $seReq.Attributes.Add("attributeTypes") | Out-Null
    $seReq.Attributes.Add("objectClasses") | Out-Null
    $seReq.Attributes.Add("dITContentRules") | Out-Null

    $searchResponse = [System.DirectoryServices.Protocols.SearchResponse]$ldapConnection.SendRequest($seReq)

    $logfilenamePrefix = [string] ".\LdapTrace_$(Get-Date -Format yyyyMMddHHmmss)"

    Write-Host "Exporting data to '$logfilenamePrefix*' files..." -ForegroundColor Cyan

    $logfilename = $logfilenamePrefix + "-extendedAttributeInfo.txt"
    for ($i = 0; $i -lt $searchResponse.Entries[0].Attributes["extendedAttributeInfo"].Count; $i++)
    {
      Add-Content -Path $logfilename -Value $searchResponse.Entries[0].Attributes["extendedAttributeInfo"][$i]
    }

    $logfilename = $logfilenamePrefix + "-attributeTypes.txt"
    for ($i = 0; $i -lt $searchResponse.Entries[0].Attributes["attributeTypes"].Count; $i++)
    {
      Add-Content -Path $logfilename -Value $searchResponse.Entries[0].Attributes["attributeTypes"][$i]
    }

    $logfilename = $logfilenamePrefix + "-objectClasses.txt"
    for ($i = 0; $i -lt $searchResponse.Entries[0].Attributes["objectClasses"].Count; $i++)
    {
      Add-Content -Path $logfilename -Value $searchResponse.Entries[0].Attributes["objectClasses"][$i]
    }

    $logfilename = $logfilenamePrefix + "-dITContentRules.txt"
    for ($i = 0; $i -lt $searchResponse.Entries[0].Attributes["dITContentRules"].Count; $i++)
    {
      Add-Content -Path $logfilename -Value $searchResponse.Entries[0].Attributes["dITContentRules"][$i]
    }
}

<#
.Synopsis
   Trace LDAP queries
.DESCRIPTION
   Helper function for troubleshooting Active Directory LDAP queries
.EXAMPLE
   Trace-ADSyncToolsLdapQuery -RootDN "DC=Contoso,DC=com" -Credential $Credential
#>

Function Trace-ADSyncToolsLdapQuery
{
    [CmdletBinding()]
    Param
    (
        # DistinguishedName of base object
        [Parameter(Mandatory=$true,
                   Position=0,
                   ValueFromPipelineByPropertyName=$true)]
        [ValidateNotNullOrEmpty()]
        [String] $BaseDN,

        # AD Credential
        [Parameter(Mandatory=$false,
                   Position=1,
                   ValueFromPipelineByPropertyName=$true)]
        [ValidateNotNullOrEmpty()]
        [pscredential] $Credential,

        # Domain Controller Name (optional)
        [Parameter(Mandatory=$false,
                   Position=2,
                   ValueFromPipelineByPropertyName=$true)]
        [String] $Server,

        # Domain Controller port (default: 389)
        [Parameter(Mandatory=$false,
                   Position=2,
                   ValueFromPipelineByPropertyName=$true)]
        [Int] $Port = 389,

        # LDAP filter (default: objectClass=domainDNS)
        [Parameter(Mandatory=$false)]
        [String]
        $Filter = "(objectClass=container)",

        # LDAP SearchScope (default: SubTree)
        [Parameter(Mandatory=$false)]
        [String]
        $SearchScope = "SubTree",

        # LDAP SizeLimit (default: 0)
        [Parameter(Mandatory=$false)]
        [Int]
        $SizeLimit = 0,

        # LDAP TimeLimit in seconds (default: 120)
        [Parameter(Mandatory=$false)]
        [Int]
        $TimeLimitSeconds = 120
        
    )
    
    # Load System.Directoryservices
    Write-Verbose "Init: Load System.Directoryservices"
    Try
    {
        [void] ([System.Reflection.Assembly]::LoadWithPartialName("System.Directoryservices.Protocols"))
        [void] ([System.Reflection.Assembly]::LoadWithPartialName("System.Directoryservices"))
    }
    Catch
    {
        Throw "Error loading assemblies: $($_.Exception.Message)"
    }
    Write-Verbose "Exit: Load System.Directoryservices"

    # Instantiate LdapDirectoryIdentifier
    Write-Verbose "Init: LdapDirectoryIdentifier"
    Try
    {
        $ldapDirectoryId = New-Object System.DirectoryServices.Protocols.LdapDirectoryIdentifier($Server, $Port)
    }
    Catch
    {
        Throw "Error instantiating LdapDirectoryIdentifier: $($_.Exception.Message)"
    }
    Write-Verbose "Exit: LdapDirectoryIdentifier"

    # Instantiate LdapConnection
    Write-Verbose "Init: LdapConnection"
    Try
    {
        If ($null -eq $Credential)
        {
            $ldapConnection = New-Object System.DirectoryServices.Protocols.LdapConnection($ldapDirectoryId)
        }
        Else
        {
            $ldapConnection = New-Object System.DirectoryServices.Protocols.LdapConnection($ldapDirectoryId, $Credential.GetNetworkCredential())
        }
    }
    Catch
    {
        Throw "Error instantiating LdapConnection: $($_.Exception.Message)"
    }
    Write-Verbose "Exit: LdapConnection"

    # Instantiate SearchRequest
    Write-Verbose "Init: SearchRequest"
    Try
    {
        $searchReq = New-Object System.DirectoryServices.Protocols.SearchRequest
        $searchReq.DistinguishedName = $BaseDN
        $searchReq.Filter = $Filter
        $searchReq.Scope = [System.DirectoryServices.Protocols.SearchScope]($SearchScope)
        $searchReq.SizeLimit = $SizeLimit
        $searchReq.TimeLimit = [System.Timespan]::FromSeconds($TimeLimitSeconds)
    }
    Catch
    {
        Throw "Error instantiating SearchRequest: $($_.Exception.Message)"
    }
    Write-Verbose "Exit: SearchRequest"

    # Instantiate SendRequest
    Write-Verbose "Init: SendRequest"
    Try
    {
        $searchResponse = [System.DirectoryServices.Protocols.SearchResponse] $ldapConnection.SendRequest($searchReq)
    }
    Catch
    {
        Throw "There was an error searching Active Directory. Error Details: $($_.Exception.Message). `nInnerException: $($_.Exception.InnerException)"
    }
    Write-Verbose "Exit: SendRequest"

    $response = @($searchResponse.Entries)
    Write-Verbose "Results Count: $($response.Count)"

    Return $response
}

<#
.Synopsis
   Generates a report of all certificates issued by the Hybrid Azure AD Foin feature which are stored in Active Directory
   Computer objects.
.DESCRIPTION
   This tool checks for all certificates present in UserCertificate property of a Computer object in AD and, for each
   non-expired certificate present, validates if the certificate was issued for the Hybrid Azure AD join feature
   (i.e. Subject Name is CN={ObjectGUID}).
   Before version 1.4, Azure AD Connect would synchronize to Azure AD any Computer that contained at least one certificate but
   in Azure AD Connect version 1.4 and later, ADSync engine can identify Hybrid Azure AD join certificates and will "cloudfilter"
   (exclude) the computer object from synchronizing to Azure AD unless there's a valid Hybrid Azure AD join certificate present.
   Azure AD Device objects that were already synchronized to AD but do not have a valid Hybrid Azure AD join certificate will be
   deleted from Azure AD (CloudFiltered=TRUE) by AAD Connect.
.EXAMPLE
   Export-ADSyncToolsHybridAadJoinReport -ObjectDN 'CN=Computer1,OU=SYNC,DC=Fabrikam,DC=com'
.EXAMPLE
   Export-ADSyncToolsHybridAadJoinReport -BaseDN 'OU=SYNC,DC=Fabrikam,DC=com' -Filename "MyHybridAzureADjoinReport.csv" -Verbose
.LINK
   More Information: https://docs.microsoft.com/en-us/troubleshoot/azure/active-directory/reference-connect-device-disappearance
#>

Function Export-ADSyncToolsHybridAadJoinReport
{
    [CmdletBinding()]
    Param
    (
        # Computer object's DistinguishedName
        [Parameter(ParameterSetName='SingleObject',
                Mandatory=$true,
                ValueFromPipelineByPropertyName=$true,
                Position=0)]
        [String]
        $ObjectDN,

        # AD OrganizationalUnit
        [Parameter(ParameterSetName='MultipleObjects',
                Mandatory=$true,
                ValueFromPipelineByPropertyName=$true,
                Position=0)]
        [String]
        $BaseDN,

        # Output CSV filename (optional)
        [Parameter(Mandatory=$false,
                ValueFromPipelineByPropertyName=$false,
                Position=1)]
        [String]
        $Filename
    )
    
    Import-ADSyncToolsModule -ModuleName ActiveDirectory -InstallMessage $addsInstallMsg

    # Generate Output filename if not provided
    If ($Filename -eq "")
    {
        $Filename = [string] "$([string] $(Get-Date -Format yyyyMMddHHmmss))_ADSyncAADHybridJoinCertificateReport.csv"
    }
    Write-Verbose "Output filename: '$Filename'"
   
    # Read AD object(s)
    If ($PSCmdlet.ParameterSetName -eq 'SingleObject')
    {
        $directoryObjs = @(Get-ADObject $ObjectDN -Properties UserCertificate)
        Write-Verbose "Starting report for a single object '$ObjectDN'"
    }
    Else
    {
        $directoryObjs = @(Get-ADObject -Filter { ObjectClass -like 'computer' } -SearchBase $BaseDN -Properties UserCertificate)
        Write-Verbose "Starting report for $($directoryObjs.Count) computer objects in '$BaseDN'"
    }

    If ($directoryObjs.Count -gt 0)
    {
        Write-Host "Processing $($directoryObjs.Count) directory object(s). Please wait..."
        # Check Certificates on each AD Object
        $results = @()
        ForEach ($obj in $directoryObjs)
        {
            # Read UserCertificate multi-value property
            $objDN = [string] $obj.DistinguishedName
            $objectGuid = [string] ($obj.ObjectGUID).Guid
            $userCertificateList = @($obj.UserCertificate)
            $validEntries = @()
            $totalEntriesCount = $userCertificateList.Count
            Write-verbose "'$objDN' ObjectGUID: $objectGuid"
            Write-verbose "'$objDN' has $totalEntriesCount entries in UserCertificate property."
            If ($totalEntriesCount -eq 0)
            {
                Write-verbose "'$objDN' has no Certificates - Skipped."
                Continue
            }

            # Check each UserCertificate entry and build array of valid certs
            ForEach($entry in $userCertificateList)
            {
                Try
                {
                    $cert = [System.Security.Cryptography.X509Certificates.X509Certificate2] $entry
                }
                Catch
                {
                    Write-verbose "'$objDN' has an invalid Certificate!"
                    Continue
                }
                Write-verbose "'$objDN' has a Certificate with Subject: $($cert.Subject); Thumbprint:$($cert.Thumbprint)."
                $validEntries += $cert

            }
       
            $validEntriesCount = $validEntries.Count
            Write-verbose "'$objDN' has a total of $validEntriesCount certificates (shown above)."
       
            # Get non-expired Certs (Valid Certificates)
            $validCerts = @($validEntries | Where-Object {$_.NotAfter -ge (Get-Date)})
            $validCertsCount = $validCerts.Count
            Write-verbose "'$objDN' has $validCertsCount valid certificates (not-expired)."

            # Check for AAD Hybrid Join Certificates
            $hybridJoinCerts = @()
            $hybridJoinCertsThumbprints = [string] "|"
            ForEach ($cert in $validCerts)
            {
                $certSubjectName = $cert.Subject
                If ($certSubjectName.StartsWith($("CN=$objectGuid")) -or $certSubjectName.StartsWith($("CN={$objectGuid}")))
                {
                    $hybridJoinCerts += $cert
                    $hybridJoinCertsThumbprints += [string] $($cert.Thumbprint) + '|'
                }
            }

            $hybridJoinCertsCount = $hybridJoinCerts.Count
            If ($hybridJoinCertsCount -gt 0)
            {
                $cloudFiltered = 'FALSE'
                Write-verbose "'$objDN' has $hybridJoinCertsCount AAD Hybrid Join Certificates with Thumbprints: $hybridJoinCertsThumbprints (cloudFiltered=FALSE)"
            }
            Else
            {
                $cloudFiltered = 'TRUE'
                Write-verbose "'$objDN' has no AAD Hybrid Join Certificates (cloudFiltered=TRUE)."
            }
       
            # Save results
            $r = "" | Select ObjectDN, ObjectGUID, TotalEntriesCount, CertsCount, ValidCertsCount, HybridJoinCertsCount, CloudFiltered
            $r.ObjectDN = $objDN
            $r.ObjectGUID = $objectGuid
            $r.TotalEntriesCount = $totalEntriesCount
            $r.CertsCount = $validEntriesCount
            $r.ValidCertsCount = $validCertsCount
            $r.HybridJoinCertsCount = $hybridJoinCertsCount
            $r.CloudFiltered = $cloudFiltered
            $results += $r
        }

        If ($results.Count -gt 0)
        {
            # Export results to CSV
            Try
            {        
                $results | Export-Csv $Filename -NoTypeInformation -Delimiter ';'
                Write-Host "Exported Hybrid Azure AD Domain Join Certificate Report to '$Filename'.`n" -ForegroundColor Cyan
            }
            Catch
            {
                Throw "There was an error saving the file '$Filename': $($_.Exception.Message)"
            }
        }
        Else
        {
            Write-Host "No Hybrid Azure AD Join certificates found." -ForegroundColor Cyan
        }
    }
    Else
    {
        Write-Host "No Computer objects found." -ForegroundColor Cyan
    }
}



<#
.Synopsis
   Gets the current AD DS Connector account(s) configured in Azure AD Connect
.DESCRIPTION
   This function outputs AD DS Connector account(s) from the connectivity parameters configured in Azure AD Connect
.EXAMPLE
   Get-ADSyncToolsADconnectorAccount
#>

Function Get-ADSyncToolsADconnectorAccount
{
    [CmdletBinding()]
    Param()

    Write-Verbose "Enter: Get-ADSyncToolsADconnectorAccount"
    IsAADConnectPresent
        
    # Get AD Connectors
    Try
    {
        $adConnectors = Get-ADSyncConnector -ErrorAction Stop | Where-Object {$_.ConnectorTypeName -eq "AD"}
    }
    Catch
    {
        Throw "Failure getting ADSync Connectors: $($_.Exception.Message)"
    }
    
    # Get AD Connectivity Parameters
    $ADconnectorAccount = @()
    ForEach ($connector in $ADConnectors)
    {
        $connectorForestName = $connector.ConnectivityParameters | Where-Object {$_.Name -eq "forest-name"}
        $connectorAccountDomain = $connector.ConnectivityParameters | Where-Object {$_.Name -like "forest-login-domain"}
        $connectorAccountName = $connector.ConnectivityParameters | Where-Object {$_.Name -like "forest-login-user"}

        $row = "" | Select Name,Forest,Domain,Username
        $row.Name = $connector.Name
        $row.Forest = $connectorForestName.Value
        $row.Domain = $connectorAccountDomain.Value
        $row.Username = $connectorAccountName.Value

        $ADconnectorAccount += $row
    }
    
    Write-Verbose "Exit: Get-ADSyncToolsADconnectorAccount"
    Return $ADconnectorAccount
}


<#
.Synopsis
   Gets the current ADSync service account configured for Azure AD Connect
.DESCRIPTION
   This function outputs the account used by Microsoft Azure AD Sync (ADSync) service
.EXAMPLE
   Get-ADSyncToolsADconnectorAccount
#>

Function Get-ADSyncToolsServiceAccount
{
    [CmdletBinding()]
    Param()

    Write-Verbose "Enter: Get-ADSyncToolsServiceAccount"
    # Get ADSync Service Account from Windows services
    Try
    {
        $cimService = Get-CimInstance -ClassName CIM_Service -ErrorAction Stop | 
            Where Name -eq 'ADSync'| 
                Select Name, StartMode, StartName
    }
    Catch
    {
        Throw "Failure getting CimInstance services information: $($_.Exception.Message)"
    }
    
    If ([string]::IsNullOrEmpty($cimService))
    {
        Throw "Cannot find 'Microsoft Azure AD Sync' (ADSync) service."
    }

    # Check service account type (Domain account / VSA / MSA / gMSA)
    If ($cimService.StartName[$cimService.StartName.Length-1] -eq '$')
    {
        $accountType = "ManagedAccount"
    }
    ElseIf ($cimService.StartName -eq "NT SERVICE\ADSync")
    {
        $accountType = "VSA"
    }
    ElseIf ($($cimService.StartName) -match $netbiosDomainRegex)
    {
        $accountType = "DomainAccount"
    }

    $serviceAccount = "" | select ServiceName,StartMode,ServiceLogOnAs,AccountType
    $serviceAccount.ServiceName = $cimService.Name
    $serviceAccount.StartMode = $cimService.StartMode
    $serviceAccount.ServiceLogOnAs = $cimService.StartName
    $serviceAccount.AccountType = $accountType

    Write-Verbose "Exit: Get-ADSyncToolsServiceAccount"
    Return $serviceAccount
}


<#
.Synopsis
   Diagnostic tool for AADConnect Password Writeback feature
.DESCRIPTION
   Tests a Password Writeback operation (Password Reset) for a given AD Connector Account and a target user account.
 
   Sources:
   NetUserGetInfo function (lmaccess.h) - https://docs.microsoft.com/en-us/windows/win32/api/lmaccess/nf-lmaccess-netusergetinfo
   USER_INFO_1 structure (lmaccess.h) - https://docs.microsoft.com/en-us/windows/win32/api/lmaccess/ns-lmaccess-user_info_1
   IADsUser::SetPassword method (iads.h) - https://docs.microsoft.com/en-us/windows/win32/api/iads/nf-iads-iadsuser-setpassword
   Protected Accounts and Groups in Active Directory - https://docs.microsoft.com/en-us/windows-server/identity/ad-ds/plan/security-best-practices/appendix-c--protected-accounts-and-groups-in-active-directory
.EXAMPLE
   Test-ADSyncToolsPasswordWriteback -Credential $(Get-Credential) -DomainName "Contoso.com" -TargetUser 'user1'
.EXAMPLE
   Test-ADSyncToolsPasswordWriteback -Credential $(Get-Credential) -DomainName "Contoso.com" -TargetUser 'user1' -Server DomainController1.contoso.com
.EXAMPLE
   $creds = Get-Credential
   $creds | Test-ADSyncToolsPasswordWriteback -DomainName "Contoso.com" -TargetUser 'username1'
#>

Function Test-ADSyncToolsPasswordWriteback
{
    [CmdletBinding()]
    Param
    (
        # AD Connector Account credentials
        [Parameter(Mandatory=$true,
                    ValueFromPipelineByPropertyName=$true,
                    ValueFromPipeline=$true,
                    Position=0)]
        [PSCredential]
        $Credential,

        # Target FQDN (e.g. Contoso.com)
        [Parameter(Mandatory=$true,
                    ValueFromPipelineByPropertyName=$true,
                    Position=1)]
        [string]
        $DomainName,

        # Target Username (sAMAccountName)
        [Parameter(Mandatory=$true,
                    ValueFromPipelineByPropertyName=$true,
                    Position=2)]
        [string]
        $TargetUser,

        # Target Domain Controller name
        [Parameter(Mandatory=$false,
                    ValueFromPipelineByPropertyName=$true,
                    Position=3)]
        [string]
        $Server,

        [Parameter(Mandatory=$false,
                    Position=4)]
        [switch]
        $NewPasswordPrompt = $False
    )

    # BEGIN
    $ErrorActionPreference = 'Stop'

    $DS_PDC_REQUIRED = 0x00000080
    $DS_IS_DNS_NAME = 0x00020000
    $DS_RETURN_DNS_NAME = 0x40000000

    $ImpersonatedUser = @{} 
    $tokenHandle = 0 
    $dcBuffer = 0    

    # Set AD Connector Account Credentials
    $Username = $Domain = $Password = $null
    Get-Variable Username, Domain, Password | 
        ForEach-Object { 
            Set-Variable $_.Name -Value $Credential.GetNetworkCredential().$($_.Name)
        } 
    
    # PROCESS
 
    # Impersonate AD Connector Account Credentials
    Write-Host "Attempting to impersonate user '$Username'..."
    Write-Verbose "Attempting LogonUser..."
    $returnValue = [NetApi32]::LogonUser($Username, $Domain, $Password, 2, 3, [ref]$tokenHandle) 
    $Domain = $Password = $Credential = $null
 
    if ($returnValue -eq $false) { 
        $errCode = [System.Runtime.InteropServices.Marshal]::GetLastWin32Error(); 
        Write-Host "Impersonate-User failed a call to LogonUser with error code: $errCode" 
        Throw [System.ComponentModel.Win32Exception]$errCode 
    } 

    Write-Verbose "Attempting ImpersonationContext..."
    $ImpersonatedUser.ImpersonationContext = [System.Security.Principal.WindowsIdentity]::Impersonate($tokenHandle) 
    [void][NetApi32]::CloseHandle($tokenHandle) 
    Write-Host "Impersonating user $([System.Security.Principal.WindowsIdentity]::GetCurrent().Name) ..." -ForegroundColor Green

    # Acquiring target DC
    If ($Server -eq '')
    {
        Write-Verbose "Attempting DsGetDcName for target Domain '$DomainName'..."
        Try
        {
            $result = [NetApi32]::DsGetDcName("", $DomainName, 0, "", $DS_PDC_REQUIRED -bor $DS_IS_DNS_NAME -bor $DS_RETURN_DNS_NAME , [ref]$dcBuffer)
            $dcInfo = [Runtime.InteropServices.Marshal]::PtrToStructure($dcBuffer, [Type] ("NetApi32+DomainControllerInfo" -as [Type]))
        }
        Catch
        {
            Cleanup -Buffer $outBuffer -User $ImpersonatedUser
            Throw "DsGetDcName failure: $($_.Exception.Message) `nInnerException: $($_.Exception.InnerException)"
        }

        If ($dcInfo -eq $null)
        {
            Cleanup -Buffer $outBuffer -User $ImpersonatedUser
            Throw "Domain '$DomainName' not found."
        }

        [Void] [NetApi32]::NetApiBufferFree($dcBuffer)

        # DC Information
        $dcName = $dcInfo.DomainControllerName
        If ($dcName.length -gt 0 -and $dcName.StartsWith("."))
        {
            $dcName = $dcName.Substring(1);
        }
        If($dcName.length -gt 0 -and $dcName.StartsWith("\"))
        {
            $dcName = $dcName.Substring(1);
        }
        If ($dcName.length -gt 0 -and $dcName.StartsWith("\"))
        {
            $dcName = $dcName.Substring(1);
        }
        Write-Host "DC Information: " -NoNewline
        $dcInfo
    }
    Else
    {
        $dcName = $Server
    }

    # Get target user info
    Write-Verbose "Attempting NetUserGetInfo for target user '$TargetUser' against DC '$dcName'..."
    Try
    {
        $outBuffer = 0
        $result = [NetApi32]::NetUserGetInfo($dcName, $TargetUser, 1, [ref]$outBuffer)
    }
    Catch
    {
        Cleanup -Buffer $outBuffer -User $ImpersonatedUser
        Throw "NetUserGetInfo failure: $($_.Exception.Message) `nInnerException: $($_.Exception.InnerException)"
    }

    If ($result -ne 0)
    {
        Write-Host "Impersonate-User failed with error code: $result" 
        Cleanup -Buffer $outBuffer -User $ImpersonatedUser
        Throw [System.ComponentModel.Win32Exception]$result 
    }

    Write-Verbose "Retrieving NetUserGetInfo for target user '$TargetUser'..."
    $userInfo = [Runtime.InteropServices.Marshal]::PtrToStructure($outBuffer, [Type] ("NetApi32+USER_INFO_1" -as [Type]))

    Write-Host "`nTarget User Information" 
    #"sHome_Dir : $($userInfo.sHome_Dir )"
    #"sPassword : $($userInfo.sPassword )"
    #"sScript_Path : $($userInfo.sScript_Path )"
    "sUsername : $($userInfo.sUsername )"
    "uiFlags : $($userInfo.uiFlags )"
    "uiPasswordAge : $($userInfo.uiPasswordAge)"
    "uiPriv : $($userInfo.uiPriv)"

    Write-Host "`nUserAccountControl Flags on target user '$TargetUser': "
    $uiFlags = "" | select PASSWD_CANT_CHANGE, `
        DONT_EXPIRE_PASSWD, `
        MNS_LOGON_ACCOUNT, `
        SMARTCARD_REQUIRED, `
        TRUSTED_FOR_DELEGATION, `
        NOT_DELEGATED, `
        USE_DES_KEY_ONLY, `
        DONT_REQUIRE_PREAUTH, `
        PASSWORD_EXPIRED, `
        TRUSTED_TO_AUTHENTICATE_FOR_DELEGATION, `
        NO_AUTH_DATA_REQUIRED, `
        PARTIAL_SECRETS_ACCOUNT, `
        USE_AES_KEYS, `
        TEMP_DUPLICATE_ACCOUNT, `
        NORMAL_ACCOUNT, `
        INTERDOMAIN_TRUST_ACCOUNT, `
        WORKSTATION_TRUST_ACCOUNT, `
        SERVER_TRUST_ACCOUNT

    $accountFlags = Get-Member -InputObject $uiFlags -MemberType Properties | select -ExpandProperty Name
    ForEach ($f in $accountFlags)
    {
        [bool] $uiFlags.$f = $userInfo.uiFlags -band [NetApi32]::$("UF_"+ $f)
    }
    $uiFlags

    # Result
    If (($userInfo.uiFlags -band [NetApi32]::UF_PASSWD_CANT_CHANGE) -ne 0)
    {
        Cleanup -Buffer $outBuffer -User $ImpersonatedUser
        Throw "ERROR_ACCESS_DENIED: Can't change password for the target user '$TargetUser' (UF_PASSWD_CANT_CHANGE flag)"
    }
    Else
    {
        Write-Host "Impersonate-User executed successfully."
    }
    
    Try
    {
        # Reset Password for user
        Set-TargetUserPassword -DomainName $DomainName -SAMAccountName $TargetUser -NewPasswordPrompt:$([bool]$NewPasswordPrompt)
    }
    Catch
    {
        Cleanup -Buffer $outBuffer -User $ImpersonatedUser
        Throw "Set password failure: $($_.Exception.Message)"
    }

    # END
    Cleanup -Buffer $outBuffer -User $ImpersonatedUser
    Write-Host "Security context returned to previous user $([System.Security.Principal.WindowsIdentity]::GetCurrent().Name)"
}


Function Set-TargetUserPassword
{
    [CmdletBinding()]
    Param
    (
        [Parameter(Mandatory=$true)]
        [string]
        $DomainName,

        [Parameter(Mandatory=$true)]
        [string]
        $SAMAccountName,

        [Parameter(Mandatory=$false)]
        [switch]
        $NewPasswordPrompt
    )

    # Check security policy "\Network access: Restrict clients allowed to make remote calls to SAM"
    Write-Host "Checking Security policy 'Network access: Restrict clients allowed to make remote calls to SAM' (aka. RestrictRemoteSAM) under 'Computer Configuration|Windows Settings|Security Settings|Local Policies|Security Options'..."
    $restrictRemoteSam = Get-ItemProperty -Path HKLM:\System\CurrentControlSet\Control\Lsa | select -ExpandProperty RestrictRemoteSAM -ErrorAction Ignore    
    If ($restrictRemoteSam -ne $null)
    {
        Write-Warning "RestrictRemoteSAM policy is present. Password Writeback might not work if the AD DS Connector account is not allowed in RestrictRemoteSAM policy."
    }
    Write-Host "RestrictRemoteSAM policy must also be disabled on the DC side. Type 'Get-ItemProperty -Path HKLM:\System\CurrentControlSet\Control\Lsa | select RestrictRemoteSAM' on the target DC to confirm if RestrictRemoteSAM policy is present." -ForegroundColor Yellow

    # Get target user from AD
    Write-Verbose "Seaching for target user '$SAMAccountName'..."
    $targetUser = Find-TargetUser -DomainName $DomainName -SAMAccountName $SAMAccountName

    If ($targetUser -eq $null)
    {
        Write-Error "User '$SAMAccountName' not found in domain '$DomainName'."
        Return
    }

    # Check if is a Protected Account (adminCount == 1)
    If ($targetUser.Properties.admincount -eq '1')
    {
        Write-Error "User '$($targetUser.Path)' is a Protected Account (adminCount == 1)."
        Return
    }

    # Check if AD permissions inheritance is disabled
    $adObject = $targetUser.GetDirectoryEntry()
    If ($adObject.ObjectSecurity.AreAccessRulesProtected)
    {
        Write-Error "User '$($targetUser.Path)' has AD permissions inheritance disabled."
        Return
    }
    
    Write-Verbose "Target user DN: $($targetUser.Path)"
    Try
    {
        $oTargetUser = [adsi] $targetUser.Path
    }
    Catch
    {
        Write-Error "ADSI failure: $($_.Exception.Message) `nInnerException: $($_.Exception.InnerException)"
        Return
    }
    
    
    If ($NewPasswordPrompt)
    {
        $pwdStr1 = Read-Host "New Password" -AsSecureString
        $pwdStr2 = Read-Host "Confirm Password" -AsSecureString
        $pwd = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto([System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($pwdStr1))
        If ([string]::IsNullOrEmpty($pwd))
        {
            Throw "Invalid password. Please try again."
        }
        If ([System.Runtime.InteropServices.Marshal]::PtrToStringAuto([System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($pwdStr2)) -ne $pwd)
        {
            Throw "Passwords don't match. Please try again."
        }
    }
    Else
    {
        # Use random string
        $pwd = "O6FYO0&rRvJG5tzlOw55"
    }

    Write-Host "`nAttempting to reset password for user '$SAMAccountName'..."
    Try
    {
        $oTargetUser.psbase.invoke('SetPassword',$pwd)
        $oTargetUser.psbase.CommitChanges()
    }
    Catch
    {
        Write-Error "ADSI failure: $($_.Exception.Message) `nInnerException: $($_.Exception.InnerException)"
        Return
    } 

    Write-Host "`nPassword Reset for user '$SAMAccountName' terminated successfully..." -ForegroundColor Green
    # Wait for AD replications
    Start-Sleep -Seconds 5
    
    # Get Last password changed time:
    $targetUser = Find-TargetUser -DomainName $DomainName -SAMAccountName $SAMAccountName
    Try
    {
        [string] $pwdLastChanged = ([datetime]::FromFileTime($targetUser.Properties.pwdlastset[0])).DateTime
    }
    Catch
    {
        [string] $pwdLastChanged = $targetUser.Properties.pwdlastset
    }
    Write-Host "`nLast password changed time: $pwdLastChanged"
}

#TODO: replace with Search-ADSyncToolsADobject
Function Find-TargetUser
{
    [CmdletBinding()]
    Param
    (
        [Parameter(Mandatory=$true)]
        [string]
        $DomainName,

        [Parameter(Mandatory=$true)]
        [string]
        $SAMAccountName
    )

    $root = [ADSI]''
    $searcher = New-Object -TypeName System.DirectoryServices.DirectorySearcher -ArgumentList ($DomainName)
    $searcher.Filter = "(&(objectClass=User)(sAMAccountName=$SAMAccountName))"
    $user = $searcher.FindAll()
    Return $user
}


Function Cleanup
{
    [CmdletBinding()]
    Param
    (
        [Parameter(Mandatory=$true)]
        [object]
        $Buffer,

        [Parameter(Mandatory=$true)]
        [object]
        $User
    )

    Write-Verbose "Cleaning up..."
    [void][NetApi32]::NetApiBufferFree($buffer)
    $user.ImpersonationContext.Undo() 
    $user.ImpersonationContext.Dispose()
}


<#
.Synopsis
   Automates troubleshooting with Single Object Sync tool
.DESCRIPTION
   Run the Single Object Sync tool from ADSyncDiagnostics and saves the results to a json file in the current directory.
.EXAMPLE
   Start-ADSyncToolsSingleObjectSync -DistinguishedName "CN=User1,OU=Corp,DC=Contoso,DC=com"
#>

Function Start-ADSyncToolsSingleObjectSync
{
    [CmdletBinding()]
    Param
    (
        # DistinguishedName of the target object
        [Parameter(Mandatory=$true,
                   ValueFromPipelineByPropertyName=$true,
                   Position=0)]
        [string]
        $DistinguishedName
    )

    IsAADConnectPresent -MinVersion '1.6.2.4'

    Write-Warning "This operation will temporarily stop the Sync Scheduler. Do you want to continue?" -WarningAction Inquire
    If (IsSyncCycleRunning)
    {
        Throw "Azure AD Connect is currently running a sync scheduler." 
    }

    Set-ADSyncScheduler -SyncCycleEnabled $false
    $adSyncLocation = Get-ADSyncToolsADsyncFolder
    If (-not [string]::IsNullOrEmpty($adSyncLocation))
    {
        Try
        {
            Import-Module "$($adSyncLocation)Bin\ADSyncDiagnostics\ADSyncDiagnostics.psm1" -ErrorAction Stop
        }
        Catch
        {
            Set-ADSyncScheduler -SyncCycleEnabled $true
            Throw "Cannot import ADSyncDiagnostics Module: $($_.Exception.InnerException)"
        }

        $reportFilename = "C:\ProgramData\AADConnect\ADSyncObjectDiagnostics\ADSyncSingleObjectSyncResult-$(Get-Date -Format yyyyMMddHHmmss)"
        $result = Invoke-ADSyncSingleObjectSync -DistinguishedName $DistinguishedName
    
        if ($result)
        {
            $result | Out-File -FilePath "$($reportFilename).json"
            Write-Host "`nSingle Object Sync report saved in '$reportFilename'`n" -ForegroundColor Green
        }
    }
    Set-ADSyncScheduler -SyncCycleEnabled $true
}


#endregion
#=======================================================================================




#=======================================================================================
#region Automated Logman (ETW) tracing
#=======================================================================================

Function Checkpoint-ADSyncToolsLogmanTrace
{
    [CmdletBinding()]
    Param
    (
        # Command (Init, Start, Stop)
        [Parameter(Mandatory=$true,
                   ValueFromPipelineByPropertyName=$true,
                   Position=0)]
        [ValidateSet('Init', 'Start', 'Stop')]
        [string]
        $Command
    )
    # Get current datetime
    $currentDateTime = Get-Date
    $currentTime = Get-Date $currentDateTime -Format yyyyMMdd-HHmmss
    $currentTimeUTC = Get-Date ($currentDateTime.ToUniversalTime()) -Format "yyyy-MM-dd HH:mm:ss"
    $logName = 'ADSyncTools-SyncTrace'
    
    if ($Command -eq 'Init')
    {
        # Save old log file
        Rename-Item "$logName.log" "$($logName)_$currentTime.log" -ErrorAction Ignore
        # Init log file
        "DateTime,DateTime (UTC),Status"  | Out-File -FilePath "$logName.log"
        [bool] $script:ADSyncToolsLogmanLogInit = $true
    }

    If (-not $script:ADSyncToolsLogmanLogInit)
    {
        Checkpoint-ADSyncToolsLogmanTrace -Command Init
    }

    # Save log entry
    "$currentTime,$currentTimeUTC (UTC),$Command"  | Out-File -FilePath "$logName.log" -Append

    # Return filename for ETL trace
    if ($Command -eq 'Start')
    {
        Return "$($logName)_$currentTime.etl"
    }
}


Function Trace-ADSyncToolsLogmanTrace
{
    [CmdletBinding()]
    Param
    (
        # No output
        [Parameter(Mandatory=$false,
                   ValueFromPipelineByPropertyName=$true,
                   Position=0)]
        [switch]
        $NullOutput
    )

    $filename = Checkpoint-ADSyncToolsLogmanTrace 'Start'
    
    # start logman
    $cmd = 'logman.exe'
    $arg1 = 'start'
    $arg2 = 'mysession'
    $arg3 = '-p'
    $arg4 = '{cec61b36-75f2-44b3-ba80-177955c0db12}'
    $arg5 = '-o'
    $arg6 = $filename
    $arg7 = '-ets'
    Write-Verbose "Starting trace: $cmd $arg1 $arg2 $arg3 $arg4 $arg5 $arg6 $arg7"
    $output = & $cmd $arg1 $arg2 $arg3 $arg4 $arg5 $arg6 $arg7
    If ($LASTEXITCODE -ne 0)
    {
        If ($output -like "*Data Collector Set already exists*")
        {
            $output += "Use 'Stop-ADSyncToolsLogmanTrace' to stop the current trace."
        }
        Throw $output
    }
    If (-not $NullOutput)
    {
        $output
    }
}

<#
.Synopsis
   Stops the automated ETW trace on each synchronization cycle
.DESCRIPTION
   To be used when ETW tracing is already running. Check 'Start-ADSyncToolsLogmanTrace' help for more details:
        Get-Help Start-ADSyncToolsLogmanTrace -Full
.EXAMPLE
   Stop-ADSyncToolsLogmanTrace
#>

Function Stop-ADSyncToolsLogmanTrace
{
    [CmdletBinding()]
    Param
    (
        # No output
        [Parameter(Mandatory=$false,
                   ValueFromPipelineByPropertyName=$true,
                   Position=0)]
        [switch]
        $NullOutput
    )
    
    Checkpoint-ADSyncToolsLogmanTrace 'Stop'

    #end logman
    $cmd = 'logman.exe'
    $arg1 = 'stop'
    $arg2 = 'mysession'
    $arg7 = '-ets'
    Write-Verbose "Stopping trace: $cmd $arg1 $arg2 $arg7"
    $output = & $cmd $arg1 $arg2 $arg7  
    If ($LASTEXITCODE -ne 0)
    {
        Throw $output
    }
    If (-not $NullOutput)
    {
        $output
    }
}


<#
.Synopsis
   Starts an automated ETW trace on each synchronization cycle
.DESCRIPTION
   When using ETW tracing to troubleshoot synchronization issues on a large deployment, ETL files can grow rapidly.
   With this tool, you can leave ETW tracing running but a separated ETL file will be created for every sync cycle.
   The tool will also create a log file 'ADSyncTools-SyncTrace.log' on the same folder where you can check for ETW
   tracing activity.
   It is recommended that you go to a temporary folder as all the files will be created on the current directory.
   To use this cmdlet, you need to first configure miiserver.exe.config file for verbose logging by following these
   instructions:
 
     1. Edit the file "C:\Program Files\Microsoft Azure AD Sync\Bin\miiserver.exe.config"
     2. For each source that you want to trace, set the switchValue to Verbose level:
            switchValue="Verbose"
     3. Restart the ADSync Service to pick up the new config settings
     4. Open a PowerShell session with "Run As Administrator"
     5. Go to the target folder where the trace files will be created, e.g.:
            C:\Temp\ADSyncToolsLogmanTrace\
     5. Start tracing the sync cycles with:
            Start-ADSyncToolsLogmanTrace
     6. Leave the script running while ETW traces are being captured.
     7. When you're done capturing ETW traces, press CTRL+C to stop monitoring sync cycles and stop the current
        running trace with:
            Stop-ADSyncToolsLogmanTrace
 
.EXAMPLE
   Start-ADSyncToolsLogmanTrace
.EXAMPLE
   Start-ADSyncToolsLogmanTrace -SleepTimerSecs 10
#>

Function Start-ADSyncToolsLogmanTrace
{
    [CmdletBinding()]
    Param(
        # Whether to trace sync cycles continuously
        [Parameter(Mandatory=$false,
                   Position=0)]
        [switch] 
        $SyncSycleMonitoring,

        # Wait time to check for the next sync cycle
        [Parameter(Mandatory=$false,
                   ValueFromPipelineByPropertyName=$true,
                   ValueFromPipeline=$true,
                   Position=1)]
        [int] 
        $SleepTimerSecs = 60
    )
    
    $VerboseLevel = Confirm-ADSyncToolsLogmanTraceLevel

    If ($VerboseLevel)
    {
        $script:ADSyncToolsLogmanLogInit = $false

        If ($SyncSycleMonitoring)
        {
            Write-Host "Press CTRL+C any time to stop monitoring Sync Scheduler and type 'Stop-ADSyncToolsLogmanTrace' to stop the current trace." -ForegroundColor Cyan
            Do
            {
                $currentSyncCycleTime = Get-Date $((Get-ADSyncScheduler).NextSyncCycleStartTimeInUTC)
                if ($currentSyncCycleTime -le $startedSyncCycleTime)
                {
                    Write-Verbose "Waiting for next Sync cycle (Sleeping)..."
                    Start-Sleep -Seconds $SleepTimerSecs
                }
                Else
                {
                    # Start new ETl trace
                    Write-Verbose "Sync cycle started (Starting ETW trace)..."
                    If ($startedSyncCycleTime -ne $null)
                    {
                        Write-Verbose "Sync cycle ended (Stopping ETW trace)..."
                        Stop-ADSyncToolsLogmanTrace -NullOutput
                    }
                    Trace-ADSyncToolsLogmanTrace -NullOutput
                    $startedSyncCycleTime = $currentSyncCycleTime
                    Write-Verbose "startedSyncCycleTime = currentSyncCycleTime = $currentSyncCycleTime"
                }
            }
            While ($true)
        }
        Else
        {
            Trace-ADSyncToolsLogmanTrace
            Write-Host "Tracing has started, you can now reproduce the issue and then use 'Stop-ADSyncToolsLogmanTrace' to stop the current trace.`n" -ForegroundColor Green
        }
        
    }
}        


<#
.Synopsis
   Searches a given SourceObjectId in ADSync database and returns the respective object name
   e.g. DistinguishedName
#>

Function Resolve-ADSyncToolsObjectName
{
    [CmdletBinding()]
    [Alias()]
    [OutputType([string])]
    Param
    (
        # In-Memory cache with ObjectId mappings
        [Parameter(Mandatory=$true,
                   Position=0)]
        [ref] 
        $CacheTable,

        # Source of the object to lookup (ConnectorSpace / Metaverse)
        [Parameter(Mandatory=$true,
                   ValueFromPipelineByPropertyName=$true,
                   Position=1)]
        [ValidateSet('ConnectorSpace','Metaverse')]
        $Source,

        # Source object Identifier (GUID)
        [Parameter(Mandatory=$true,
                   ValueFromPipelineByPropertyName=$true,
                   Position=2)]
        $SourceObjectId,

        # Include the original SourceObjectId in the results
        [Parameter(Mandatory=$false,
                   Position=3)]
        [switch]
        $IncludeGuid = $false

    )
    Write-Verbose "Entering: Resolve Object: $source | $SourceObjectId"

    # Check if mapping already exists
    $findEntry = $CacheTable.Value[$SourceObjectId]
    If ($null -eq $findEntry)
    {
        # Find object in database
        Write-Verbose "Object not found in cache: $SourceObjectId"
        $objectName = $null
        switch ($Source)
        {
            'ConnectorSpace' 
            {
                Try
                {
                    $csObj = Get-ADSyncCSObject -Identifier $SourceObjectId -ErrorAction Stop
                }
                Catch
                {
                    If ($_.Exception.Message -match 'Invalid connector space object xml')
                    {
                        Write-Verbose "Object not found in Connector Space, searching in MV..."
                        Return (Resolve-ADSyncToolsObjectName -CacheTable $CacheTable -Source Metaverse -SourceObjectId $SourceObjectId)
                    }
                    Else
                    {
                        Write-Verbose "Object not found in Connector Space. Error Details: $($_.Exception.Message)"
                        Write-Error "Error occurred finding SourceObjectId ' $SourceObjectId' in '$Source'. Error Details: $($_.Exception.Message)"
                        $objectName = "SearchError"
                        $IncludeGuid = $true
                    }
                }

                If ([string]::IsNullOrEmpty($objectName))
                {
                    $objectName = Get-ADSyncToolsObjectNameFromCS -CsObj $csObj
                    # Note: no [ref] here because $CacheTable is already a [ref] inside this function
                    Add-ADSyncToolsLogmanTraceCache -CacheTable $CacheTable -Name $objectName -Value $SourceObjectId
                }
            }
            'Metaverse' 
            {
                Try
                {
                    $mvObj = Get-ADSyncMVObject -Identifier $SourceObjectId -ErrorAction Stop
                }
                Catch
                {
                    Write-Warning "Object '$SourceObjectId' not found in ConnectorSpace/Metaverse. Details: $($_.Exception.Message)"
                    $objectName = "ObjectNotFound"
                    $IncludeGuid = $true
                }

                If ([string]::IsNullOrEmpty($objectName))
                {
                    $objectName = Get-ADSyncToolsObjectNameFromMV -MvObj $mvObj
                }
                Add-ADSyncToolsLogmanTraceCache -CacheTable $CacheTable -Name $objectName -Value $SourceObjectId
            }
            Default 
            {
                Throw "Unexpected parameter value. Please provide a valid 'Source' value."
            }
        }
    }
    Else
    {
        $objectName = $findEntry
        Write-Verbose "Object mapping already in memory cache: $SourceObjectId ; $findEntry"
    }

    If ($IncludeGuid)
    {
        $objectName += "{$SourceObjectId}"
    }
    Write-Verbose "Exiting: Resolve Object: $source | $SourceObjectId | $objectName"
    
    Return $objectName
}


Function Get-ADSyncToolsObjectNameFromMV
{
    [CmdletBinding()]
    [Alias()]
    Param
    (
        # Metaverse Object
        [Parameter(Mandatory=$true,
                   Position=0)]
        $MvObj

    )
    Write-Verbose "Entering: Get-ADSyncToolsObjectNameFromMV"

    # Metaverse object attribute list
    $AttributeNamePriority = @(
        'distinguishedName',   # 1st priority
        'userPrincipalName',             # 2nd, if a user
        'cn',                            # 3rd, if no distinguishedName (same as 'commonName')
        'mailNickname',                  # 4th, if a GroupWriteback (same as 'alias')
        'cloudAnchor'                    # finally, as a last resort
    )

    # Get attribute value from priority list
    [string] $objectName = $null
    ForEach ($a in $AttributeNamePriority)
    {
        $objectName = $MvObj.Attributes[$a].Values
        If (-not ([string]::IsNullOrEmpty($objectName)))
        {
            Break
        }
    }
    $namePrefix = "MV|'"

    If ([string]::IsNullOrEmpty($objectName))
    {
        $objectName = "AttributeValueNotFound"
    }

    # Get MV object type
    Try
    {
        [xml] $objXml = $MvObj.SerializedXml
    }
    Catch
    {
        Write-Error "There was an error getting the object type. Error Details: $($_.Exception.Message)"
    }
    $objType = $objXml.'mv-objects'.'mv-object'.entry.'primary-objectclass'.'#text'

    Write-Verbose "Exiting: Get-ADSyncToolsObjectNameFromMV"
    Return ($namePrefix + $objectName + "'(" + $objType + ")")
}


Function Get-ADSyncToolsObjectNameFromCS
{
    [CmdletBinding()]
    [Alias()]
    Param
    (
        # Connector Space Object
        [Parameter(Mandatory=$true,
                   Position=0)]
        $CsObj

    )

    Write-Verbose "Entering: Get-ADSyncToolsObjectNameFromCS"

    If ($CsObj.ConnectorId -eq 'b891884f-051e-4a83-95af-2544101c9083') 
    {
        # AAD CS object
        Write-Verbose "AAD CS Object."
        $AttributeNamePriority = @(
            'onPremisesDistinguishedName',   # 1st priority
            'userPrincipalName',             # 2nd, if a user
            'commonName',                    # 3rd, if no onPremisesDistinguishedName
            'alias',                         # 4th, if a GroupWriteback
            'cloudAnchor'                    # finally, as a last resort (if a group)
        )

        # Get attribute value from priority list
        [string] $objectName = $null
        ForEach ($a in $AttributeNamePriority)
        {
            $objectName = $CsObj.Attributes[$a].Values
            Write-Verbose "Attribute value from '$a' = '$objectName'"
            If (-not ([string]::IsNullOrEmpty($objectName)))
            {
                Break
            }
        }
        $namePrefix = "AADCS|'"
    }
    Else 
    {
        # AD CS object - Should always have a DistinguishedName
        Write-Verbose "AD CS Object."
        $objectName = $CsObj.DistinguishedName
        $namePrefix = "ADCS|'"
    }

    If ([string]::IsNullOrEmpty($objectName))
    {
        $objectName = "AttributeValueNotFound"
    }
    
    # Get MV object type
    $objType = $CsObj.ObjectType

    Write-Verbose "Exiting: Get-ADSyncToolsObjectNameFromCS"
    Return ($namePrefix + $objectName + "'(" + $objType + ")")
}


Function Initialize-ADSyncToolsLogmanTraceCache
{
    [CmdletBinding()]
    [Alias()]
    Param
    (
        # In-Memory cache with ObjectId mappings
        [Parameter(Mandatory=$true,
                   Position=0)]
        [ref] 
        $CacheTable,

        # Disk cache filename
        [Parameter(Mandatory=$true,
                   Position=1)]
        [string]
        $CacheFilename

    )
    Write-Verbose "Entering Cache Init: $CacheFilename"

    If (-not (Test-Path $CacheFilename))
    {
        Write-Verbose "Cache not found, creating new file."
        # Create disk cache file
        Set-Content $CacheFilename "guid;name"

        # Add Null Guid 00000000-0000-0000-0000-000000000000
        Add-Content $CacheFilename "00000000-0000-0000-0000-000000000000;00000000-0000-0000-0000-000000000000"
        
        # Add ConnectorIds
        Get-ADSyncConnector | select Name,Identifier | %{
            Add-Content $CacheFilename "$($_.Identifier);Connector|$($_.Name)"
        }

        # Add SyncRules
        Get-ADSyncRule | select Name,Identifier | %{
            Add-Content $CacheFilename "$($_.InternalId);SyncRule|$($_.Name)"
        }
    }

    # Import disk cache to memory
    Import-CSV -Path $CacheFilename -Delimiter ';' | %{
        $CacheTable.Value.Add($_.guid,$_.name)
    }

    Write-Verbose "Exiting Cache Init: $($CacheTable.Value.Count) objects in cache."
}


Function Add-ADSyncToolsLogmanTraceCache
{
    [CmdletBinding()]
    [Alias()]
    Param
    (
        # In-Memory cache with ObjectId mappings
        [Parameter(Mandatory=$true,
                   Position=0)]
        [ref] 
        $CacheTable,

        # Name of the object to add
        [Parameter(Mandatory=$true,
                   Position=1)]
        [string]
        $Name,

        # Guid Value of the object to add
        [Parameter(Mandatory=$true,
                   Position=2)]
        [string]
        $Value
    )
    Write-Verbose "Entering: Add Object to cache."

    # Check if mapping already exists
    $findEntry = $CacheTable.Value[$Value]
    If ($findEntry -eq $null)
    {
        Write-Verbose "Adding object mapping to memory cache: $Value ; $Name"
        $CacheTable.Value.Add($Value, $Name)
    }
    Else
    {
        Write-Verbose "Object mapping already in memory cache: $Value ; $Name"
    }
    Write-Verbose "Exiting: Add Object to cache."
}


Function Save-ADSyncToolsLogmanTraceCache
{
    [CmdletBinding()]
    [Alias()]
    Param
    (
        # In-Memory cache with ObjectId mappings
        [Parameter(Mandatory=$true,
                   Position=0)]
        [ref] 
        $CacheTable,

        # Disk cache filename
        [Parameter(Mandatory=$true,
                   Position=1)]
        [string]
        $CacheFilename
    )
    Write-Verbose "Entering: Save cache to disk: $CacheFilename"

    Try
    {
        # Export ObjectId mappings to disk cache file
        $CacheTable.Value.GetEnumerator() |
            Select-Object -Property @{Name='guid';Expression={$_.Key}}, @{Name='name';Expression={$_.Value}} |
                Export-Csv -NoTypeInformation -Path $CacheFilename -Delimiter ';'
    }
    Catch
    {
        Throw "There was an error exporting CSV file. Error Details: $($_.Exception.Message)"
    }
    
    Write-Verbose "Exiting: Save cache to disk: $CacheFilename | $($CacheTable.Value.Count) entries"
}


Function Find-ADSyncToolsMiiserverExeConfig
{
    [CmdletBinding()]
    [Alias()]
    Param
    ()

    $configFilename = 'miiserver.exe.config'
    Try
    {
        $configFilePath = "$(Get-ADSyncToolsADsyncFolder)bin\$configFilename"
        Write-Verbose "$configFilename file: $configFilePath"
    }
    Catch
    {
        Throw "There was an error getting the '$configFilename' file. Error Details: $($_.Exception.Message)"
    }

    Return $configFilePath
}


Function Import-ADSyncToolsMiiserverExeConfig
{
    [CmdletBinding()]
    [Alias()]
    Param
    ()

    $configFilename = Find-ADSyncToolsMiiserverExeConfig
    Try
    {
        [xml] $configXml = Get-Content $configFilename -ErrorAction Stop
        Write-Verbose "$configFilename parsed."
    }
    Catch
    {
        Throw "There was an error parsing the '$configFilename' file. Error Details: $($_.Exception.Message)"
    }

    Return $configXml
}


Function Export-ADSyncToolsMiiserverExeConfig
{
    [CmdletBinding()]
    [Alias()]
    Param
    (
        # XML config data
        [Parameter(Mandatory=$true,
                   Position=0)]
        [xml] 
        $ConfigXml
    )
    $currentConfigFile = Find-ADSyncToolsMiiserverExeConfig

    # Backup current config file
    $backupConfigFile = $currentConfigFile + "_$(Get-Date -Format yyyyMMdd-HHmmss).bak"

    Try
    {
        Rename-Item $currentConfigFile $backupConfigFile -ErrorAction Stop
    }
    Catch
    {
        Throw "There was an error saving a backup of the '$configFilename' file. Error Details: $($_.Exception.Message)"
    }

    # Save the new config
    Try
    {
        $ConfigXml.Save($currentConfigFile)
    }
    Catch
    {
        Throw "There was an error saving a backup of the '$configFilename' file. Error Details: $($_.Exception.Message)"
    }
}


<#
.Synopsis
   Gets the current ETW trace level for SyncRulesPipeline debugging
.DESCRIPTION
   You can use this function to check what is the current ETW trace level.
.EXAMPLE
   Get-ADSyncToolsLogmanTraceLevel
#>

Function Get-ADSyncToolsLogmanTraceLevel
{
    [CmdletBinding()]
    [Alias()]
    Param
    ()
    
    [xml] $configXml = Import-ADSyncToolsMiiserverExeConfig
    Write-Verbose "Config data: $configXml"
    Return ($configXml.configuration.'system.diagnostics'.sources.source)
}


<#
.Synopsis
   Sets the ETW trace level (Warning/Verbose) for SyncRulesPipeline debugging
.DESCRIPTION
   By default ETW trace level of SyncRulesPipeline is 'Warning'. You can use this function to
   change ETW trace level to 'Verbose'.
   To check what is the current ETW trace level you can use 'Get-ADSyncToolsLogmanTraceLevel'.
   This funtion must be run in an elevated PowerShell window.
.EXAMPLE
   Set-ADSyncToolsLogmanTraceLevel -Level 'Verbose'
.EXAMPLE
   Set-ADSyncToolsLogmanTraceLevel -Level 'Warning'
#>

Function Set-ADSyncToolsLogmanTraceLevel
{
    [CmdletBinding()]
    [Alias()]
    Param
    (
        # Level of ETW traces produced by the different module sources in the SyncRulesPipeline (Default='Warning')
        [Parameter(Mandatory=$true,
                   Position=0)]
        [ValidateSet('Warning','Verbose', IgnoreCase = $false)]
        $Level
    )
    
    $verboseLevel = Confirm-ADSyncToolsLogmanTraceLevel -SkipWarning
    If (($verboseLevel -and $Level -eq 'Verbose') -or
        ((-not $verboseLevel) -and $Level -eq 'Warning'))
    {
        # Trace Level already set
        Write-Host "No changes needed in config file.`n" -ForegroundColor Cyan
    }
    Else
    {
        [xml] $configXml = Import-ADSyncToolsMiiserverExeConfig
        Write-Verbose "Config data: $configXml"
        Try
        {
            $configXml.configuration.'system.diagnostics'.sources.source | %{$_.switchValue = $Level}
            Write-Verbose "Config updated with level: $Level"
        }
        Catch
        {
            Throw "There was an error setting trace level from the '$configFilename' file. Error Details: $($_.Exception.Message)"
        }
    
        Export-ADSyncToolsMiiserverExeConfig -ConfigXml $configXml
        Write-Verbose "Config saved: $configXml"
        Write-Host "Trace level set to '$Level'.`n" -ForegroundColor Green

        $srvName = "'Microsoft Azure AD Sync' (ADsync) service"
        $title    = "Changes to config file require $srvName restart.`n"
        $question = "Restart $srvName now?"
        $choices  = '&Yes', '&No'

        $decision = $Host.UI.PromptForChoice($title, $question, $choices, 1)
        If ($decision -eq 0) 
        {
            Try
            {
                Restart-Service ADSync -ErrorAction Stop
                If ($Level -eq 'Verbose')
                {
                    Write-Host "Changes in config file applied successfully. Use 'Start-ADSyncToolsLogmanTrace' to start ETW tracing.`n" -ForegroundColor Green
                }
                Else
                {
                    Write-Host "Changes in config file applied successfully.`n" -ForegroundColor Green
                }
            }
            Catch
            {
                Write-Error "There was an error restarting $srvName. Error Details: $($_.Exception.Message)"
            }
        } 
        Else 
        {
            Write-Host "Changes in config file are pending $srvName restart.`n" -ForegroundColor Cyan
        }
    }
}


Function Confirm-ADSyncToolsLogmanTraceLevel
{
    [CmdletBinding()]
    [Alias()]
    Param
    (
        # Supress warning
        [Parameter(Mandatory=$false,
                   Position=0)]
        [switch]
        $SkipWarning
    )

    $configXml = Import-ADSyncToolsMiiserverExeConfig

    $syncModules = $configXml.configuration.'system.diagnostics'.sources.source
    Write-Host ""
    Write-Host "Current config has the following trace level:"
    $syncModules | select name, switchValue | %{Write-Host "$($_.Name) = $($_.switchValue)"}
    Write-Host ""

    $verboseModules = $($syncModules | where {$_.switchValue -eq 'Verbose'})
    If ($verboseModules.Count -lt 1)
    {
        # Sync Modules not set with verbose tracing
        If (-not $SkipWarning)
        {
            Write-Warning "Current config does not have 'Verbose' tracing enabled.`n"
            Write-Host "`nTo set the module sources in the SyncRulesPipeline for Verbose level use:"
            Write-Host " Set-ADSyncToolsLogmanTraceLevel -Level Verbose`n"
        }
        Return $false
    }
    Else
    {
        # Sync Modules set with verbose tracing
        Return $true
    }

}


<#
.Synopsis
    Decodes an ETW trace for SyncRulesPipeline debugging into a CSV text file
.DESCRIPTION
   IMPORTANT: This function must be executed on the same AADConnect server where the ETW trace was captured.
   This function takes an .ETL file containing a SyncRulesPipeline ETW trace and decodes it to a CSV file.
.EXAMPLE
   Convert-ADSyncToolsLogmanTrace -Path '.\SyncEventTrace.etl'
#>


Function Convert-ADSyncToolsLogmanTrace
{
    [CmdletBinding()]
    [Alias()]
    Param
    (
        # Path to ETL trace file
        [Parameter(Mandatory=$true,
                   ValueFromPipelineByPropertyName=$true,
                   Position=0)]
        [string]
        $Path
    )

    Try
    {
        $inputFile = Get-Item $Path -ErrorAction Stop
    }
    Catch
    {
        Throw "There was an error opening the file '$Path'. Error Details: $($_.Exception.Message)"
    }

    If ($inputFile.Extension -ne '.etl')
    {
        Throw "Invalid ETW trace file, please provide a '.etl' file to convert."

    }
    
    # Execute tracerpt
    # e.g.: tracerpt synctrace.etl -o SyncEventTrace.csv -of CSV
    [string] $outputFile = $Path + "-converted.csv"
    
    $cmd = 'tracerpt.exe'
    $arg1 = $Path
    $arg2 = '-o'
    $arg3 = $outputFile
    $arg4 = '-of'
    $arg5 = 'CSV'

    Write-Verbose "Starting trace: $cmd $arg1 $arg2 $arg3 $arg4 $arg5"
    & $cmd $arg1 $arg2 $arg3 $arg4 $arg5 $arg6 $arg7
    " "
    If ($LASTEXITCODE -ne 0)
    {
        Throw "An unexpected error occurred."
    }
}


<#
.Synopsis
   Decodes an ETW trace for SyncRulesPipeline debugging and translates the ObjectIds to object names
.DESCRIPTION
   IMPORTANT: This function must be executed on the same AADConnect server where the ETW trace was captured.
   This function takes an .ETL file containing a SyncRulesPipeline ETW trace and decodes it to a CSV file. Same as
     Convert-ADSyncToolsLogmanTrace.
   Then, it parses the CSV file and translates every ObjectId (GUID) to the respective object name, i.e. DistinguishedName
     by querying the ADSync database.
   The process of translating the ObjectIds uses a cache (persisted to disk as 'SyncEventTrace-cache.csv') to help speed up
     the decoding of new ETL traces and reduce the load on the ADSync database.
   To capture an ETW trace for SyncRulesPipeline debugging use 'Start-ADSyncToolsLogmanTrace'
.EXAMPLE
   Resolve-ADSyncToolsLogmanTrace -Path '.\SyncEventTrace.etl'
#>

Function Resolve-ADSyncToolsLogmanTrace

{
    [CmdletBinding()]
    [Alias()]
    Param
    (
        # Path to ETL trace file
        [Parameter(Mandatory=$true,
                   ValueFromPipelineByPropertyName=$true,
                   Position=0)]
        [string]
        $Path
    )

    IsAADConnectPresent -MinVersion '1.6'
    Import-ADSyncToolsModule -ModuleName ADSync -InstallMessage $adSyncInstallMsg
    Convert-ADSyncToolsLogmanTrace -Path $Path

    [string] $sourceFilename = $Path + "-converted.csv"
    [string] $targetFilename = $Path + "-translated.csv"
    Write-Verbose "sourceFilename = $sourceFilename | targetFilename = $targetFilename"

    # Init Cache
    $CacheTable = @{}
    [string] $cacheFilename = (Split-Path $Path) + "\SyncEventTrace-cache.csv"
    Initialize-ADSyncToolsLogmanTraceCache -CacheTable ([ref] $CacheTable) -CacheFilename $cacheFilename

    # Process Sync Event Trace
    Write-Host "Reading Sync Event Trace file into memory. Please wait..." -ForegroundColor Cyan
    $syncEventTrace = Get-Content $sourceFilename

    # Validate Sync Event Trace Header
    [int] $UserDataCol = 19
    $syncEventTraceHeader = $($syncEventTrace[0] -split ',')[$UserDataCol].Trim()
    If ($syncEventTraceHeader -ne "User Data")
    {
        Throw "Unexpected Sync Event Trace header. Error Details: 'User Data' not found in column #$UserDataCol."
    }

    # Prepare copy of User Data to ArrayList
    $totalLines = $syncEventTrace.count
    Write-Verbose "Copy data to array list ($totalLines entries)"
    $processTime = [System.Diagnostics.Stopwatch]::StartNew()
    $syncEventTraceArray = [System.Collections.ArrayList]@()
    $i = 0

    # Init Progress Bar
    $PercentComplete = 0
    $showProgressTimer = [System.Diagnostics.Stopwatch]::StartNew()
    $msgProgressBar = "Reading Sync Event Trace file"
    Write-Progress -Activity $msgProgressBar -Status "$PercentComplete,0% Complete:" -PercentComplete $PercentComplete

    # Copy User Data to ArrayList
    ForEach ($line in $syncEventTrace)
    {
        # Show progress every 3s
        If ($showProgressTimer.Elapsed.TotalMilliseconds -ge 3000)
        {
            $PercentComplete = [math]::round((($i / $totalLines) * 100),1)
            Write-Progress -Activity $msgProgressBar -Status "$PercentComplete,0% Complete:" -PercentComplete $PercentComplete
            $showProgressTimer.Reset()
            $showProgressTimer.Start()
        }

        If (-not $line.StartsWith(' EventTrace, ')) # Skip event trace Headers
        {
            # Get "User Data" in Sync Event Trace line, don't split commas between double-quotes
            $l = ($line -split ',+(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)')[$UserDataCol].Trim()
            $syncEventTraceArray.Add($l) | Out-Null
        }
        $i++
    }
    $showProgressTimer.Stop()
    $processTime.Stop()
    "Time taken: $($processTime.Elapsed)"

    # Free-up memory
    $syncEventTrace = $null

    # Resolve GUIDs to object names
    $totalLines = $syncEventTraceArray.count
    $processTime = [System.Diagnostics.Stopwatch]::StartNew()
    If ($totalLines -gt 0)
    {
        Write-Host "Parsing Sync Event Trace ($totalLines events). Please wait..." -ForegroundColor Cyan
        $i = 0
        # Init Progress Bar
        $PercentComplete = 0
        $showProgressTimer = [System.Diagnostics.Stopwatch]::StartNew()
        $msgProgressBar = "Resolving object names"
        Write-Progress -Activity $msgProgressBar -Status "$([math]::round($PercentComplete,1))% Complete:" -PercentComplete $PercentComplete

        
        For ($i = 0; $i -lt $totalLines; $i++)
        {
            # Show progress every 3s
            If ($showProgressTimer.Elapsed.TotalMilliseconds -ge 3000)
            {
                $PercentComplete = [math]::round((($i / $totalLines) * 100),1)
                Write-Progress -Activity $msgProgressBar -Status "$PercentComplete% Complete:" -PercentComplete $PercentComplete
                $showProgressTimer.Reset()
                $showProgressTimer.Start()
            }
            
            # Parse all GUIDs from line
            $guidMatches = $syncEventTraceArray[$i] | Select-String -Pattern $guidRegex -AllMatches | Select-Object -ExpandProperty Matches | Select-Object -ExpandProperty Value -Unique

            # Replace each GUID with object name
            ForEach ($m in $guidMatches)
            {
                $objName = Resolve-ADSyncToolsObjectName -CacheTable ([ref] $CacheTable) -Source ConnectorSpace -SourceObjectId $m
                $syncEventTraceArray[$i] = $syncEventTraceArray[$i].Replace($m, $objName)
            }
        }
        $showProgressTimer.Stop()
    }
    $processTime.Stop()
    "Time taken: $($processTime.Elapsed)"

    # Save translated trace to disk
    Write-Verbose "Saving translated Sync Event Trace '$targetFilename' ($totalLines events)..."
    Try
    {
        $syncEventTraceArray | Set-Content $targetFilename
    }
    Catch
    {
        Throw "There was an error exporting CSV file. Error Details: $($_.Exception.Message)"
    }

    # Save updated cache to disk
    Save-ADSyncToolsLogmanTraceCache -CacheTable ([ref] $CacheTable) -CacheFilename $cacheFilename
}


#endregion
#=======================================================================================



#=======================================================================================
#region Custom Sync Scheduler
#=======================================================================================

Function IsSyncCycleRunning
{
    [CmdletBinding()]
    Param()

    IsAADConnectPresent

    $runStatus = Get-ADSyncConnectorRunStatus
    If ($runStatus.RunState -eq 'Busy')
    {
        Return $true
    }
    Else
    {
        Return $false
    }
}

Function IsWizardRunning
{
    [CmdletBinding()]
    Param()

    $wizardProc = @(Get-Process | Where {$_.ProcessName -eq 'AzureADConnect'})
    If ($wizardProc.Count -gt 0)
    {
        Return $true
    }
    Else
    {
        Return $false
    }
}

Function StartRunProfile
{
    [CmdletBinding()]
    Param(
        # Connector Name
        [Parameter(Mandatory=$true,
                    Position=0)]
        $ConnectorName,

        # Run Profile Name
        [Parameter(Mandatory=$true,
                    Position=1)]
        $RunProfile
    )

    IsAADConnectPresent

    Write-Output "$(Get-Date) - Running '$RunProfile' step for connector '$ConnectorName'..."
    Invoke-ADSyncRunProfile -ConnectorName $ConnectorName -RunProfileName  $RunProfile | 
        Select ConnectorName, RunProfileName, IsRunComplete, Result | ft
}

Function ConfirmCustomSyncScheduler
{
    [CmdletBinding()]
    Param()
    
    Write-Verbose "Checking AADConnect Wizard..."
    If (IsWizardRunning)
    {
        # Interrupt Custom sync scheduler
        Write-Host "`n$(Get-Date) - Azure AD Connect Wizard is running. Custom Sync Scheduler stopped gracefully." -ForegroundColor Cyan
        Return $false
    }

    Write-Verbose "Checking Sync Cycle progress..."
    If (IsSyncCycleRunning)
    {
        # Sync Cycle is currently running, cannot start
        Throw "`nSync Cycle is in progress. Cannot run Custom Sync Scheduler."
    }

    Write-Verbose "Checking Scheduler status..."
    $syncScheduler = Get-ADSyncScheduler
    If ($syncScheduler.SyncCycleEnabled)
    {
        # Disable Sync Scheduler
        Try
        {
            Set-ADSyncScheduler -SyncCycleEnabled $false
        }
        Catch
        {
            Throw "Set-ADSyncScheduler failure: $($_.Exception.InnerException)"
        }
        Write-Verbose "Sync Scheduler disabled."
    }
    
    Write-Verbose "Checking Maintenance task status..."
    If (-not $syncScheduler.MaintenanceEnabled)
    {
        Try
        {
            Set-ADSyncScheduler -MaintenanceEnabled $true
        }
        Catch
        {
            Throw "Set-ADSyncScheduler failure: $($_.Exception.InnerException)"
        }
        Write-Verbose "Maintenance task enabled."
    }
        
    Return $syncScheduler
}

<#
.Synopsis
   Custom Sync Scheduler to run every sync cycle with a given Connector's order
 
.DESCRIPTION
   The specific Connector order which is run on a sync cycle (Run Profile) is normally not important but in some
   scenarios, it can cause sync issues, however, Azure AD Connect cannot guarantee to always run a sync cycle with
   a specific Connector order. This script DISABLES the built-in Sync Scheduler and provides a synchronous sync
   scheduler (while the script is running) to honor a given Connector order in every sync cycle.
   NOTE: This script will not disable the built-in Sync Scheduler in case a sync cycle is already running.
 
   Run as a Windows Task Scheduler
   In case you want to run the Custom Sync Scheduler whether a user is logged on or not, open the Windows Task Scheduler
   and follow these steps:
     1. Create a local folder to store your Custom Sync Scheduler files, e.g.:
        C:\CustomScheduler\
     2. Create a text file with your specific connector's order, e.g. MyConnectorsOrder.txt:
        Get-ADSyncConnector | select -ExpandProperty Name | Out-File C:\CustomScheduler\MyConnectorsOrder.txt
        NOTE: The line above creates a text file which you can edit to set a specific Connector's order
     3. Open Windows Task Scheduler:
        Click Create Task... (not the basic task)
     4. In General tab:
        Name: AADConnect Custom Sync Scheduler
        Select "Run whether user is logged on or not"
        Enable "Do not store password"
        Set "Configure for:" with your current operating system version, e.g.: Windows Server 2019
     5. In Triggers tab:
        Click New...
        Daily - Recur every '1' days
        Enable "Repeat task every '30 minutes'"
     6. In Actions tab:
        Click New...
        Program/script: powershell
        Add arguments: -command &{Import-Module "C:\Program Files\Microsoft Azure Active Directory Connect\Tools\AdSyncTools.psm1"; Start-ADSyncToolsCustomSyncScheduler -ConnectorsOrderFilename "C:\CustomScheduler\MyConnectorsOrder.txt" -RunProfile Delta >>"C:\CustomScheduler\ADSyncCustomSyncScheduler.log"}
     7. In Settings tab:
        Disable "Stop the task if it runs longer than"
     8. Run the new task and check in the Synchronization Service Manager if a new delta sync cycle has started.
 
.EXAMPLE
   $myConnectorsOrder = @('Contoso.com','Contoso.onmicrosoft.com - AAD')
 
   NOTE: The line above creates a list (array) with your specific Connector's order, then start the Custom Sync Scheduler with:
 
   Start-ADSyncToolsCustomSyncScheduler -ConnectorsOrderList $myConnectorsOrder
 
.EXAMPLE
    Get-ADSyncConnector | select -ExpandProperty Name | Out-File .\MyConnectorsOrder.txt
 
    NOTE: The line above creates a text file which you can edit to set a specific Connector's order, then start the Custom Sync Scheduler with:
 
    Start-ADSyncToolsCustomSyncScheduler -ConnectorsOrderFilename .\MyConnectorsOrder.txt
 
.EXAMPLE
   Start-ADSyncToolsCustomSyncScheduler -ConnectorsOrderList @('Contoso.com','Contoso.onmicrosoft.com - AAD') -RunProfile Delta
    
   NOTE: This will run a single Delta sync cycle, without starting the custom sync scheduler.
 
.EXAMPLE
   Start-ADSyncToolsCustomSyncScheduler -ConnectorsOrderFilename .\MyConnectorsOrder.txt -RunProfile Full
 
   NOTE: This will run a single Full sync cycle, without starting the custom sync scheduler.
#>

Function Start-ADSyncToolsCustomSyncScheduler
{
    [CmdletBinding()]
    Param
    (
        # Custom Sync Scheduler Connectors order as a collection
        [Parameter(ParameterSetName='Array',
                    Mandatory=$true,
                    Position=0)]
        [string[]] $ConnectorsOrderList,


        # Custom Sync Scheduler Connectors order
        [Parameter(ParameterSetName='Filename',
                    Mandatory=$true,
                    Position=0)]
        [string] $ConnectorsOrderFilename,

        # Run Profile (Use "Scheduler" to enable custom sync scheduler or "Full"/"Delta" for a single sync cycle
        [Parameter(Mandatory=$false,
                    Position=1)]
        [ValidateSet("Scheduler", "Full", "Delta", IgnoreCase = $false)]
        [string] $RunProfile = "Scheduler"
    )

    $ErrorActionPreference = 'Stop'

    switch ($PSCmdlet.ParameterSetName)
    {
        'Array' 
        {
            $ADSyncConnectorsOrder = $ConnectorsOrderList
        }
        'Filename' 
        {
            Try
            {
                $ADSyncConnectorsOrder = @(Get-Content $ConnectorsOrderFilename)
            }
            Catch
            {
                Throw "Error reading the file '$ConnectorsOrderFilename'. Error Details: $($_.Exception.Message)"
            }
        }
    }

    Write-Host  "`nCustom Sync Scheduler Connectors order:" -ForegroundColor Green
    $ADSyncConnectorsOrder

    Write-Verbose "Connectors Count = $(@($ADSyncConnectorsOrder).Count)"
    If (@($ADSyncConnectorsOrder).Count -lt 2)
    {
        Throw "Invalid Connector order. Please use 'get-help ADSyncToolsCustomSyncScheduler.ps1 -Full' for more information."
    }

    if ($RunProfile -eq 'Scheduler')
    {
        $runProfileName = 'Delta'
        $customSyncSchedulerEnabled = $true
    }
    Else
    {
        $runProfileName = $RunProfile
        $customSyncSchedulerEnabled = $false
    }
    Write-Verbose "CustomSyncSchedulerEnabled = $customSyncSchedulerEnabled"
    Write-Verbose "RunProfileName = $runProfileName"

    $checkDelaySeconds = 0
    $customSyncCycleNextStartUTC = Get-Date 0

    # Enter Sync Scheduler
    :MainLoop Do
    {
        Do
        {
            Write-Verbose "Confirm Custom Sync Scheduler"
            $syncSchedulerSettings = ConfirmCustomSyncScheduler
            if ($syncSchedulerSettings)
            {
                $syncCycleIntervalMins = $syncSchedulerSettings.CurrentlyEffectiveSyncCycleInterval.Minutes
                $currentTimeUTC = (Get-Date).ToUniversalTime()
                Write-Verbose "Sleeping for $checkDelaySeconds seconds..."
                Start-Sleep -Seconds $checkDelaySeconds
            }
            Else
            {
                # Wizard open, exit Custom Sync Scheduler
                Break MainLoop
            }
        }
        While ($currentTimeUTC -lt $customSyncCycleNextStartUTC)

        # Calculate next sync cycle start time
        $customSyncCycleStartUTC = (Get-Date).ToUniversalTime()
        $customSyncCycleNextStartUTC = $customSyncCycleStartUTC + $(New-TimeSpan -Minutes $syncCycleIntervalMins)
        $checkDelaySeconds = 10
    
        # Start a new Sync Cycle
        Write-Host  "`n$(Get-Date) - Sync Cycle Start" -ForegroundColor Green
        Write-Host 'Sync Cycle started - Please do not interrupt sync cycles.' -ForegroundColor Yellow

        # Import Step
        ForEach ($c in $ADSyncConnectorsOrder)
        {
            $runProfileFullName = $runProfileName + ' Import'
            StartRunProfile -ConnectorName $c -RunProfile $runProfileFullName
        }
    
        # Synchronization Step
        ForEach ($c in $ADSyncConnectorsOrder)
        {
            $runProfileFullName = $runProfileName + ' Synchronization'
            StartRunProfile -ConnectorName $c -RunProfile $runProfileFullName
        }
    
        # Export Step
        If (-not $syncSchedulerSettings.StagingModeEnabled) 
        {
            ForEach ($c in $ADSyncConnectorsOrder)
            {
                $runProfileFullName = 'Export'
                StartRunProfile -ConnectorName $c -RunProfile $runProfileFullName
            }
        }

        If ($customSyncSchedulerEnabled)
        {
            # Show wait time for next sync cycle
            $customSyncCycleFinishUTC = (Get-Date).ToUniversalTime()
            $customSyncCycleWaitTimeSeconds = [math]::Round(($customSyncCycleNextStartUTC - $customSyncCycleFinishUTC).TotalSeconds)
            If ($customSyncCycleWaitTimeSeconds -lt 0)
            {
                $customSyncCycleWaitTimeSeconds = 0
            }
            Write-Host "$(Get-Date) - Next Sync Cycle Starting in $customSyncCycleWaitTimeSeconds seconds..." -ForegroundColor Green
            Write-Host 'To stop Custom Sync Scheduler, please launch Azure AD Connect Wizard.' -ForegroundColor Yellow
        }

    }
    While ($customSyncSchedulerEnabled)
}

#endregion
#=======================================================================================


#=======================================================================================
#region Active Directory Permissions Troubleshooting
#=======================================================================================

# Set/Create OutputDirectory global variable
Function Set-OutputDirectory
{
    [CmdletBinding()]
    Param()

    # ADsync Diags output sub-folder
    $outputFolder =  "ADSyncTools-Output"

    [string] $currentLocation = (Get-Location).Path
    
    If (-not [string]::IsNullOrEmpty($currentLocation))
    {
        [string] $global:outputPath = "$currentLocation\$outputFolder"
        
        # Create Output Folder
        If (-not (Test-Path $global:outputPath))  
        {
            Try
            {
                $newfolder = New-Item -Path $global:outputPath -ItemType directory
            }
            Catch
            {
                Throw "Unable to set output folder. Error Details: $($_.Exception.Message)"
            }
            
        }
        Write-Verbose "Current output folder: $($global:outputPath)"
    }
    Else
    {
        Throw "Unable to get working folder."
    }
}

# Initialtes the HTML report headers and filename
Function Initialize-ADSyncToolsHtmlReport
{
    [CmdletBinding()]
    Param()
    
    If ($PSVersionTable.PSVersion.Major -gt 2)
    {
        $reportStyleHtml = @"
/* ADSyncTools-Output.css file to format HMTL report */
p{ line-height: 1em; }
h1, h2, h3, h4{
    color: DodgerBlue;
    font-weight: normal;
    line-height: 1.1em;
    margin: 0 0 .5em 0;
}
h1{ font-size: 1.7em; }
h2{ font-size: 1.5em; }
a{
    color: black;
    text-decoration: none;
}
    a:hover,
    a:active{ text-decoration: underline; }
body{
    font-family: arial; font-size: 80%; line-height: 1.2em; width: 100%; margin: 0; background: white;
}
"@

        # Create the Cascading Style Sheet (CSS) file
        $outputReportStyleFile = "ADSyncTools-Output.css"
        Try  
        {
            $reportStyleHtml | Out-File -FilePath "$global:outputPath\$outputReportStyleFile"
        }
        Catch  
        {
            Write-Error "Error creating '$global:ADSyncToolsOutputStyle' file in '$global:outputPath'. Error Details: $($_.Exception.Message)"
        }

        # Init the HTML elements in memory - Title and date
        $Global:ADSyncToolsHtmlReport = $null
        [string] $reportLongDate = "$((Get-Date).ToUniversalTime().DateTime) UTC"
        [string] $reportTitle = 'AAD Connect Diagnostics'
        $htmlReport = ConvertTo-Html -CssUri $outputReportStyleFile -Body "<H1>$reportTitle</H1><p>$reportLongDate</p>" -Title $reportTitle

        $i=0
        while ($htmlReport[$i] -ne "<table>")  {
            $Global:ADSyncToolsHtmlReport += "$($htmlReport[$i])`n"
            $i++
        }
        $Global:ADSyncToolsHtmlReport += "<p></p>`n"
    }
    else
    {
        $Global:ADSyncToolsHtmlReport = $null
    }
}

# Adds the input content into HTML fragments to the report
Function Export-ADSyncToolsHtmReport   
{
    [CmdletBinding()]
    Param
    (
        [Parameter(Mandatory=$true)]
        $Title,

        [Parameter(Mandatory=$false)]
        $InputObject,

        [ValidateSet("List","Table","String")]
        $As="Table"
    )
    
    If ($Global:ADSyncToolsHtmlReport -ne $null)  
    {
        If ($As -eq "String")  
        {
            $Global:ADSyncToolsHtmlReport += "<p>$Title$InputObject<p>`n" 
        }
        Else
        {
            $Global:ADSyncToolsHtmlReport += "<H2>$Title</H2>`n" 
            $Global:ADSyncToolsHtmlReport += $InputObject | ConvertTo-Html -Fragment -As $As
            $Global:ADSyncToolsHtmlReport += "<p></p>`n"
        }
    }
} 

# Finalizes the report and saves the HTML file
Function Close-ADSyncToolsHtmlReport
{
    [CmdletBinding()]
    Param
    (
        [Parameter(Mandatory=$true)]
        $Title,

        [Parameter(Mandatory=$true)]
        $ReportDate
    )

    If ($Global:ADSyncToolsHtmlReport -ne $null)  
    {
        $filename = "$global:outputPath\$($ReportDate)_ADSyncTools_$Title.htm"
        $Global:ADSyncToolsHtmlReport += "</body></html>"
        Try 
        {
            $Global:ADSyncToolsHtmlReport  | Out-File -FilePath $filename
            Write-Host "Exported HTML Report to file $filename"
        }
        Catch
        {
             Write-Error "An error occurred exporting HTML Report to '$filename'. Error Details: $($_.Exception.Message)"
        }
    }
}

# Exports report data into a standalone XML file
Function Export-ADSyncToolsXmlReport   {

    [CmdletBinding()]
    Param
    (
        [Parameter(Mandatory=$true)]
        $Title,

        [Parameter(Mandatory=$true)]
        $InputObject,

        [Parameter(Mandatory=$true)]
        $ReportDate
    )
    
    $filename = "$global:outputPath\$($ReportDate)_ADSyncTools_$Title.xml"
    Try  
    {
        # Export data to XML file
        $InputObject | Export-Clixml $filename
    }
    Catch   
    {
         Write-Error "An error occurred exporting data to file '$filename'. Error Details: $($_.Exception.Message)"

    }

}

<#
.Synopsis
   Find an Active Directory object in the Forest by its DOMAIN\username'.
.DESCRIPTION
   Supports multi-domain queries and returns all the required properties including mS-DS-ConsistencyGuid.
   TODO - Replace by Search-ADSyncToolsADobject
#>

Function Get-ADSyncToolsADobjectByDomainUsername
{
    [CmdletBinding()]
    Param
    (
        [Parameter(Mandatory=$true,
                    Position=0)]
        [string]
        $DomainUsername
    )
    Write-Verbose "Enter: Get-ADSyncToolsADobjectByDomainUsername -DomainUsername $DomainUsername"
    Write-Verbose "'$DomainUsername' -match regex Domain: $($DomainUsername -match $netbiosDomainRegex)"
    Write-Verbose "'$DomainUsername' -match regex UPN: $($DomainUsername -match $upnRegex)"
    If ($DomainUsername -match $netbiosDomainRegex)
    {
        # DOMAIN\USER input format
        $domainUsernameA = $DomainUsername -split '\\'
    }
    ElseIf ($DomainUsername -match $upnRegex)
    {
        # UPN input format
        $DomainUsernameA = $DomainUsername -split '@'
        [array]::Reverse($DomainUsernameA)
    }
    Else
    {
        Throw "Invalid input. Make sure you are using a valid domain account in 'DOMAIN\username' or UPN format."
    }

    <# TODO - suport for multi-domain query
    Try
    {
        $userDomainObj = Get-ADDomain $domainAccount[0] -ErrorAction Stop
        Write-Verbose "Found Domain $userDomainObj"
        $userDomain = $userDomainObj.DistinguishedName
    }
    Catch
    {
        Write-Error "Unable to find Domain $($domainAccount[0]) : $($_.Exception.Message)"
        return $null
    }
    #>


    # Get the AD object from target DC
    Write-Verbose "Executing: Get-ADObject -Filter `"sAMAccountName -eq '$($domainUsernameA[1])'`" -Properties $defaultADobjProperties -SearchBase $domainDN -SearchScope Subtree -Server $targetDC"           
    Try
    {
        $seachResult = Get-ADObject -Filter "sAMAccountName -eq '$($domainUsernameA[1])'" -Properties $defaultADobjProperties -ErrorAction Stop
        #TODO: -SearchBase $domainDN -SearchScope Subtree -Server $targetDC
    }
    Catch
    {
        Throw "Cannot find user '$DomainUsername': $($_.Exception.Message)"
    }

    Write-Verbose "Exit: Get-ADSyncToolsADobjectByDomainUsername"
    Return $seachResult
}

# Convert SIDs to readable names
Function Convert-SIDtoName
{
    [CmdletBinding()]
    Param
    (
        $sid
    )

    # Super Verbose
    # Write-Verbose $sid

    Try 
    {
        $ID = New-Object System.Security.Principal.SecurityIdentifier($sid)
        $User = $ID.Translate( [System.Security.Principal.NTAccount])
        $User.Value
    } 
    Catch 
    {
        Switch($sid) 
        {
            #Reference http://support.microsoft.com/kb/243330
            "S-1-0" { "Null Authority" }
            "S-1-0-0" { "Nobody" }
            "S-1-1" {"World Authority" }
            "S-1-1-0" { "Everyone" }
            "S-1-2" { "Local Authority" }
            "S-1-2-0" { "Local" }
            "S-1-2-1" { "Console Logon" }
            "S-1-3" { "Creator Authority" }
            "S-1-3-0" { "Creator Owner" }
            "S-1-3-1" { "Creator Group" }
            "S-1-3-4" { "Owner Rights" }
            "S-1-5-80-0" {"All Services" }
            "S-1-4" { "Non Unique Authority" }
            "S-1-5" { "NT Authority" }
            "S-1-5-1" { "Dialup" }
            "S-1-5-2" { "Network" }
            "S-1-5-3" { "Batch" }
            "S-1-5-4" { "Interactive" }
            "S-1-5-6" { "Service" }
            "S-1-5-7" { "Anonymous" }
            "S-1-5-9" { "Enterprise Domain Controllers"}
            "S-1-5-10" { "Self" }
            "S-1-5-11" { "Authenticated Users" }
            "S-1-5-12" { "Restricted Code" }
            "S-1-5-13" { "Terminal Server Users" }
            "S-1-5-14" { "Remote Interactive Logon" }
            "S-1-5-15" { "This Organization" }
            "S-1-5-17" { "This Organization" }
            "S-1-5-18" { "Local System" }
            "S-1-5-19" { "NT Authority Local Service" }
            "S-1-5-20" { "NT Authority Network Service" }
            "S-1-5-32-544" { "Administrators" }
            "S-1-5-32-545" { "Users"}
            "S-1-5-32-546" { "Guests" }
            "S-1-5-32-547" { "Power Users" }
            "S-1-5-32-548" { "Account Operators" }
            "S-1-5-32-549" { "Server Operators" }
            "S-1-5-32-550" { "Print Operators" }
            "S-1-5-32-551" { "Backup Operators" }
            "S-1-5-32-552" { "Replicators" }
            "S-1-5-32-554" { "Pre-Windows 2000 Compatibility Access"}
            "S-1-5-32-555" { "Remote Desktop Users"}
            "S-1-5-32-556" { "Network Configuration Operators"}
            "S-1-5-32-557" { "Incoming forest trust builders"}
            "S-1-5-32-558" { "Performance Monitor Users"}
            "S-1-5-32-559" { "Performance Log Users" }
            "S-1-5-32-560" { "Windows Authorization Access Group"}
            "S-1-5-32-561" { "Terminal Server License Servers"}
            "S-1-5-32-561" { "Distributed COM Users"}
            "S-1-5-32-569" { "Cryptographic Operators" }
            "S-1-5-32-573" { "Event Log Readers" }
            "S-1-5-32-574" { "Certificate Services DCOM Access" }
            "S-1-5-32-575" { "RDS Remote Access Servers" }
            "S-1-5-32-576" { "RDS Endpoint Servers" }
            "S-1-5-32-577" { "RDS Management Servers" }
            "S-1-5-32-575" { "Hyper-V Administrators" }
            "S-1-5-32-579" { "Access Control Assistance Operators" }
            "S-1-5-32-580" { "Remote Management Users" }
            default {$sid}
        }
    }
}

# Convert schema GUID's to readable names
Function Convert-GUIDtoName
{
    [CmdletBinding()]
    Param
    (
        [Parameter(Mandatory=$true)]
        [string]
        $guid,

        [switch]
        $extended
    )
 
    $guidval = [Guid]$guid
    $bytearr = $guidval.tobytearray()
    $bytestr = ""
    
    ForEach ($byte in $bytearr) 
    {
        $str = "\" + "{0:x}" -f $byte
        $bytestr += $str
    }

    If ($extended) 
    {
        #for extended rights, we can check in the configuration container
        $de = New-Object directoryservices.directoryentry("LDAP://" + ([adsi]"LDAP://rootdse").psbase.properties.configurationnamingcontext)
        $ds = New-Object directoryservices.directorysearcher($de)
        $ds.propertiestoload.add("displayname") | Out-Null
        $ds.filter = "(rightsguid=$guid)"
        $result = $ds.findone()
    } 
    Else 
    {
        #Search schema for possible matches for this GUID
        $de = New-Object directoryservices.directoryentry("LDAP://" + ([adsi]"LDAP://rootdse").psbase.properties.schemanamingcontext)
        $ds = New-Object directoryservices.directorysearcher($de)
        $ds.filter = "(|(schemaidguid=$bytestr)(attributesecurityguid=$bytestr))"
        $ds.propertiestoload.add("ldapdisplayname") | Out-Null
        $result = $ds.findone()
    }

    If ($result -eq $null) 
    {
        If ($guid -like '00000000-0000-0000-0000-000000000000')
        {
            Return ""
        }
        Else
        {
            Return $guid
        }
    }
    Else 
    {
        If ($extended) 
        {
            $result.properties.displayname
        } 
        Else 
        {
            $result.properties.ldapdisplayname 
        }
    }
}

# Parse Active Directory Access Rights and translate extended access rights
Function Translate-ADSyncToolsExtendedAccessRights
{
    [CmdletBinding()]
    Param
    (
        [Parameter(Mandatory=$true)]
        $objectDACL
    )
    
    $accessRightsList = @()

    ForEach ($ace in $objectDACL)
    {
        $accessRights = New-Object PSobject -Property @{
            ActiveDirectoryRights = $ace.ActiveDirectoryRights
            InheritanceType = $ace.InheritanceType
            ObjectType = ""
            InheritedObjectType = Convert-GUIDtoName -guid $($ace.inheritedobjecttype)
            ObjectFlags = $ace.ObjectFlags
            AccessControlType = $ace.accesscontroltype
            IdentityReference = Convert-SIDtoName -sid $($ace.identityReference)
            IsInherited = $ace.isinherited
            InheritanceFlags = $ace.InheritanceFlags
            PropagationFlags = $ace.PropagationFlags
        }

        If ($ace.ActiveDirectoryRights -eq "ExtendedRight") 
        {
            $accessRights.ObjectType = Convert-GUIDtoName -guid $($ace.objecttype) -extended
        }
        Else
        {
            $accessRights.ObjectType = Convert-GUIDtoName -guid $($ace.objecttype)
        }
        $accessRightsList += $accessRights
    }

    Return $($accessRightsList | 
        select ActiveDirectoryRights,AccessControlType,IdentityReference,ObjectType,InheritedObjectType,ObjectFlags,IsInherited,InheritanceType,InheritanceFlags,PropagationFlags)
}

# Function used by Get-ADSyncToolsUsrMemberOfTransitive to get Group membership recursively.
Function Get-ADSyncToolsGrpMemberOfRecursive
{
    [CmdletBinding()]
    Param
    (
        $ADobject
    )

    $groups = Get-ADPrincipalGroupMembership -Identity $($ADobject.distinguishedName)

    foreach ($g in $groups)  {
        # Call recursive function
        Get-ADSyncToolsGrpMemberOfRecursive ($g)

        # Return the group Object
        Get-ADObject $($g.distinguishedName) -Properties CanonicalName,msDS-PrincipalName | 
            select CanonicalName,DistinguishedName,msDS-PrincipalName,Name,ObjectClass,ObjectGUID
    }
}

# Receives a user account as input (AD object)
# Returns the group membership including Nested-Groups, Foreign-Security-Principals and its own identity reference
Function Get-ADSyncToolsUsrMemberOfTransitive
{
    [CmdletBinding()]
    Param
    (
        [Parameter(Mandatory=$true)]
        $UserAccount
    )

    # Get AD DS Connector Account group membership from AD including PrimaryGroup
    Try  
    {
        $srvAccountMemberOf = @(Get-ADPrincipalGroupMembership $($UserAccount.distinguishedName) -ErrorAction Stop)
    }
    Catch
    {
        Write-Error "Unable to run Get-ADPrincipalGroupMembership for target object: $($_.Exception.Message)"
    }

    
    # Add all Groups to an Array of Group Objects
    $srvAccountMemberOfObj = @()
    ForEach ($group in $srvAccountMemberOf)  
    {
        $srvAccountMemberOfObj += Get-ADObject $($group.distinguishedName) -Properties CanonicalName,msDS-PrincipalName | 
            select CanonicalName,DistinguishedName,msDS-PrincipalName,Name,ObjectClass,ObjectGUID
    }    
    
    # Add Authenticated Users Group into the Array of Group Objects
    $authUsersGroup = Get-ADObject -Filter {ObjectClass -eq "foreignSecurityPrincipal"} -Properties CanonicalName,msDS-PrincipalName | 
        Where-Object {$_.'msDS-PrincipalName' -like "*Authenticated Users*"} |
        select CanonicalName,DistinguishedName,msDS-PrincipalName,Name,ObjectClass,ObjectGUID
    $srvAccountMemberOfObj += $authUsersGroup
    
    # Get Authenticated Users nested groups
    $authUsersMemberOf = (Get-ADobject $($authUsersGroup.distinguishedName) -Properties memberOf).memberOf
    
    # Add Authenticated Users nested groups into the Array of Group Objects
    foreach ($group in $authUsersMemberOf)  {
        $srvAccountMemberOfObj += Get-ADObject $group -Properties CanonicalName,msDS-PrincipalName | 
            select CanonicalName,DistinguishedName,msDS-PrincipalName,Name,ObjectClass,ObjectGUID
    }    

    # Get all nested groups into an array of Group Objects
    $srvAccountNestedMemberOfObj = @()
    foreach ($group in $srvAccountMemberOfObj)  {
        if ($group.ObjectClass -eq 'group')  {
            #$group.CanonicalName
            $srvAccountNestedMemberOfObj += @(Get-ADSyncToolsGrpMemberOfRecursive ($group))
        }
    }    

    # Add all nested groups into the main Array of Group Objects
    $srvAccountMemberOfObj += $srvAccountNestedMemberOfObj

    # Add Everyone Group to the list of Identities
    $everyoneGroup = "" | select CanonicalName,DistinguishedName,msDS-PrincipalName,Name,ObjectClass,ObjectGUID
    $everyoneGroup.CanonicalName = "Everyone"
    $everyoneGroup.DistinguishedName = "S-1-1-0"
    $everyoneGroup.'msDS-PrincipalName' = "Everyone"
    $everyoneGroup.Name = "Everyone"
    $everyoneGroup.ObjectClass = "well-known-sid"
    $srvAccountMemberOfObj += $everyoneGroup
    
    Write-Verbose ""
    Write-Verbose "--- AD DS Connector Account full group membership ---`n$($srvAccountMemberOfObj.'msDS-PrincipalName' | sort -Unique | %{"`n$_"}) `n"
    
    # Add the account itself to the list of Identities
    #$adObject = Invoke-Command -Session $ADSyncToolsPsSession { Get-ADObject $args[0] -Properties CanonicalName,msDS-PrincipalName } -Args $UserAccount.DistinguishedName | select CanonicalName,DistinguishedName,msDS-PrincipalName,Name,ObjectClass,ObjectGUID
    $adObject = Get-ADObject $($UserAccount.DistinguishedName) -Properties CanonicalName,msDS-PrincipalName | 
        select CanonicalName,DistinguishedName,msDS-PrincipalName,Name,ObjectClass,ObjectGUID
    $srvAccountMemberOfObj += $adObject
    
    Write-Verbose ""
    Write-Verbose "--- AD DS Connector Account --- `n`n$($adObject.'msDS-PrincipalName') `n"

    # Remove duplicates
    $srvAccountMemberOfObj = $srvAccountMemberOfObj | sort DistinguishedName -Unique

    Return $srvAccountMemberOfObj
}

# Exports permissions to HTML and XML files
Function Export-ADSyncToolsADpermissions
{
    [CmdletBinding()]
    Param
    (
        [Parameter(Mandatory=$true, Position=0)]
        $ADtarget,

        [Parameter(Mandatory=$true, Position=1)]
        $ADroot,

        [Parameter(Mandatory=$true, Position=2)]
        $ADconnectorAccount,

        [Parameter(Mandatory=$true, Position=3)]
        $ADSyncSrvAccount,

        [Parameter(Mandatory=$true, Position=4)]
        $ADbuiltinContainer,

        [Parameter(Mandatory=$true, Position=5)]
        $ADsamServer

    )
    Write-Verbose "Enter: Export-ADSyncToolsADpermissions"

    # Init report
    Set-OutputDirectory
    Initialize-ADSyncToolsHtmlReport
    $reportDate = [string] $((Get-Date).toString('yyyyMMdd-HHmmss'))
    
    # AD DS Connector object
    $adConnectorDetails = $ADconnectorAccount | select $defaultADobjProperties
    Export-ADSyncToolsHtmReport -InputObject $adConnectorDetails -Title "AD DS Connector Account '$($adConnectorDetails.CanonicalName)' details:" -As List

    # ADSync Service Account object
    $adSyncSrvDetails = $ADSyncSrvAccount #| select $defaultADobjProperties
    Export-ADSyncToolsHtmReport -InputObject $adSyncSrvDetails -Title "ADSync Service Account '$($adSyncSrvDetails.CanonicalName)' details:" -As List

    # Target AD object
    $ADtargetDetails = $ADtarget | select $defaultADobjProperties
    Export-ADSyncToolsHtmReport -InputObject $ADtargetDetails -Title "Target AD object '$($ADtarget.CanonicalName)' details:" -As List
    Export-ADSyncToolsXmlReport -InputObject $ADtargetDetails -Title "ADtarget-Details" -ReportDate $reportDate

    # AD Domain Root Container
    $adRootDetails = $ADroot | select $defaultADobjProperties
    Export-ADSyncToolsHtmReport -InputObject $adRootDetails -Title "AD Root container '$($ADroot.CanonicalName)' details:" -As List
    Export-ADSyncToolsXmlReport -InputObject $adRootDetails -Title "DomainRoot-Details" -ReportDate $reportDate

    # AD Builtin Container
    $adBuiltinDetails = $ADbuiltinContainer | select $defaultADobjProperties
    Export-ADSyncToolsHtmReport -InputObject $adBuiltinDetails -Title "AD Builtin container '$($ADbuiltinContainer.CanonicalName)' details:" -As List
    Export-ADSyncToolsXmlReport -InputObject $adBuiltinDetails -Title "Builtin-Details" -ReportDate $reportDate

    # AD SAM Server object
    $adSamServerDetails = $ADsamServer | select $defaultADobjProperties
    Export-ADSyncToolsHtmReport -InputObject $adSamServerDetails -Title "AD SAM Server object '$($ADsamServer.CanonicalName)' details:" -As List
    Export-ADSyncToolsXmlReport -InputObject $adSamServerDetails -Title "SamServer-Details" -ReportDate $reportDate

    # Get Full ACL of target AD object, Root AD Container, Builtin container and SAM Server
    $adTargetACL = Get-ADSyncToolsADpermissions -ADobject $ADtarget
    $adRootACL =  Get-ADSyncToolsADpermissions -ADobject $ADroot
    $adBuiltinACL = Get-ADSyncToolsADpermissions -ADobject $ADbuiltinContainer
    $adSamServerACL = Get-ADSyncToolsADpermissions -ADobject $ADsamServer

    # Get AD DS Connector Account full group membership
    $adConnectorGroups = @(Get-ADSyncToolsUsrMemberOfTransitive -UserAccount $ADconnectorAccount)
        
    If ($adConnectorGroups.Count -gt 0)
    {
        # Export AD DS Connector Account Groups
        Export-ADSyncToolsHtmReport -InputObject $adConnectorGroups -Title "AD DS Connector Account '$($adConnectorDetails.CanonicalName)' group membership:" -As Table
        Export-ADSyncToolsXmlReport -InputObject $adConnectorGroups -Title "ADConnectorAccGroups" -ReportDate $reportDate
        
        # Calculate Effective Permissions of ADconnectorAccount over the ADtarget
        $effectiveDACL = Get-ADSyncToolsADeffectivePermissions -ADPermissions $adTargetACL -ADconnectorAccGroups $adConnectorGroups
        
        # Export effective permissions to a XML and HTML
        Export-ADSyncToolsHtmReport -InputObject $effectiveDACL -Title "AD DS Connector Account effective permissions over target AD object" -As Table
        Export-ADSyncToolsXmlReport -InputObject $effectiveDACL -Title "ADConnectorOnADtarget-EffectiveDACL" -ReportDate $reportDate

        # Calculate Effective Permissions of ADconnectorAccount over the Domain Root
        $effectiveDACL = Get-ADSyncToolsADeffectivePermissions -ADPermissions $adRootACL -ADconnectorAccGroups $adConnectorGroups
        
        # Export effective permissions to a XML and HTML
        Export-ADSyncToolsHtmReport -InputObject $effectiveDACL -Title "AD DS Connector Account effective permissions over Domain Root" -As Table
        Export-ADSyncToolsXmlReport -InputObject $effectiveDACL -Title "ADConnectorOnDomainRoot-EffectiveDACL" -ReportDate $reportDate

        # Calculate Effective Permissions of ADconnectorAccount over the Builtin Container
        $effectiveDACL = Get-ADSyncToolsADeffectivePermissions -ADPermissions $adBuiltinACL -ADconnectorAccGroups $adConnectorGroups
        
        # Export effective permissions to a XML and HTML
        Export-ADSyncToolsHtmReport -InputObject $effectiveDACL -Title "AD DS Connector Account effective permissions over Builtin Container" -As Table
        Export-ADSyncToolsXmlReport -InputObject $effectiveDACL -Title "ADConnectorOnBuiltin-EffectiveDACL" -ReportDate $reportDate

        # Calculate Effective Permissions of ADconnectorAccount over the SAM Server object
        $effectiveDACL = Get-ADSyncToolsADeffectivePermissions -ADPermissions $adSamServerACL -ADconnectorAccGroups $adConnectorGroups
        
        # Export effective permissions to a XML and HTML
        Export-ADSyncToolsHtmReport -InputObject $effectiveDACL -Title "AD DS Connector Account effective permissions over SAM Server object" -As Table
        Export-ADSyncToolsXmlReport -InputObject $effectiveDACL -Title "ADConnectorOnSamServer-EffectiveDACL" -ReportDate $reportDate
    }
    Else  
    {
        Write-Warning "Unable to calculate effective permissions for AD DS Connector Account."
    }

    <# TODO support for all types of ADSync service accounts (domain, MSA, VSA, gMSA)
    # Get ADSync Service Account full group membership
    $adSyncSrvGroups = @(Get-ADSyncToolsUsrMemberOfTransitive -UserAccount $ADSyncSrvAccount)
    If ($adSyncSrvGroups.Count -gt 0)
    {
        # Export ADSync Service Account Groups
        Export-ADSyncToolsHtmReport -InputObject $adSyncSrvGroups -Title "ADSync Service Account '$($adSyncSrvDetails.CanonicalName)' group membership:" -As Table
        Export-ADSyncToolsXmlReport -InputObject $adSyncSrvGroups -Title "ADSyncAccGroups" -ReportDate $reportDate
 
        # Calculate Effective Permissions of ADSync Service Account over the ADtarget
        $effectiveDACL = Get-ADSyncToolsADeffectivePermissions -ADPermissions $adTargetACL -ADconnectorAccGroups $adSyncSrvGroups
         
        # Export effective permissions to a XML and HTML
        Export-ADSyncToolsHtmReport -InputObject $effectiveDACL -Title "ADSync Service Account effective permissions over target AD object" -As Table
        Export-ADSyncToolsXmlReport -InputObject $effectiveDACL -Title "ADSyncOnADtarget-EffectiveDACL" -ReportDate $reportDate
 
        # Calculate Effective Permissions of ADSync Service Account over the Domain Root
        $effectiveDACL = Get-ADSyncToolsADeffectivePermissions -ADPermissions $adRootACL -ADconnectorAccGroups $adSyncSrvGroups
         
        # Export effective permissions to a XML and HTML
        Export-ADSyncToolsHtmReport -InputObject $effectiveDACL -Title "ADSync Service Account effective permissions over Domain Root" -As Table
        Export-ADSyncToolsXmlReport -InputObject $effectiveDACL -Title "ADSyncOnDomainRoot-EffectiveDACL" -ReportDate $reportDate
 
        # Calculate Effective Permissions of ADSync Service Account over the Builtin Container
        $effectiveDACL = Get-ADSyncToolsADeffectivePermissions -ADPermissions $adBuiltinACL -ADconnectorAccGroups $adSyncSrvGroups
         
        # Export effective permissions to a XML and HTML
        Export-ADSyncToolsHtmReport -InputObject $effectiveDACL -Title "ADSync Service Account effective permissions over Builtin Container" -As Table
        Export-ADSyncToolsXmlReport -InputObject $effectiveDACL -Title "ADSyncOnBuiltin-EffectiveDACL" -ReportDate $reportDate
 
        # Calculate Effective Permissions of ADSync Service Account over the SAM Server object
        $effectiveDACL = Get-ADSyncToolsADeffectivePermissions -ADPermissions $adSamServerACL -ADconnectorAccGroups $adSyncSrvGroups
         
        # Export effective permissions to a XML and HTML
        Export-ADSyncToolsHtmReport -InputObject $effectiveDACL -Title "ADSync Service Account effective permissions over SAM Server object" -As Table
        Export-ADSyncToolsXmlReport -InputObject $effectiveDACL -Title "ADSyncOnSamServer-EffectiveDACL" -ReportDate $reportDate
 
    }
    Else
    {
        Write-Warning "Unable to calculate effective permissions for ADSync Service Account."
    }
    #>


    # Export Full ACL of target AD Object
    Export-ADSyncToolsHtmReport -InputObject $adTargetACL -Title "Full permissions of object '$($ADtarget.CanonicalName)'" -As Table
    Export-ADSyncToolsXmlReport -InputObject $adTargetACL -Title "ADtarget-FullDACL" -ReportDate $ReportDate

    # Export Full ACL of Domain Root Container
    Export-ADSyncToolsHtmReport -InputObject $adRootACL -Title "Full permissions of object '$($ADroot.CanonicalName)'" -As Table
    Export-ADSyncToolsXmlReport -InputObject $adRootACL -Title "DomainRoot-FullDACL" -ReportDate $ReportDate

    # Export Full ACL of Builtin Container
    Export-ADSyncToolsHtmReport -InputObject $adBuiltinACL -Title "Full permissions of object '$($ADbuiltinContainer.CanonicalName)'" -As Table
    Export-ADSyncToolsXmlReport -InputObject $adBuiltinACL -Title "Builtin-FullDACL" -ReportDate $ReportDate
    
    # Export Full ACL of SAM Server object
    Export-ADSyncToolsHtmReport -InputObject $adSamServerACL -Title "Full permissions of object '$($ADsamServer.CanonicalName)'" -As Table
    Export-ADSyncToolsXmlReport -InputObject $adSamServerACL -Title "SamServer-FullDACL" -ReportDate $ReportDate

    # Export file system permissions
    $fileSystemACL = Get-ADSyncToolsFSpermissions "C:\Windows\System32"
    Export-ADSyncToolsHtmReport -InputObject $fileSystemACL -Title "Full permssions of 'System32' folder" -As Table
    Export-ADSyncToolsXmlReport -InputObject $fileSystemACL -Title "System32-FullDACL" -ReportDate $ReportDate

    $fileSystemACL = Get-ADSyncToolsFSpermissions "C:\Windows\System32\samlib.dll"
    Export-ADSyncToolsHtmReport -InputObject $fileSystemACL -Title "Full permssions of 'Samlib' file" -As Table
    Export-ADSyncToolsXmlReport -InputObject $fileSystemACL -Title "Samlib-FullDACL" -ReportDate $ReportDate
   
    # Close HTML report
    Close-ADSyncToolsHtmlReport -Title "_ADpermissionsReport" -ReportDate $reportDate
    
    # Export Group Policies
    Export-ADSyncToolsGroupPolicies -ReportDate $reportDate

    # Export NTDS service on localhost
    Export-ADSyncToolsDomainServices -ReportDate $reportDate

    Write-Verbose "Exit: Export-ADSyncToolsADpermissions"
}


Function Export-ADSyncToolsGroupPolicies
{
    [CmdletBinding()]
    Param
    (
        [Parameter(Mandatory=$true)]
        $ReportDate
    )

    # Export Group Policies
    $filename = "$global:outputPath\" + "$($ReportDate)_ADSyncTools__$($env:COMPUTERNAME)-GPresult.htm"
    gpresult /H $filename

}

Function Export-ADSyncToolsDomainServices
{
    [CmdletBinding()]
    Param
    (
        [Parameter(Mandatory=$true)]
        $ReportDate
    )

    # Export Domain Controller Service (NTDS)
    $ntdsSrv = Get-Service NTDS -ErrorAction SilentlyContinue
    If ([string]::IsNullOrEmpty($ntdsSrv))
    {
        $ntdsSrv = "Active Directory Domain Services not found on localhost."
    }
    Write-Verbose $ntdsSrv

    $filename = "$global:outputPath\" + "$($ReportDate)_ADSyncTools_$($env:COMPUTERNAME)-DomainServices.xml"
    Export-Clixml -InputObject $ntdsSrv -Path $filename 

}


# Returns the list of permissions (DACL) of a given object (ADobject)
Function Get-ADSyncToolsADpermissions 
{
    [CmdletBinding()]
    Param
    (
        $ADobject
    )
    Write-Verbose "Enter: Get-ADSyncToolsADpermissions"

    # Move to AD PS Drive
    $currentDrive = Get-Location
    Set-Location AD:

    # Get and translate DACL of object in AD
    Try
    {
        $permissionsRaw = (Get-Acl $($ADobject.distinguishedName)).Access
    }
    Catch
    {
        Throw "A problem occurred reading AD ACLs. Error Details: $($_.Exception.Message)"
    }
    Finally
    {
        Set-Location $currentDrive
    }

    # Translate ActiveDirectory Extended Access Rights
    Write-Verbose "Translating ActiveDirectory Extended Access Rights from AD object '$($ADobject.distinguishedName)'..."
    $permissions = Translate-ADSyncToolsExtendedAccessRights $permissionsRaw

    Write-Verbose "Exit: Get-ADSyncToolsADpermissions"
    Return $permissions
}


# Returns the list of permissions (DACL) of a given file system path
Function Get-ADSyncToolsFSpermissions 
{
    [CmdletBinding()]
    Param
    (
        $Path
    )
    Write-Verbose "Enter: Get-ADSyncToolsFSpermissions"

    # Get and translate DACL of object in AD
    If (Test-Path $Path)
    {
        Try
        {
            $permissions = (Get-Acl $Path).Access
        }
        Catch
        {
            Write-Error "A problem occurred reading file system ACLs. Error Details: $($_.Exception.Message)"
        }
    }
    Else
    {
        Write-Error "Path '$Path' not found."
    }

    Write-Verbose "Exit: Get-ADSyncToolsFSpermissions"
    Return $permissions
}

# Returns the list of effective permissions (DACL) of a given object (ADPermissions) based on a list of groups (ADconnectorAccGroups)
# Exports All permissions to XML
Function Get-ADSyncToolsADeffectivePermissions 
{
    [CmdletBinding()]
    Param
    (
        [Parameter(Mandatory=$true)]
        $ADPermissions,

        [Parameter(Mandatory=$true)]
        $ADconnectorAccGroups

    )
    Write-Verbose "Enter: Get-ADSyncToolsADeffectivePermissions"
              
    # Build full group membership list in Domain\groupname format
    $ADconnectorAccGroupList = $adConnectorAccGroups.'msDS-PrincipalName'
        
    # Filter ACEs of the AD MA Account object and any Groups that AD MA Account belogs to
    Write-Verbose "`n--- Group Transitive Membership ---`n"
    $effectiveDACL = @()
    foreach ($ace in $ADPermissions)  {
        $aceName = [string] $ace.IdentityReference
        if ($ADconnectorAccGroupList -contains $aceName)   {
            $effectiveDACL +=$ace
            Write-Verbose "$aceName"
        }
    }
    
    Write-Verbose "`n--- Effective AD Permissions DACL ---`n"
    # Expand ActiveDirectoryRights (CreateChild, Self, WriteProperty, ExtendedRight, Delete, GenericRead, WriteDacl, WriteOwner) into separated ACEs
    $expEffectiveDACL = @()
    foreach ($ace in $effectiveDACL)  {

        $adRights = ($ace.ActiveDirectoryRights.ToString()) -split ', '
        if ($adRights.Count -gt 1)  {
            foreach ($adRight in $adRights)  {
                $aceCopy = $ace  | select *  # new clone of the ACE instance
                $aceCopy.ActiveDirectoryRights = $adRight
                $expEffectiveDACL += $aceCopy
            }
        }
        else
        {
            # no need to expand ActiveDirectoryRights, just casting from Enum to string
            $ace.ActiveDirectoryRights = [string] $ace.ActiveDirectoryRights
            $expEffectiveDACL += $ace
        }
            
    }

    Write-Verbose "$($expEffectiveDACL | select ActiveDirectoryRights,AccessControlType,IdentityReference | Out-String)"
    Write-Verbose "Exit: Get-ADSyncToolsADeffectivePermissions"
    Return $expEffectiveDACL
} 

<#
.Synopsis
   Exports AD effective/permissions that AD DS Connector Account has over an object
.DESCRIPTION
   This function takes as input an AD object 'DistinguishedName' (-ADobjectDN) and the AD DS Connector Account 'Domain\username' (ADconnectorAccount) to calculate the effective AD permissions over that AD object.
   It also retrieves a list of effective permission over the AD root container object from the Domain where that AD object belongs.
   Outputs to screen and XML/HTML files these effective AD permissions, as well as a full dump of all permissions of the AD object and other related objects.
.EXAMPLE
   Export-ADSyncToolsADpermissionsReport -ADobjectDN "CN=User1,OU=AADconnect,DC=Contoso,DC=com" -ADconnectorAccount "Contoso\ADsyncSvc"
.EXAMPLE
   Export-ADSyncToolsADpermissionsReport -ADobjectDN "CN=User1,OU=AADconnect,DC=Contoso,DC=com" -ADconnectorAccount "Contoso\ADsyncSvc" -Verbose
.EXAMPLE
   Export-ADSyncToolsADpermissionsReport -ADobjectDN "CN=User1,OU=AADconnect,DC=Contoso,DC=com" -ADconnectorAccountDN "CN=MSOL_11aabbcc1234,CN=Users,DC=Contoso,DC=com" -Verbose
#>

Function Export-ADSyncToolsADpermissionsReport
{
    [CmdletBinding()]
    Param
    (
        [Parameter(Mandatory=$true, Position=0)]
        $ADobjectDN,

        #TODO : change to ADconnectorAccountDN intergrate with search by DN
        [Parameter(Mandatory=$false, Position=1)]
        $ADconnectorAccount 
    )
    
    Write-Verbose "Enter: Export-ADSyncToolsADpermissionsReport"
    IsPowerShellSessionElevated
    IsAADConnectPresent
    Import-ADSyncToolsModule -ModuleName ActiveDirectory -InstallMessage $addsInstallMsg
    
    ## TODO integrate with Search by DN
    # Get the target objects from AD
    Try 
    {
        $adObject = Get-ADObject $ADobjectDN -Properties $defaultADobjProperties -ErrorAction Stop
        $rootDN = [string] $adObject.DistinguishedName.Substring($adObject.DistinguishedName.IndexOf("DC="))
        $adRoot = Get-ADObject $rootDN -Properties $defaultADobjProperties -ErrorAction Stop
        $forestRoot = (Get-ADDomain $rootDN).Forest
    }
    Catch  
    {
        Throw "Cannot find AD object: $($_.Exception.Message)"
    }

    If ([string]::IsNullOrEmpty($ADconnectorAccount))
    {
        # Get AD DS Connector Account from server config
        Write-Verbose "Get-ADSyncToolsADconnectorAccount"
        $connectorAccount = Get-ADSyncToolsADconnectorAccount | Where Forest -eq $forestRoot
        If ([string]::IsNullOrEmpty($connectorAccount))
        {
            Throw "Cannot find AD Connector Space for user '$ADobjectDN'."
        }

        $ADconnectorAccount = $connectorAccount.Domain + "\" + $connectorAccount.Username
    }
    ## TODO: integrate with Search by Domain user
    $adConnectorAccObj = Get-ADSyncToolsADobjectByDomainUsername -DomainUsername $ADconnectorAccount


    # Get the ADSync service account
    # TODO: VSA scenario (SYSTEM security context)
    $adSyncSrvAccount = Get-ADSyncToolsServiceAccount
    If ($ADSyncSrvAccount.AccountType -eq 'VSA')
    {
        $adSyncSrvAccObj = $ADSyncSrvAccount
    }
    Else
    {
        $adSyncSrvAccObj = Get-ADSyncToolsADobjectByDomainUsername -DomainUsername $adSyncSrvAccount.ServiceLogOnAs
    }
    
    # Get Builtin container
    Try 
    {
        $builtinDN = "CN=Builtin," + $rootDN
        $builtinObj = Get-ADObject $builtinDN -Properties $defaultADobjProperties -ErrorAction Stop
    }
    Catch  
    {
        Throw "Cannot find AD Builtin Container: $($_.Exception.Message)"
    }

    # Get SAM Server object
    Try 
    {
        $samServerDN = "CN=Server,CN=System," + $rootDN        
        $samServerObj = Get-ADObject $samServerDN -Properties $defaultADobjProperties -ErrorAction Stop
    }
    Catch  
    {
        Throw "Cannot find AD SAM Server object: $($_.Exception.Message)"
    }

    Export-ADSyncToolsADpermissions -ADtarget $adObject `
                                    -ADroot $adRoot `
                                    -ADconnectorAccount $adConnectorAccObj `
                                    -ADSyncSrvAccount $adSyncSrvAccObj `
                                    -ADbuiltinContainer $builtinObj `
                                    -ADsamServer $samServerObj

    Write-Verbose "Exit: Export-ADSyncToolsADpermissionsReport"
}

<#
.Synopsis
   Imports AD permissions data from XML file and returns a DACL table
.DESCRIPTION
   This funtion takes as input the XML file containing DACL information obtained from 'Export-ADSyncToolsADpermissionsReport' cmdlet and returns an array of objects with each ACE.
.EXAMPLE
   Import-ADSyncToolsADpermissionsReport -Path ".\ADSyncToolsExport_Contoso.com_-FullPerms.xml"
#>

Function Import-ADSyncToolsADpermissionsReport
{
    [CmdletBinding()]
    Param
    (
        # Permissions XML filename
        [Parameter(Mandatory=$true)]
        [string]
        $Path
    )

    If (Test-Path $Path)  
    {
        Try
        {
            Import-Clixml $Path
        }
        Catch
        {
            Throw "An error occurred reading the file '$Path'. Error Details: $($_.Exception.Message)"
        }
    }
    Else  
    {
        Throw "File '$Path' not found."
    }
}


#endregion
#=======================================================================================


#=======================================================================================
#region Migration / Disaster Recovery Functions
#=======================================================================================

<#
.Synopsis
   Import ImmutableID from AAD
.DESCRIPTION
   Generates a file with all Azure AD Synchronized users containing the ImmutableID value in GUID format
   Requirements: MSOnline PowerShell Module
.EXAMPLE
   Import-ADSyncToolsSourceAnchor -OutputFile '.\AllSyncUsers.csv'
.EXAMPLE
   Another example of how to use this cmdlet
#>

Function Import-ADSyncToolsSourceAnchor
{
    [CmdletBinding()]
    Param
    (
        # Output CSV file
        [Parameter(Mandatory=$true)]
        [String] $Output,

        # Get Synchronized Users from Azure AD Recycle Bin
        [Parameter(Mandatory=$false)]
        [switch] $IncludeSyncUsersFromRecycleBin = $false        
    )
    Try
    {
        $creds = Get-Credential
        # TODO : Support for AAD PowerShell v2
        # TODO : Function to connect - Control connected state
        $tenantAzureEnvironment = Get-ADSyncToolsTenantAzureEnvironment $creds
        Connect-MsolService -Credential $creds -AzureEnvironment $tenantAzureEnvironment

    }
    Catch
    {
        Throw "Unable to Connect to Azure AD: $($_.Exception.Message)"
    }

    # Start Importing
    $results = @()
    $allSyncUsers = @()
    $userProperties = @('UserPrincipalName', 'ImmutableID', 'ObjectId', 'LastDirSyncTime', 'IsLicensed', 'SoftDeletionTimestamp')

    Write-Host "Reading Synchronized Users from Azure AD ..."
    $allSyncUsers = Get-MsolUser -Synchronized -All | Where-Object {$_.ImmutableID -ne $null} | select $userProperties

    If ($IncludeSyncUsersFromRecycleBin)
    {
        $allSyncUsers += Get-MsolUser -Synchronized -All -ReturnDeletedUsers | Where-Object {$_.ImmutableID -ne $null} | select $userProperties
    }

    # Start Processing
    #Write-Host "Found $($allSyncUsers.Count) Synchronized Users in Azure AD ..."

    Foreach ($user in $allSyncUsers) 
    {
        # Convert ImmutableID to GUID for each user
        Try
        {
            $immutableIdGuid = [GUID] ([System.Convert]::FromBase64String($user.ImmutableID))
        }
        Catch
        {
            # Failure to convert to GUID value - Skip to the next loop
            Write-Error "Failure to convert ImmutableID to a GUID string: $($_.Exception.Message)"
            Continue 

        }
        
        # Instantiate custom Object with all the properties in $userProperties
        $aadUser = New-Object -TypeName PSObject
        $aadUser | Add-Member -MemberType NoteProperty -Name UserPrincipalName -Value $($user.UserPrincipalName)
        $aadUser | Add-Member -MemberType NoteProperty -Name ImmutableID -Value $($user.ImmutableID)
        $aadUser | Add-Member -MemberType NoteProperty -Name ImmutableIdGuid -Value $immutableIdGuid
        $aadUser | Add-Member -MemberType NoteProperty -Name LastDirSyncTime -Value $($user.LastDirSyncTime)
        $aadUser | Add-Member -MemberType NoteProperty -Name IsLicensed -Value $($user.IsLicensed)
        $aadUser | Add-Member -MemberType NoteProperty -Name SoftDeletionTimestamp -Value $($user.SoftDeletionTimestamp)
        $aadUser | Select UserPrincipalName, ImmutableID, ImmutableIdGuid
        $results += $aadUser
    }

    # Exporting data
    Write-Host "`n`nExporting $($results.count) Synchronized Users in Azure AD ..."
    $results | Export-Csv "$Output.csv" -NoTypeInformation
}


<#
.Synopsis
   Export ms-ds-Consistency-Guid Report
.DESCRIPTION
   Generates a ms-ds-Consistency-Guid report based on an import CSV file from Import-ADSyncToolsSourceAnchor
.EXAMPLE
   Import-Csv .\AllSyncUsers.csv | Export-ADSyncToolsSourceAnchorReport -Output ".\AllSyncUsers-Report"
.EXAMPLE
   Another example of how to use this cmdlet
#>

Function Export-ADSyncToolsSourceAnchorReport
{
    [CmdletBinding()]
    Param
    (
        # Use Alternative Login ID (mail)
        [Parameter(Mandatory=$false)]
        [switch] $AlternativeLoginId = $false,
        
        # UserPrincipalName
        [Parameter(Mandatory=$true,
                   ValueFromPipeline=$true,
                   ValueFromPipelineByPropertyName=$true)]
        [ValidateNotNullOrEmpty()]
        [String] $UserPrincipalName,

        # ImmutableIdGUID
        [Parameter(Mandatory=$true,
                   ValueFromPipeline=$true,
                   ValueFromPipelineByPropertyName=$true)]
        [ValidateNotNullOrEmpty()]
        [String] $ImmutableIdGUID,

        # Output filename for CSV and LOG files
        [Parameter(Mandatory=$true)]
        [String] $Output
    )

    Begin
    {
        Import-ADSyncToolsModule -ModuleName ActiveDirectory -InstallMessage $addsInstallMsg
    
        # Check/Remove output files
        $currentFolder = (Get-Location).Path
        
        Remove-Item "$currentFolder\$Output.csv" -ErrorAction SilentlyContinue -Confirm
        If (Test-Path "$currentFolder\$Output.csv")
        {
            Write-Error "File $currentFolder\$Output.csv already exists. Exiting."
            Exit
        }
        Remove-Item "$currentFolder\$Output.log" -ErrorAction SilentlyContinue -Confirm

        # Set the signInAttribute
        If ($AlternativeLoginId)
        {
            $signInAttribute = 'mail'
        }
        Else
        {
            $signInAttribute = 'UserPrincipalName'
        }

        $usersFound = $usersNotFound = 0
        $defaultProperties = @('UserPrincipalName','ObjectGUID','mS-DS-ConsistencyGuid','distinguishedName')
    }
    Process
    {
        #$objectResult = $msgResult = $seachResult = $null
        $logMessage = "AD user with $UserPrincipalName in $signInAttribute" + "`t" + "ImmutableId: $ImmutableIdGUID"
        $seachResult = $null

        # Initiate custom object
        $objectResult = New-Object -TypeName PSObject
        $objectResult | Add-Member -MemberType NoteProperty -Name UserPrincipalName -Value $UserPrincipalName
        $objectResult | Add-Member -MemberType NoteProperty -Name ImmutableIdGUID -Value $ImmutableIdGUID
        $objectResult | Add-Member -MemberType NoteProperty -Name OnPremisesUPN -Value $null
        $objectResult | Add-Member -MemberType NoteProperty -Name ObjectGUID -Value $null
        $objectResult | Add-Member -MemberType NoteProperty -Name ConsistencyGuid -Value $null
        $objectResult | Add-Member -MemberType NoteProperty -Name SearchResult -Value $null
        $objectResult | Add-Member -MemberType NoteProperty -Name Action -Value $null
        $objectResult | Add-Member -MemberType NoteProperty -Name Description -Value $null
        $objectResult | Add-Member -MemberType NoteProperty -Name DistinguishedName -Value $null

        Write-Verbose "UserPrincipalName : $UserPrincipalName | ImmutableIdGUID : $ImmutableIdGUID"

        # Search for User in AD
        Try
        {
            $user = Get-ADObject -Filter '$signInAttribute -eq $UserPrincipalName' -Properties $defaultProperties  -ErrorAction Stop
        }
        Catch
        {
            Write-Error "Unable to search in ActiveDirectory: $($_.Exception.Message)"
            return
        }

        # User not found searching for the UserPrincipalName
        If ($user -eq $null)
        {
            $seachResult = "UserPrincipalName does not exist in AD"
            $objectResult.OnPremisesUPN = "N/A"
            $objectResult.ObjectGUID = "N/A"
            $objectResult.ConsistencyGuid = "N/A"
            $objectResult.SearchResult = $seachResult
            $objectResult.Action = "Skip"
            $objectResult.Description = "AD User cannot be found"
            $objectResult.DistinguishedName = "N/A"
            $usersNotFound ++

            # Try to search for the UPN prefix in sAMAccountName, if not using Alternative LoginId
            If (-not $AlternativeLoginId)
            {
                # Get the UPN prefix
                Try
                {
                    $upnPrefix = $UserPrincipalName.Substring(0, $UserPrincipalName.IndexOf('@'))
                }
                Catch
                {
                    Write-Error "Invalid UserPrincipalName format: $($_.Exception.Message)"
                }

                # Search for UPN prefix on AD sAMAccountName
                If ($upnPrefix -notlike "")
                {
                    Try
                    {   
                        $user = Get-ADObject -Filter 'sAMAccountName -eq $upnPrefix' -Properties $defaultProperties -ErrorAction Stop
                    }
                    Catch
                    {
                        Write-Error "Unable to search in ActiveDirectory: $($_.Exception.Message)"
                        return
                    }
                    
                    # User Found in AD based on the UPN prefix equal to AD sAMAccountName
                    If ($user -ne $null)
                    {
                        $seachResult = "UserPrincipalName prefix present in AD sAMAccountName"
                        $objectResult.OnPremisesUPN = $user.UserPrincipalName
                        $objectResult.ObjectGUID = $user.ObjectGUID
                        $objectResult.ConsistencyGuid = Get-ADSyncToolsMsDsConsistencyGuid($user)
                        $objectResult.SearchResult = $seachResult
                        $objectResult.Action = ""
                        $objectResult.Description = ""
                        $objectResult.DistinguishedName = $user.distinguishedName
                        $usersFound ++
                        $usersNotFound --
                    }
                }
            }
            $logMessage += "`t" + "Result: $seachResult"
        }
        Else
        {
            # User Found in AD
            $seachResult = "UserPrincipalName is present in AD"
            $objectResult.OnPremisesUPN = $user.UserPrincipalName
            $objectResult.ObjectGUID = $user.ObjectGUID
            $objectResult.ConsistencyGuid = Get-ADSyncToolsMsDsConsistencyGuid($user)
            $objectResult.SearchResult = $seachResult
            $objectResult.DistinguishedName = $user.distinguishedName
            $logMessage += "`t" + "Result: $seachResult"
            $usersFound ++
            $userFound = $true
        }

        # Calculate Action + Action Result
        If ($user -ne $null)
        {
            If ($objectResult.ConsistencyGuid -eq $null)
            {
                # Target AD User does not have a ConsistencyGuid value yet
                $objectResult.Action = "Add"
                $objectResult.Description = "AD User does not have 'mS-DS-ConsistencyGuid' value"

            }
            Else
            {
                # Compare AAD ImmutableId with AD 'mS-DS-ConsistencyGuid' values
                $AdObject = $objectResult | Select ImmutableIdGUID, ConsistencyGuid
                $sourceConsistencyGuid = [GUID] $AdObject.ImmutableIdGUID
                $targetConsistencyGuid = [GUID] $AdObject.ConsistencyGuid
                Write-Verbose "sourceConsistencyGuid : $sourceConsistencyGuid | targetConsistencyGuid : $targetConsistencyGuid"

                If ($sourceConsistencyGuid -eq $targetConsistencyGuid)
                {
                    $objectResult.Action = "Skip"
                    $objectResult.Description = "AD User already have the correct 'mS-DS-ConsistencyGuid'"
                }
                Else
                {
                    $objectResult.Action = "Update"
                    $objectResult.Description = "AD User requires an update of 'mS-DS-ConsistencyGuid'"
                }
            }
        }

        #$objectResult | Select UserPrincipalName, ImmutableIdGUID, ConsistencyGuid, SearchResult, Action, Description
        $objectResult | Select UserPrincipalName, SearchResult, Action, Description
        $objectResult | Export-Csv "$currentFolder\$Output.csv" -NoTypeInformation -Append -Delimiter "`t"
        $logMessage + "`n" | Out-File "$currentFolder\$Output.log" -Append
    }
    End
    {
        Write-Host "`n`nProcessed a total of $($usersFound + $usersNotFound) users | $usersFound Users found + $usersNotFound Users not found"
        Write-Host "Report file: $currentFolder\$Output.csv"
        Write-Host "Log file: $currentFolder\$Output.log"
    }
}


<#
.Synopsis
   Updates users with the new ConsistencyGuid (ImmutableId)
.DESCRIPTION
   Updates users with the new ConsistencyGuid (ImmutableId) value taken from the ConsistencyGuid Report
   This function supports the WhatIf switch
   Note: ConsistencyGuid Report must be imported with Tab delimiter
.EXAMPLE
   Import-Csv .\AllSyncUsersTEST-Report.csv -Delimiter "`t"| Update-ADSyncToolsSourceAnchor -Output .\AllSyncUsersTEST-Result2 -WhatIf
.EXAMPLE
   Import-Csv .\AllSyncUsersTEST-Report.csv -Delimiter "`t"| Update-ADSyncToolsSourceAnchor -Output .\AllSyncUsersTEST-Result2
#>

Function Update-ADSyncToolsSourceAnchor
{
    [CmdletBinding(SupportsShouldProcess)]
    Param
    (
        # DistinguishedName
        [Parameter(Mandatory=$false,
                   ValueFromPipeline=$true,
                   ValueFromPipelineByPropertyName=$true)]
        [String] $DistinguishedName = $false,

        # ImmutableIdGUID
        [Parameter(Mandatory=$true,
                   ValueFromPipeline=$true,
                   ValueFromPipelineByPropertyName=$true)]
        [ValidateNotNullOrEmpty()]
        [String] $ImmutableIdGUID,
        
        # Action
        [Parameter(Mandatory=$true,
                   ValueFromPipeline=$true,
                   ValueFromPipelineByPropertyName=$true)]
        [ValidateNotNullOrEmpty()]
        [String] $Action,

        # Output filename for LOG files
        [Parameter(Mandatory=$true)]
        [String] $Output
    )

    Begin
    {
        Import-ADSyncToolsModule -ModuleName ActiveDirectory -InstallMessage $addsInstallMsg

        # Check/Remove output files
        $currentFolder = (Get-Location).Path
        
        Remove-Item "$currentFolder\$Output.log" -ErrorAction SilentlyContinue -Confirm
        Write-Verbose "WhatIfPreference: $WhatIfPreference"
    }
    Process
    {
        # Process each user with Add or Update action
        Write-Verbose "$Action | Value:$ImmutableIdGUID | User:$DistinguishedName"
        If ($Action -eq 'Add' -or $Action -eq 'Update')
        {
            $logMessage = "$Action | Value:$ImmutableIdGUID | User:$DistinguishedName"
            Write-Host $logMessage

            $adObject = Search-ADSyncToolsADobject -User $DistinguishedName

            If ($adObject)
            {
                Try
                {
                    if ($PSCmdlet.ShouldProcess($adObject, $Action))
                    {
                        $newValue = [GUID] $ImmutableIdGUID
                        Set-ADObject -Identity $adObject -Replace @{'mS-DS-ConsistencyGuid'=$newValue}
                        $logMessage += " | Result: Success"
                        $usersUpdated ++
                    }
                    
                }
                Catch
                {
                    # Could not set user
                    Write-Error "Unable to set user $adObject in Active Directory: $($_.Exception.Message)"
                    $logMessage += " | Result: Failed"
                    $usersFailed ++
                }
            }

            $logMessage + "`n" | Out-File "$currentFolder\$Output.log" -Append
        }
    }
    End
    {
        Write-Host "`n`nProcessed a total of $($usersUpdated + $usersFailed) users | $usersUpdated Users Updated + $usersFailed Users Failed"
        Write-Host "Log file: $currentFolder\$Output.log"
    }
}

#endregion
#=======================================================================================


#=======================================================================================
#region Mitigation Functions
#=======================================================================================


<#
.Synopsis
   Repair Azure AD Connect AutoUpgrade State
.DESCRIPTION
   Fixes an issue with AutoUpgrade introduced in build 1.1.524.0 (May 2017) which disables the online checking
   of new versions while AutoUpgrade is enabled.
.EXAMPLE
   Repair-ADSyncToolsAutoUpgradeState
#>


Function Repair-ADSyncToolsAutoUpgradeState 
{
    [CmdletBinding()]
    Param()
    
    IsAADConnectPresent -MinVersion '1.1.524.0'
    IsAADConnectPresent -MaxVersion '1.3.20.0'
    IsPowerShellSessionElevated

    # Checking UpdateCheckEnabled Registry key
    $regkey = 'HKLM:\SOFTWARE\Microsoft\ADHealthAgent\Sync'
    $name = 'UpdateCheckEnabled'
    Try
    {
        $val = Get-ItemProperty -Path $regkey -Name $name -ErrorAction Stop
        Write-Host 'UpdateCheckEnabled: ' $val.UpdateCheckEnabled
    }
    Catch [System.Security.SecurityException]
    {
        Write-Error "Please execute this cmdlet in Windows PowerShell with 'Run As Administrator': $($_.Exception.Message)"
        Return
    }

    # Checking ADSync AutoUpgrade Status
    Try
    {
        $autoUpgradeState = Get-ADSyncAutoUpgrade -ErrorAction Stop
        Write-Host 'ADSyncAutoUpgrade: ' $autoUpgradeState
    }
    Catch
    {
        Write-Error "Error retrieving ADSync AutoUpgrade status: $($_.Exception.Message)"
        Return
    }    

    # Checking AutoUpgrade State Fix
    $isAgentDisabled = $val.UpdateCheckEnabled -eq 0
    $isAutoUpgradeAllowed = $autoUpgradeState -ne 'disabled'
    If($isAutoUpgradeAllowed -and $isAgentDisabled) 
    {
        # Applying AutoUpgrade Fix
        Write-Host 'Fixing AutoUpgrade status and restarting AutoUpgrade service...'
        Set-ItemProperty -path $regkey -name $name -value 1
        Restart-Service 'AzureADConnectHealthSyncMonitor'
        Write-Host 'Result: AutoUpgrade has been fixed successfully.'
    }
    Else
    {
        # Skipping AutoUpgrade Fix
        Write-Host 'Result: AutoUpgrade fix is not required.'
    }
}

Function Get-ADSyncToolsTls12RegValue
{
    [CmdletBinding()]
    Param
    (
        # Registry Path
        [Parameter(Mandatory=$true,
                   Position=0)]
        [string]
        $RegPath,

        # Registry Name
        [Parameter(Mandatory=$true,
                   Position=1)]
        [string]
        $RegName
    )

    $regItem = Get-ItemProperty -Path $RegPath -Name $RegName -ErrorAction Ignore
    $regKey = Get-Item -Path $RegPath -ErrorAction Ignore

    $output = "" | select Path, Name, Value, Type
    $output.Path = $RegPath
    $output.Name = $RegName
    

    If ($regItem -eq $null)
    {
        $output.Value = "Not Found"
        $output.Type = "N/A"
        
    }
    Else
    {
        $output.Value = $regItem.$RegName
        $output.Type = $regKey.GetValueKind($RegName)
    }
    $output
}

<#
.Synopsis
   Gets Client\Server TLS 1.2 settings for .NET Framework
.DESCRIPTION
   Reads information from the Registry regarding TLS 1.2 for .NET Framework:
 
    Path Name
    ---- ----
    HKLM:\SOFTWARE\WOW6432Node\Microsoft\.NETFramework\v4.0.30319 SystemDefaultTlsVersions
    HKLM:\SOFTWARE\WOW6432Node\Microsoft\.NETFramework\v4.0.30319 SchUseStrongCrypto
    HKLM:\SOFTWARE\Microsoft\.NETFramework\v4.0.30319 SystemDefaultTlsVersions
    HKLM:\SOFTWARE\Microsoft\.NETFramework\v4.0.30319 SchUseStrongCrypto
    HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Server Enabled
    HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Server DisabledByDefault
    HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Client Enabled
    HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Client DisabledByDefault
 
.EXAMPLE
   Get-ADSyncToolsTls12
.LINK
   More Information:
    TLS 1.2 enforcement for Azure AD Connect
    https://docs.microsoft.com/en-us/azure/active-directory/hybrid/reference-connect-tls-enforcement
#>

Function Get-ADSyncToolsTls12
{
    [CmdletBinding()]
    Param
    ()

    $regSettings = @()
    $regKey = 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\.NETFramework\v4.0.30319'
    $regSettings += Get-ADSyncToolsTls12RegValue $regKey 'SystemDefaultTlsVersions'
    $regSettings += Get-ADSyncToolsTls12RegValue $regKey 'SchUseStrongCrypto'

    $regKey = 'HKLM:\SOFTWARE\Microsoft\.NETFramework\v4.0.30319'
    $regSettings += Get-ADSyncToolsTls12RegValue $regKey 'SystemDefaultTlsVersions'
    $regSettings += Get-ADSyncToolsTls12RegValue $regKey 'SchUseStrongCrypto'

    $regKey = 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Server'
    $regSettings += Get-ADSyncToolsTls12RegValue $regKey 'Enabled'
    $regSettings += Get-ADSyncToolsTls12RegValue $regKey 'DisabledByDefault'

    $regKey = 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Client'
    $regSettings += Get-ADSyncToolsTls12RegValue $regKey 'Enabled'
    $regSettings += Get-ADSyncToolsTls12RegValue $regKey 'DisabledByDefault'

    $regSettings
}

<#
.Synopsis
   Sets Client\Server TLS 1.2 settings for .NET Framework
.DESCRIPTION
   Sets the registry entries to enable/disable TLS 1.2 for .NET Framework:
 
    Path Name
    ---- ----
    HKLM:\SOFTWARE\WOW6432Node\Microsoft\.NETFramework\v4.0.30319 SystemDefaultTlsVersions
    HKLM:\SOFTWARE\WOW6432Node\Microsoft\.NETFramework\v4.0.30319 SchUseStrongCrypto
    HKLM:\SOFTWARE\Microsoft\.NETFramework\v4.0.30319 SystemDefaultTlsVersions
    HKLM:\SOFTWARE\Microsoft\.NETFramework\v4.0.30319 SchUseStrongCrypto
    HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Server Enabled
    HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Server DisabledByDefault
    HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Client Enabled
    HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Client DisabledByDefault
 
   Running the cmdlet without any parameters will enable TLS 1.2 for .NET Framework
 
   More Information:
    TLS 1.2 enforcement for Azure AD Connect
    https://docs.microsoft.com/en-us/azure/active-directory/hybrid/reference-connect-tls-enforcement
 
.EXAMPLE
   Set-ADSyncToolsTls12
.EXAMPLE
   Set-ADSyncToolsTls12 -Enabled $true
#>

Function Set-ADSyncToolsTls12
{
    [CmdletBinding()]
    Param
    (
        # TLS 1.2 Enabled
        [Parameter(Mandatory=$false,
                   ValueFromPipelineByPropertyName=$true,
                   ValueFromPipeline=$true,
                   Position=0)]
        [bool]
        $Enabled = $true
    )

    $ErrorActionPreference = 'Stop'

    Write-Warning 'Modifying TLS settings may affect other services on the Server.' -WarningAction Inquire

    If ($Enabled)
    {
        $regValueEnabled = '1'
        $regValueDisabled = '0'
        $message = 'TLS 1.2 has been enabled. You must restart the Windows Server for the changes to take affect.'
    }
    Else
    {
        $regValueEnabled = '0'
        $regValueDisabled = '1'
        $message = 'TLS 1.2 has been disabled. You must restart the Windows Server for the changes to take affect.'
    }
    
    Try
    {
        If (-Not (Test-Path 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\.NETFramework\v4.0.30319'))
        {
            New-Item 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\.NETFramework\v4.0.30319' -Force | Out-Null
        }
        New-ItemProperty -Path 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\.NETFramework\v4.0.30319' -Name 'SystemDefaultTlsVersions' -Value $regValueEnabled -PropertyType 'DWord' -Force | Out-Null
        New-ItemProperty -Path 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\.NETFramework\v4.0.30319' -Name 'SchUseStrongCrypto' -Value $regValueEnabled -PropertyType 'DWord' -Force | Out-Null

        If (-Not (Test-Path 'HKLM:\SOFTWARE\Microsoft\.NETFramework\v4.0.30319'))
        {
            New-Item 'HKLM:\SOFTWARE\Microsoft\.NETFramework\v4.0.30319' -Force | Out-Null
        }
        New-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\.NETFramework\v4.0.30319' -Name 'SystemDefaultTlsVersions' -Value $regValueEnabled -PropertyType 'DWord' -Force | Out-Null
        New-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\.NETFramework\v4.0.30319' -Name 'SchUseStrongCrypto' -Value $regValueEnabled -PropertyType 'DWord' -Force | Out-Null

        If (-Not (Test-Path 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Server'))
        {
            New-Item 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Server' -Force | Out-Null
        }
        New-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Server' -Name 'Enabled' -Value $regValueEnabled -PropertyType 'DWord' -Force | Out-Null
        New-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Server' -Name 'DisabledByDefault' -Value $regValueDisabled -PropertyType 'DWord' -Force | Out-Null

        If (-Not (Test-Path 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Client'))
        {
            New-Item 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Client' -Force | Out-Null
        }
        New-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Client' -Name 'Enabled' -Value $regValueEnabled -PropertyType 'DWord' -Force | Out-Null
        New-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Client' -Name 'DisabledByDefault' -Value $regValueDisabled -PropertyType 'DWord' -Force | Out-Null
    }
    Catch [System.Security.SecurityException]
    {
        Throw "Please execute this cmdlet in Windows PowerShell with 'Run As Administrator': $($_.Exception.Message)"
    }
    Catch
    {
        Throw "Failed to set Registry settings. Error Details: $($_.Exception.Message)"
    }

    Write-Host $message -ForegroundColor Cyan
}

#endregion
#=======================================================================================


#=======================================================================================
#region SQL Functions
#=======================================================================================

Function GetInnerExceptionMessage
{
    Param 
    (
        $exception
    )
    
    $innerException = $exception.InnerException
    If ($innerException)
    {
        return $innerException.Message
    }

    Return $null;
}

Function SplitString
{
    Param 
    (
        [string] $Source,
        [string] $SplitCharacter,
        [switch] $RemoveDuplicates
    )
    
    If ($RemoveDuplicates)
    {
        $splitOption = [System.StringSplitOptions]::RemoveEmptyEntries
    }
    Else
    {
        $splitOption = [System.StringSplitOptions]::None
    }

    $records = $Source.Split($SplitCharacter, $splitOption)
    Return $records
}

Function ParseBrowserResponse
{
    Param 
    (
        [string] $ResponseString
    )
    
    # A SQL Browser response looks like instance;;instance;;instance;; where each instance string
    # contains a list of parameter/value pairs encoded as: p1;v1;p2;v2;p3;v3;p4;v4;;
    $response = $ResponseString.Substring(3,$ResponseString.Length-3).Replace(";;","~")
    $instanceRecords = SplitString -Source $response -SplitCharacter "~" -RemoveDuplicates
    Write-Host SQL browser response contained $instanceRecords.Length instances.

    $Instances = @();
    ForEach ($instance in $instanceRecords)
    {
        $sqlInstance = New-Object -TypeName PsObject
        $config = SplitString -Source $instance -SplitCharacter ";"
        $param = 0

        $sqlInstance | Add-Member -MemberType NoteProperty -Name BrowserRecord -Value $instance
        For ($param = 0; $param -lt $config.Count; $param + 2)
        {
            $keyword = $config[$param]
            $value = $config[$param + 1]
            $sqlInstance | Add-Member -MemberType NoteProperty -Name $keyword -Value $value 
            $param += 2
        }
        $instances += $sqlInstance
    }
    Return $instances
}

<#
.Synopsis
   Get SQL Server Instances from SQL Browser service
.DESCRIPTION
   SQL Diagnostics related functions and utilities
.EXAMPLE
   Get-ADSyncToolsSqlBrowserInstances -Server 'sqlserver01'
#>

Function Get-ADSyncToolsSqlBrowserInstances
{
    Param
    (
        # SQL Server Name
        [string] 
        $Server
    )

    $Port = 1434
    $ConnectionTimeout = 1000

    $UDPClient = new-Object system.Net.Sockets.Udpclient
    $UDPClient.client.ReceiveTimeout = $ConnectionTimeout
    $UDPClient.Client.Blocking = $True

    Write-Progress -Activity "Attempting to retrieve instance information for '$Server'" -Status "Querying the SQL Server Browser service"
    Try
    {
        $UDPClient.Connect($Server, $Port)
    }
    Catch
    {
        $innerExceptionMsg = GetInnerExceptionMessage($_.Exception)
        Write-Error -Category ConnectionError "Unable to connect to the SQL Server Browser service on $Server port $Port (UDP). $innerExceptionMsg."
        Return $null
    }

    $rawResponse = ""
    Try 
    {
        $UDPPacket = 0x02,0x00,0x00
        $UDPEndpoint = New-Object System.Net.IPEndPoint([system.net.ipaddress]::Any,0)
        [void]$UDPClient.Send($UDPPacket, $UDPPacket.length)
        $BytesRecived = $UDPClient.Receive([ref]$UDPEndpoint)

        $ToASCII = New-Object System.Text.ASCIIEncoding
        $rawResponse = $ToASCII.GetString($BytesRecived)
        $socket = $null
        $UDPClient.Close()
    }
    Catch 
    {
        Write-Progress -Activity "Attempting to retrieve instance information for $Server" -Status "Failed" -Completed

        $innerExceptionMsg = GetInnerExceptionMessage($_.Exception)
        $message = "Unable to read the SQL Server Browser configuration. "
        $message += $innerExceptionMsg + ". "
        $message += "Ensure port $port (UDP) is open on $Server and the SQL Server Browser service is running. "
        Write-Error -Category ConnectionError $message
        $UDPClient.Close()
        Return $null
    }

    If ($rawResponse) 
    {
        $instances = @(ParseBrowserResponse -ResponseString $rawResponse)

        Write-Host "Verifying protocol bindings and port connectivity..."
        $step = 0
        ForEach ($instance in $Instances)
        {
            $instanceName = $instance.InstanceName
            $port = $instance.tcp

            $step++
            $complete = ($step * 100) / $instances.Count
            If ($instance.tcp)
            {
                Write-Progress -Activity "Verifying SQL Browser Configuration" -Status "Instance: $instanceName - connecting to port $port" -PercentComplete $complete
                $isPortOpen = Test-ADSyncToolsSqlNetworkPort -Server $Server -Port $instance.tcp
                if ($isPortOpen)
                {
                    $status = "Enabled - port $port is assigned and reachable through the firewall"
                    $instance | Add-Member -MemberType NoteProperty -Name TcpStatus -Value $status
                    Start-Sleep -Seconds 2
                }
                Else
                {
                    $status = "Blocked - the inbound firewall rule for port $port is missing or disabled"
                    $instance | Add-Member -MemberType NoteProperty -Name TcpStatus -Value $status
                }
            }
            Else
            {
                Write-Progress -Activity "Verifying SQL Browser Configuration" -Status "Instance: $instanceName - TCP/IP binding is disabled" -PercentComplete $complete
                $status = "Disabled - the TCP/IP binding for this instance is missing or disabled"
                $instance | Add-Member -MemberType NoteProperty -Name tcp -Value Disabled
                $instance | Add-Member -MemberType NoteProperty -Name TcpStatus -Value $status
                Start-Sleep -Seconds 2
            }

            $progressMsg = "{0,-15} : {1}" -f $instanceName,$status
            Write-Host $progressMsg
        }
        Write-Progress -Activity "Verifying SQL Firewall Configuration" -Status "Completed" -Completed
        Return $Instances
    }
    Return $null
}


<#
.Synopsis
   Gets the status of SQL Server Protocols
.DESCRIPTION
   Displays all SQL Server Protocols status running on the SQL Server.
   SQL LocalDB is not supported.
.EXAMPLE
   Get-ADSyncToolsSqlProtocols
#>

function Get-ADSyncToolsSqlProtocols
{
    [CmdletBinding()]
    Param
    ()

    # Check if module is installed
    Try
    {
        Import-Module SQLPS -ErrorAction Stop
    }
    Catch
    {
        Throw "This function requires SQL PowerShell module installed (SQLPS)"
    }

    # Create a SQL Management object
    Try
    {
        $smo = 'Microsoft.SqlServer.Management.Smo.'
        $wmi = New-Object ($smo + 'Wmi.ManagedComputer')
    }
    Catch
    {
         Throw "There was an error creating SQL Management object. Error Details: $($_.Exception.Message)"
    }

    # Show SQL Server settings
    "SQL Server Configuration:"
    $wmi

    # Show SQL Client Protocols
    "SQL Client Protocols:" 
    $wmi.ClientProtocols | select DisplayName,IsEnabled,Urn | fl


    "`nSQL Named Pipes Protocol:"
    $urnClientNp = ($wmi.ClientProtocols | where Name -eq 'np').Urn.Value
    $clientNp = $wmi.GetSmoObject($urnClientNp)
    $clientNp | select DisplayName,Name,IsEnabled,NetworkLibrary | fl


    # Show SQL Server Protocols
    $serverInstances = @($wmi.ServerInstances | %{$_ | select Name,ServerProtocols,Urn})

    "SQL Server Protocols:" 
    ForEach ($instance in $serverInstances)
    {
        $instance
    
        $serverProtocol = $instance.ServerProtocols | where Name -eq 'Np' | 
            select DisplayName,Name,IsEnabled,`
                @{Name='Enabled'; Expression={$_.ProtocolProperties['Enabled'].Value}},`
                @{Name='PipeName'; Expression={$_.ProtocolProperties['PipeName'].Value}}

        $serverProtocol | fl
    }
}


<#
.Synopsis
   Test the SQL Server network port
.DESCRIPTION
   SQL Diagnostics related functions and utilities
.EXAMPLE
   Test-ADSyncToolsSqlNetworkPort -Server 'sqlserver01'
.EXAMPLE
   Test-ADSyncToolsSqlNetworkPort -Server 'sqlserver01' -Port 1433
#>

Function Test-ADSyncToolsSqlNetworkPort
{
    Param
    (
        # SQL Server Name
        [string] $Server, 
        
        # SQL Server Port
        [string] $Port
    )

    $tcpClient = New-Object Net.Sockets.TcpClient
    Try
    {
        $tcpClient.Connect($Server, $Port)
    }
    Catch 
    {}


    If ($tcpClient.Connected)
    {
        $tcpClient.Close()
        Return $true
    }

    Return $false
}

<#
.Synopsis
   Resolve a SQL server name
.DESCRIPTION
   SQL Diagnostics related functions and utilities
.EXAMPLE
   Resolve-ADSyncToolsSqlHostAddress -Server 'sqlserver01'
#>

Function Resolve-ADSyncToolsSqlHostAddress
{
    Param 
    (
        # SQL Server Name
        [Parameter(Mandatory=$true)]   
        [string] $Server
    )

    Try
    {
        Write-Host Resolving server address : $Server
        $ipAddresses = [System.Net.Dns]::GetHostAddresses($Server) | Select-Object -Property AddressFamily, IPAddressToString
        foreach ($address in $ipAddresses)
        {
            Write-Host " $($address.AddressFamily): $($address.IPAddressToString) `n"
        }
        Return $ipAddresses
    }
    Catch
    {
        $innerExceptionMsg = GetInnerExceptionMessage($_.Exception)
        Write-Error -Category ObjectNotFound "Unable to resolve host address '$Server'. Error Details: $innerExceptionMsg"
        Return $null
    }
}

<#
.Synopsis
   Connect to a SQL database for testing purposes
.DESCRIPTION
   SQL Diagnostics related functions and utilities
.EXAMPLE
   Connect-ADSyncToolsSqlDatabase -Server 'sqlserver01.contoso.com' -Database 'ADSync'
.EXAMPLE
   Connect-ADSyncToolsSqlDatabase -Server 'sqlserver01.contoso.com' -Instance 'INSTANCE01' -Database 'ADSync'
#>

Function Connect-ADSyncToolsSqlDatabase
{
    Param 
    (
        # SQL Server Name
        [Parameter(Mandatory=$true)]   
        [string] $Server,
        
        # SQL Server Instance Name
        [string] $Instance,
        
        # SQL Server Database Name
        [string] $Database,

        # SQL Server Port (e.g. 49823)
        [string] $Port,

        # SQL Server Login Username
        [string] $UserName,

        # SQL Server Login Password
        [string] $Password
    )

    $ErrorActionPreference = 'Continue'
    $sqlConfigMgr = "SQL Server Configuration Manager"

    # Bail immediately if we can't resolve the server name
    $ipAddresses = Resolve-ADSyncToolsSqlHostAddress -Server $Server
    if (-not $ipAddresses)
    {
        Return
    }

    # Try connecting over TCP using the full instance name + optional port ex: "MySqlInstance,1234"
    # If this succeeds return the connection object for use in SQL queries
    $sqlTcpConnection = New-ADSyncToolsSqlConnection `
                                -Server $Server `
                                -Instance $Instance `
                                -Database $Database `
                                -Protocol 'tcp' `
                                -Port $Port `
                                -UserName $UserName `
                                -Password $Password
    If ($Instance)
    {
        Write-Host Attempting to connect to $Server\$Instance using a TCP binding.
    }
    Else
    {
        Write-Host Attempting to connect to $Server using a TCP binding for the default instance.
    }
    Write-Host " $($sqlTcpConnection.ConnectionString)"
    Write-Progress -Activity "Connecting to $Instance on $Server" -Status "Attempting TCP/IP connection"

    Try
    {
        $sqlTcpConnection.Open()
        Write-Host " Successfully connected."
        Return $sqlTcpConnection
    }
    Catch
    {
        $innerExceptionMsg = GetInnerExceptionMessage($_.Exception)
        Write-Error -Category ConnectionError "Unable to connect using a TCP binding. $innerExceptionMsg `n"
    }

    # Parse out the SQL instance name and port as the latter only makes sense for TCP connections
    Write-Progress -Activity "Connecting to $Instance on $Server" -Completed 
    $instanceName = $Instance
    $port = $null
    if ($Instance)
    {
        $instanceParams = SplitString -Source $Instance -SplitCharacter "," -RemoveDuplicates
        if ($instanceParams.Count -eq 2)
        {
            $instanceName = $instanceParams[0]
            $port = $instanceParams[1]
        }
    }

    # Fall thru to troubleshooting / diagnostic steps
    Write-Host "TROUBLESHOOTING: Attempting to query the SQL Server Browser service configuration on $Server. `n"
    $instances = Get-ADSyncToolsSqlBrowserInstances -Server $Server

    # The SQL browser tells us all enabled protocols and ports. Whip thru the list testing the
    # TCP ports to see if they can be successfully opened. A failure here is most likely due to
    # a missing inbound firewall rule.
    Write-Host "`nWHAT TO TRY NEXT: `n"
    If ($instances)
    {
        $sqlBrowserEnabled = $true
        [string] $message = "Each SQL instance must be bound to an explicit static TCP port and paired with an "
        $message += "inbound firewall rule on $Server to allow connection. Review the TcpStatus field "
        $message += "for each instance and take corrective action. `n"        
        Write-Host $message
        $instances | Format-List -Property InstanceName,tcp,TcpStatus
    }
    # The browser isn't running so the best we can do is give some advice and probe the port to see
    # if it can be successfully opened. The user should use the SQL Server Configuration Manager to
    # verify the protocol bindings and/or start the SQL Server Browser service give this script more
    # information to further troubleshoot the issue.
    Else
    {
        $sqlBrowserEnabled = $false
        $message  = "Each SQL instance must be bound to an explicit static TCP port and paired with an inbound firewall rule on $Server to allow connection. "
        $message += "Enable the SQL Server Browser service temporarily on the SQL server and use Get-ADSyncToolsSqlBrowserInstances to further troubleshoot the issue. " 
        $message += "Alternatively use the $sqlConfigMgr on $Server to verify the instance name and TCP/IP port assignment manually. `n"
        Write-Host $message 

        # If no instance was given we test the typical port for the DEFAULT SQL instance (TCP 1433). If we can't
        # connect then most likely they are missing a firewall rule OR have tinkered with the default port.
        If (-not $Instance)
        {
            $portRequired = $false

            Write-Host "Determining if the default SQL instance port (TCP 1433) is open on" $Server.
            $portOpen = Test-ADSyncToolsSqlNetworkPort -Server $Server -Port 1433
            If ($portOpen)
            {
                Write-Host " " The port for the default SQL instance is open.
            }
            Else
            {
                $message =  "The typical port for the DEFAULT SQL instance (TCP 1433) is not open. Use the $sqlConfigMgr to verify "
                $message += "the SQL configuration and ensure an inbound firewall rule is opened on $Server. `n"
                Write-Host $message
            }
        }
        # For instances other than the default, both the name + port must be given in order to connect
        Else 
        {
            $portRequired = $true

            # With no browser, the SQL client won't be able to connect unless we specify the port number!
            If (!$port)
            {
                $message =  "You must specify both the instance name and the port to connect when the SQL Server Browser service is not running. "
                $message += "An inbound firewall rule on $Server is required for the associated port.`n"
                $message += "`tExample: 'MySQLInstance,1234' where 1234 has a matching firewall rule."
                Write-Host $message
            }
            # Test whether or not we can open the specified port. If we can't then most likely the firewall rule is missing.
            Else
            {
                Write-Host "To connect to the $instanceName instance, $Server must have an inbound firewall rule for port $port."
                Write-Progress -Activity "Verifying network connectivity" -Status "Instance: $instanceName - connecting to port $port" 
                Write-Host "Verifying port $port on $Server is open."
                $portOpen = Test-ADSyncToolsSqlNetworkPort -Server $Server -Port $port
                If ($portOpen)
                {
                    Write-Host "Successfully probed port. `n"
                }
                Else
                {
                    Write-Host "Unable to open port $port. `n"
                    $message = "Use the $sqlConfigMgr on $Server to verify the instance name and port assignment. "
                    $message += "Then verify an associated inbound firewall rule is opened on $Server. `n"
                    Write-Host $message
                }
                Write-Progress -Activity "Verifying network connectivity" -Completed
            }
        }
    }
}

<#
.Synopsis
   Invoke a SQL query against a database for testing purposes
.DESCRIPTION
   SQL Diagnostics related functions and utilities
.EXAMPLE
   New-ADSyncToolsSqlConnection -Server SQLserver01.Contoso.com -Port 49823 | Invoke-ADSyncToolsSqlQuery
.EXAMPLE
   $sqlConn = New-ADSyncToolsSqlConnection -Server SQLserver01.Contoso.com -Port 49823
   Invoke-ADSyncToolsSqlQuery -SqlConnection $sqlConn -Query 'SELECT *, database_id FROM sys.databases'
#>

Function Invoke-ADSyncToolsSqlQuery 
{
     Param
     (
        # SQL Connection
        [Parameter(Mandatory=$true,
                   ValueFromPipelineByPropertyName=$true,
                   ValueFromPipeline=$true,
                   Position=0)]
        [System.Data.SqlClient.SqlConnection] 
        $SqlConnection,

        # SQL Query
        [Parameter(Mandatory=$false,
                   Position=1)]
        [string]
        $Query = "SELECT name, database_id FROM sys.databases"
      )

    $command = New-Object System.Data.SqlClient.SqlCommand($Query, $SqlConnection)
    $adapter = New-Object System.Data.SqlClient.SqlDataAdapter($command)
    $dataset = New-Object System.Data.DataSet

    Try
    {
        $rows = $adapter.Fill($dataSet)       
    }
    Catch
    {
        Write-Error -Category InvalidOperation "Query failed. $_.Exception.Message"
        Return $null
    }
    
    Write-Host "Query returned $rows rows."
    Return $dataSet.Tables
}


<#
.Synopsis
   Create a SQL client connection
.DESCRIPTION
   SQL Diagnostics related functions and utilities
.EXAMPLE
   New-ADSyncToolsSqlConnection -Server SQLserver01.Contoso.com
.EXAMPLE
   New-ADSyncToolsSqlConnection -Server SQLserver01.Contoso.com -Port 49823
.EXAMPLE
   New-ADSyncToolsSqlConnection -Server SQLserver01.Contoso.com -Database ADSync -Instance AADCONNECT1 -Port 49823 -Protocol tcp
#>

Function  New-ADSyncToolsSqlConnection
{
    Param 
    (
        # SQL Server Name
        [Parameter(Mandatory=$true)]   
        [string] $Server,
        
        # SQL Server Instance Name
        [string] $Instance,
        
        # SQL Server Database Name
        [string] $Database,
        
        # SQL Server Protocol (e.g. tcp)
        [string] $Protocol,

        # SQL Server Port (e.g. 49823)
        [string] $Port,

        # SQL Server Login Username
        [string] $UserName,

        # SQL Server Login Password
        [string] $Password
    )

    $sqlBinding = New-Object System.Data.SqlClient.SqlConnectionStringBuilder
    $sqlBinding['Integrated Security'] = $true
    # $sqlBinding['Connect Timeout'] = 30

    If ($Port)
    {
        $ServerBinding = "$Server,$Port"
    }
    Else
    {
        $ServerBinding = "$Server"
    }
    
    If ($Protocol) 
    {
        $sqlBinding['Data Source'] = "${Protocol}:$ServerBinding\$Instance"
    }
    Else
    {
        $sqlBinding['Data Source'] = "$ServerBinding\$Instance"
    }

    If ($Database) 
    {
        $sqlBinding['Initial Catalog'] = $Database
    }

    If ($UserName) 
    {
        $sqlBinding['User ID'] = $UserName
    }

    If ($Password) 
    {
        $sqlBinding['Password'] = $Password
    }
    $sqlConnection = New-Object System.Data.SqlClient.SqlConnection ($sqlBinding)
    Return $sqlConnection
}

#endregion
#=======================================================================================


#=======================================================================================
#region Duplicate Users SourceAnchor Tool
#=======================================================================================


Function Get-ADSyncToolsDuplicateUsersSourceAnchor
{
    [CmdletBinding()]
    Param
    (
        # AD connector name for which user source anchors needs to be repaired
        [Parameter(Mandatory=$true, 
                   ValueFromPipelineByPropertyName=$true)]
        $ADConnectorName
    )

    Write-Verbose "Entering: Get-ADSyncToolsDuplicateUsersSourceAnchor"
    IsAADConnectPresent
    
    # Import Modules
    Import-ADSyncToolsModule -ModuleName ActiveDirectory -InstallMessage $addsInstallMsg
    $aadConnectRegistryKey = Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Azure AD Connect'
    $modulePath    = [System.IO.Path]::Combine($aadConnectRegistryKey.InstallationPath, "AADPowerShell\MSOnline.psd1")
    Try
    {
        Import-Module $modulePath -ErrorAction Stop
    }
    Catch
    {
        Throw "Failed to import MSOnline module from '$modulePath'. Error Details: $($_.Exception.Message)"
    }

    # Run csexport.exe
    $exportFilePath = $env:temp + "\export.xml"
    $exportFilePathArgs = " " + $exportFilePath + " /f:i"
    $csExportFilePath = Join-Path -Path $(Get-ADSyncToolsADsyncFolder) -ChildPath 'Bin\csexport.exe'

    If (Test-Path $exportFilePath)
    {
        Remove-Item -Path $exportFilePath
    }
    Start-Process -FilePath $csExportFilePath -ArgumentList `"$ADConnectorName`",$exportFilePathArgs -Wait

    Try
    {
        [xml] $content = Get-Content -Path $exportFilePath -ErrorAction Stop
    }
    Catch
    {
        Write-Host "No synchronization errors present in connector space '$ADConnectorName'." -ForegroundColor Cyan
    }

    # Process sync errors
    $exportErrorObjects = $content.SelectNodes("cs-objects/cs-object")
    $applyFixForAllObjects = $false
    $results = @()
    ForEach ($exportErrorObjectInfo in $exportErrorObjects)
    {
        $callStackInfo = $exportErrorObjectInfo.SelectSingleNode("import-errordetail/import-status/extension-error-info/call-stack").InnerText
        $exportErrorObject = $exportErrorObjectInfo.SelectSingleNode("synchronized-hologram/entry")
        $adDomainName = $exportErrorObjectInfo.SelectSingleNode("fully-qualified-domain-name").InnerText
        If ($callStackInfo -eq $null -or $exportErrorObject -eq $null -or $callStackInfo -NotMatch "SourceAnchor attribute has changed.")
        {
            Continue
        }

        $csObject = Get-ADSyncCSObject -DistinguishedName $exportErrorObject.dn -ConnectorName $ADConnectorName
        $mvObject = Get-ADSyncMVObject -Identifier $csObject.ConnectedMVObjectId

        If ($csObject -and $mvObject -and $adDomainName -and `
            $mvObject.Attributes['sourceAnchor'] -and $mvObject.Attributes['sourceAnchor'].Values[0])
        {
            $duplicateUserSourceAnchorInfo = [DuplicateUserSourceAnchorInfo]::new()
            $duplicateUserSourceAnchorInfo.UserName = $csObject.Attributes['displayName'].Values[0]
            $duplicateUserSourceAnchorInfo.DistinguishedName = $exportErrorObject.dn
            $duplicateUserSourceAnchorInfo.ADDomainName = $adDomainName

            Try
            {
                $duplicateUserSourceAnchorInfo.CurrentMsDsConsistencyGuid = [System.Convert]::FromBase64String($csObject.Attributes['mS-DS-ConsistencyGuid'].Values[0])
                $duplicateUserSourceAnchorInfo.ExpectedMsDsConsistencyGuid = [System.Convert]::FromBase64String($mvObject.Attributes['sourceAnchor'].Values[0])
            
                $results += [pscustomobject]@{
                    DuplicateUserSourceAnchorInfo = $duplicateUserSourceAnchorInfo
                }
            }
            Catch
            {
                Write-Host "Unable to parse MsDsConsistencyGuid value for `"$($DuplicateUserSourceAnchorInfo.UserName)`""
            }
        }
    }
    $results | ForEach-Object -MemberName DuplicateUserSourceAnchorInfo
    Write-Verbose "Exiting: Get-ADSyncToolsDuplicateUsersSourceAnchor"
}


Function Set-ADSyncToolsDuplicateUsersSourceAnchor
{
    [CmdletBinding()]
    Param
    (
        # User list for which the source anchor needs to be fixed
        [Parameter(Mandatory=$true, 
                   Position=0,
                   ValueFromPipeline=$true,
                   ValueFromPipelineByPropertyName=$true)]
        [DuplicateUserSourceAnchorInfo]
        $DuplicateUserSourceAnchorInfo,

        # AD EA/DA Admin Credentials, If not provided default credentials will be used
        [Parameter(Mandatory=$false)]
        [PSCredential] 
        $ActiveDirectoryCredential,

        [Parameter(Mandatory=$false)]
        [bool] 
        $OverridePrompt = $false
    )

    Begin
    {
        Write-Verbose "Entering: Set-ADSyncToolsDuplicateUsersSourceAnchor"
        $abort = $false
    }

    Process
    {
        If (-not $abort)
        {
            $decision = 0
            Write-Host
            If ($OverridePrompt -eq $false)
            {
                $infoUsername = $DuplicateUserSourceAnchorInfo.UserName
                $infoDN = $DuplicateUserSourceAnchorInfo.DistinguishedName
                $infoCurrentGuid = [guid] $DuplicateUserSourceAnchorInfo.CurrentMsDsConsistencyGuid
                $infoExpectedGuid = [guid] $DuplicateUserSourceAnchorInfo.ExpectedMsDsConsistencyGuid
                $question = "'$infoUsername' mS-DS-ConsistencyGuid value will be updated from '$infoCurrentGuid' to '$infoExpectedGuid'. Do you want to continue?"
                $choices  = '&Yes', '&No', '&Cancel'
                Write-Verbose "Prompting to update SourceAnchor for object '$infoDN'..."
                $decision = $Host.UI.PromptForChoice($title, $question, $choices, 1)
            }

            If ($decision -eq 0)
            {
                Write-Host "Updating '$infoDN' mS-DS-ConsistencyGuid from '$infoCurrentGuid' to '$infoExpectedGuid'"
                Try
                {
                    If ($ActiveDirectoryCredential)
                    {
                        Set-ADObject -Identity $infoDN `
                            -Replace @{'mS-DS-ConsistencyGuid'=$DuplicateUserSourceAnchorInfo.ExpectedMsDsConsistencyGuid} `
                            -Credential $ActiveDirectoryCredential `
                            -Server $DuplicateUserSourceAnchorInfo.ADDomainName -ErrorAction Stop
                    }
                    Else
                    {
                        Set-ADObject -Identity $infoDN `
                            -Replace @{'mS-DS-ConsistencyGuid'=$DuplicateUserSourceAnchorInfo.ExpectedMsDsConsistencyGuid} `
                            -Server $DuplicateUserSourceAnchorInfo.ADDomainName -ErrorAction Stop
                    }
                }
                Catch
                {
                    Write-Error "Error updating '$($DuplicateUserSourceAnchorInfo.DistinguishedName)' mS-DS-ConsistencyGuid in Active Directory. Error Details: $($_.Exception.Message)"
            }
            }
            ElseIf ($decision -eq 2)
            {
                $abort = $true
            }
        }
    }

    End
    {
        If ($abort)
        {
            Write-Host "Set-ADSyncToolsDuplicateUsersSourceAnchor execution cancelled."
        }
        Else
        {
            Write-Host "Set-ADSyncToolsDuplicateUsersSourceAnchor execution complete. Run a sync cycle with 'Start-ADSyncSyncCycle' to clear DuplicateUsers SourceAnchor errors." -ForegroundColor Green
        }
        Write-Verbose "Exiting: Set-ADSyncToolsDuplicateUsersSourceAnchor"
    }
}

#endregion
#=======================================================================================


#=======================================================================================
#region DirSyncOverrides feature
#=======================================================================================

<#
.Synopsis
   Compares between on-premises AD and Azure AD the users with DirSyncOverrides set on Mobile and/or OtherMobile
.DESCRIPTION
   Compares all synchronized user objects where 'Mobile' or 'otherMobile' property values differ between Active Directory and Azure AD.
   Reads all synced user objects from Azure AD. Gets the shadow property values of 'mobile' and/or 'otherMobile' (AlternateMobilePhones).
   Reads the 'mobile' or 'otherMobile' set in Azure AD for the same objects to compare.
   Exports the user objects and values that differ between on-premises Active Directory and Azure AD to a CSV file.
.EXAMPLE
   Compare-ADSyncToolsDirSyncOverrides -Credential $(Get-Credential)
.OUTPUTS
   This cmdlet generates a CSV file containing all synchronized user objects which 'mobile' and/or 'otherMobile' (AlternateMobilePhones)
   values differ between Active Directory and Azure AD.
   Such objects have a property called "DirSyncOverrides" set which is not publicly visible. Objects with this property set will ignore
   updates of 'mobile' or 'otherMobile' attributes synchronized from Active Directory to Azure AD.
#>

Function Compare-ADSyncToolsDirSyncOverrides
{
    [CmdletBinding()]
    Param
    (
        # Azure AD Global Admin Credential
        [Parameter(Mandatory=$true, 
                   Position=0,
                   ValueFromPipelineByPropertyName=$true)]
        [PSCredential] 
        $Credential
    )
    
    # Verify if MSOnline module is installed and install it if missing
    $MSOnlineModule = Get-Module -Name MSOnline -ListAvailable
    If ($null -eq $MSOnlineModule)
    {
        InstallModuleDepedency -ModuleName MSOnline
    }

    # Connect to Azure AD using the MSOnline module
    Write-Host "Connecting to Azure AD. Please wait..." -ForegroundColor Cyan
    Try
    {
        Import-Module MSOnline
        Connect-MsolService -Credential $Credential -ErrorAction Stop
    }
    Catch
    {
        Throw "An error occurred connecting to Azure AD. Error Details: $($_.Exception.Message)"
    }

    # Create file before processing data
    $filename = Get-ADsyncToolsLogFilename -Name 'DirSyncOverrides' -Extension 'csv'
    Try
    {
        Set-Content $filename "" -ErrorAction Stop
    }
    Catch
    {
        Throw "An error occurred saving the file '$filename'. Error Details: $($_.Exception.Message)"
    }
  

    $properties = @('SourceAnchor','DisplayName','Mail','Mobile','otherMobile', 'UserPrincipalName')
    $dirsyncObjs = Get-ADSyncToolsAadObject -SyncObjectType 'User' -Credential $Credential -Properties $properties | 
        Select-Object 'ObjectId','SourceAnchor','DisplayName','Mail','Mobile','otherMobile','UserPrincipalName'   

    Write-Host "Reading all synchronized User objects from Azure AD..." -ForegroundColor Cyan


    $aadUsers = [System.Collections.ArrayList]@()
    Get-MsolUser -Synchronized -All | & { 
        process {
                $null = $aadUsers.Add($($_ | Select-Object ObjectId, MobilePhone, AlternateMobilePhones, LastDirSyncTime))
                }
    }
    
    # From the first list get the properties set in Azure AD and compare.
    # List objects where there are differences of values on Mobile and otherMobile between Active Directory and Azure Active Directory
    $results = [System.Collections.ArrayList]@()
    Write-Host "Comparing Mobile and OtherMobile values, this process can take some time. Please wait..." -ForegroundColor Cyan
    $dirsyncObjsTotalCount = $dirsyncObjs.count
    $dirsyncObjsCounter = 0
    $showProgressTimer = [System.Diagnostics.Stopwatch]::StartNew()

    ForEach ($dirsyncObj in $dirsyncObjs)
    {
        # Show progress every 3s
        $dirsyncObjsCounter++
        If ($showProgressTimer.Elapsed.TotalMilliseconds -ge 3000)
        {
            $dirsyncObjsComplete = [math]::Round(($dirsyncObjsCounter/$dirsyncObjsTotalCount*100))
            Write-Progress -Activity "Comparing Mobile and OtherMobile values" -Status "$dirsyncObjsComplete% Complete:" -PercentComplete $dirsyncObjsComplete
            $showProgressTimer.Reset()
            $showProgressTimer.Start()
        }

        # Get user object from Azure AD
        $aadUser = $aadUsers | where ObjectId -eq $dirsyncObj.ObjectId
        
        # Compare synchronized User's DirSync Shadow properties with Azure AD properties
        If (![string]::IsNullOrEmpty($aadUser.LastDirSyncTime))
        {
            # Compare Mobile and MobilePhone
            $differentMobile = $false
            If ([string]::IsNullOrEmpty($dirsyncObj.Mobile) -xor [string]::IsNullOrEmpty($aadUser.MobilePhone))
            {
                # Either Mobile or MobilePhone is empty
                $differentMobile = $true
            }
            # Mobile and MobilePhone are either both empty or both present
            ElseIf (![string]::IsNullOrEmpty($aadUser.MobilePhone))
            {
                # Both Mobile and MobilePhone are present - Compare Mobile/MobilePhone
                $dirsyncObjMobile = $null
                $dirsyncObjMobile = $dirsyncObj.Mobile
                
                $aadUserMobile = $null
                $aadUserMobile = $aadUser.MobilePhone
                
                If ($dirsyncObjMobile -ne $aadUserMobile)
                {
                    $differentMobile = $true
                }
                Write-Verbose "Compared AD:Mobile '$dirsyncObjMobile' with AAD:MobilePhone '$aadUserMobile' for object $($dirsyncObj.ObjectId)' | Different = $differentMobile"
            }

            # Compare otherMobile and AlternateMobilePhones
            $differentOtherMobile = $false

            # Note: $dirsyncObj.otherMobile might be an empty array if previously provisioned and then cleared, or an empty string if never provisionined before
            If (($dirsyncObj.otherMobile.Count -eq 0 -or $dirsyncObj.otherMobile -eq '') -xor $aadUser.AlternateMobilePhones.Count -eq 0)
            {
                # Either otherMobile or AlternateMobilePhones is empty and the other has a value
                Write-Verbose "Either otherMobile or AlternateMobilePhones is empty and the other has a value"
                $differentOtherMobile = $true
            }
            # Check if otherMobile and AlternateMobilePhones are either both empty or both present
            ElseIf($aadUser.AlternateMobilePhones.Count -gt 0)
            {
                # Both otherMobile and AlternateMobilePhones have values - Compare array size
                Write-Verbose "Both otherMobile and AlternateMobilePhones have values - Compare array size"
                If ($dirsyncObj.otherMobile.Count -ne $aadUser.AlternateMobilePhones.Count)
                {
                    Write-Verbose "Both otherMobile and AlternateMobilePhones entry count is different"
                    $differentOtherMobile = $true
                }
                Else
                {
                    # Compare otherMobile and AlternateMobilePhones values
                    # Note: Not using Compare-Object because it doesn't respect array order.
                    Write-Verbose "Compare otherMobile and AlternateMobilePhones values"
                    For ($i = 0; $i -lt $aadUser.AlternateMobilePhones.Count; $i++)
                    { 
                        If ($aadUser.AlternateMobilePhones[$i] -ne $dirsyncObj.otherMobile[$i])
                        {
                            $differentOtherMobile = $true
                            break
                        }
                    }                    
                }
                Write-Verbose "Compared otherMobile with AlternateMobilePhones for object $($dirsyncObj.ObjectId)' | Different = $differentOtherMobile"
            }
             
            If ($differentOtherMobile -or $differentMobile)
            {

                $dirsyncObj | Add-Member NoteProperty "MobileInAAD" $aadUser.MobilePhone -Force
                $dirsyncObj | Add-Member NoteProperty "OtherMobileInAAD" $aadUser.AlternateMobilePhones -Force
                $null = $results.Add($dirsyncObj)
                Write-Verbose "Added object '$($dirsyncObj.ObjectId)' | Different Mobile = $differentMobile | Different OtherMobile = $differentOtherMobile"
                
            }
        }
    }
        
    #END
    If ($results.count -gt 0)
    {
        # Save resulting file
        $valueDelimiter = ";"
        Try
        {
            $output = $results | Select-Object UserPrincipalName,DisplayName,ObjectId,SourceAnchor,Mobile,MobileInAAD,@{
                n='otherMobile';e={($_ | Select-Object -ExpandProperty otherMobile) -join $valueDelimiter }},@{
                n='OtherMobileInAAD';e={($_ | Select-Object -ExpandProperty OtherMobileInAAD) -join $valueDelimiter }}
            $output | Export-Csv -Path $filename -NoTypeInformation -Delimiter ',' -ErrorAction Stop
            Write-Host "User list exported to '$filename' successfully." -ForegroundColor Green
        }
        Catch
        {
            Throw "An error occurred saving the file '$filename'. Error Details: $($_.Exception.Message)"
        }
        
    }
    Else
    {
        Write-Host "No users found differing Mobile or OtherMobile (AlternateMobilePhones) values between on-premises Active Directory and Azure AD." -ForegroundColor Cyan
    }
}



<#
.Synopsis
   Gets the on-premises AD Mobile and OtherMobile and/or Azure AD MobilePhone and AlternateMobilePhones attributes
.DESCRIPTION
   Returns the value in Mobile/OtherMobile and/or MobilePhone/AlternateMobilePhones from the source directory.
   Supports Active Directory objects in multi-domain forests.
.EXAMPLE
   Get-ADSyncToolsDirSyncOverridesUser -Identity 'CN=User1,OU=Sync,DC=Contoso,DC=com' -FromAD
.EXAMPLE
   Get-ADSyncToolsDirSyncOverridesUser -Identity 'User1@Contoso.com' -FromAD
.EXAMPLE
   Get-ADSyncToolsDirSyncOverridesUser 'User1@Contoso.com' -FromAzureAD
.EXAMPLE
   'User1@Contoso.com' | Get-ADSyncToolsDirSyncOverridesUser -FromAD -FromAzureAD
#>

Function Get-ADSyncToolsDirSyncOverridesUser
{
    [CmdletBinding()]
    Param 
    (
        # Target object in AD to get
        [Parameter(Mandatory=$true,
                   Position=0,
                   ValueFromPipeline=$true,
                   ValueFromPipelineByPropertyName=$true)]
        [ValidateNotNullOrEmpty()]
        $Identity,

        # Get from AD
        [Parameter(Mandatory=$false,
                   Position=1)]
        [switch]
        $FromAD,

        # Get from AAD
        [Parameter(Mandatory=$false,
                   Position=2)]
        [switch]
        $FromAzureAD

    )
    
    If (!$FromAD -and !$FromAzureAD)
    {
        Throw "Please provide the source directory to get the user object with [-FromAD] and/or [-FromAzureAD] parameters"
    }

    If ($FromAD)
    {
        # Get object from AD
        $adObject = Search-ADSyncToolsADobject $Identity -Properties 'Mobile','OtherMobile'
        Add-Member -InputObject $adObject -MemberType NoteProperty -Name _SourceDirectory -Value "ActiveDirectory" -Force
        $sortedProps = Get-Member -InputObject $adObject -MemberType NoteProperty | select -ExpandProperty Name | Sort-Object
        $adObject | select $sortedProps
    }

    If ($FromAzureAD)
    {
        If (!($Identity -match $upnRegex))
        {
        
            Write-Warning "Please provide a valid UserPrincipalName to get user from Azure AD."
        }
        Else
        {
            Try
            {
                # Get object from Azure AD
                $aadObject = Get-MsolUser -UserPrincipalName $Identity -ErrorAction Stop | select $defaultMsolObjProperties
            }
            Catch
            {
                Throw "Unable to get user '$Identity' from Azure AD. Error Details: $($_.Exception.Message)"
            }
            Add-Member -InputObject $aadObject -MemberType NoteProperty -Name _SourceDirectory -Value "AzureAD" -Force
            $sortedProps = Get-Member -InputObject $aadObject -MemberType NoteProperty | select -ExpandProperty Name | Sort-Object
            $aadObject | select $sortedProps
        }
    }
}


<#
.Synopsis
   Sets the on-premises AD Mobile and OtherMobile and/or Azure AD MobilePhone and AlternateMobilePhones attributes
.DESCRIPTION
   Updates the value in Mobile/OtherMobile and/or MobilePhone/AlternateMobilePhones in the target directory.
   Supports Active Directory objects in multi-domain forests.
.EXAMPLE
   Set-ADSyncToolsDirSyncOverridesUser -Identity 'User1@Contoso.com' -MobileInAD '999888777'
.EXAMPLE
   Set-ADSyncToolsDirSyncOverridesUser -Identity 'CN=User1,OU=Sync,DC=Contoso,DC=com' -MobileInAD '999888777' -OtherMobileInAD '0987654','1234567'
.EXAMPLE
   Set-ADSyncToolsDirSyncOverridesUser -Identity 'CN=User1,OU=Sync,DC=Contoso,DC=com' -MobileInAD '999888777' -Credential <Domain Credential>
.EXAMPLE
   Set-ADSyncToolsDirSyncOverridesUser 'User1@Contoso.com' -MobilePhoneInAAD '999888777'
.EXAMPLE
   Set-ADSyncToolsDirSyncOverridesUser 'User1@Contoso.com' -AlternateMobilePhonesInAAD '0987654','1234567'
.EXAMPLE
   Set-ADSyncToolsDirSyncOverridesUser 'User1@Contoso.com' -MobileInAD '999888777' -AlternateMobilePhonesInAAD '0987654','1234567'
#>

Function Set-ADSyncToolsDirSyncOverridesUser
{
    [CmdletBinding()]
    Param 
    (
        # Target object in AD to upodate
        [Parameter(Mandatory=$true,
                   Position=0,
                   ValueFromPipeline=$true,
                   ValueFromPipelineByPropertyName=$true)]
        [ValidateNotNullOrEmpty()]
        $Identity,

        # Value to set Mobile in AD
        [Parameter(Mandatory=$false,
                   Position=1,
                   ValueFromPipeline=$true,
                   ValueFromPipelineByPropertyName=$true)]
        [string] $MobileInAD,

        # Value to set OtherMobile in AD
        [Parameter(Mandatory=$false,
                   Position=2,
                   ValueFromPipeline=$true,
                   ValueFromPipelineByPropertyName=$true)]
        [string[]] $OtherMobileInAD,

        # Value to set MobilePhone in Azure AD
        [Parameter(Mandatory=$false,
                   Position=3,
                   ValueFromPipeline=$true,
                   ValueFromPipelineByPropertyName=$true)]
        [string] $MobilePhoneInAAD,

        # Value to set AlternateMobilePhones in Azure AD
        [Parameter(Mandatory=$false,
                   Position=4,
                   ValueFromPipeline=$true,
                   ValueFromPipelineByPropertyName=$true)]
        [string[]] $AlternateMobilePhonesInAAD,

        # Credential for target AD Domain
        [Parameter(Mandatory=$false,
                   Position=5)]
        [pscredential]
        $Credential
    )
    
    If (![string]::IsNullOrEmpty($MobileInAD) -or ![string]::IsNullOrEmpty($OtherMobileInAD))
    {
        # Get the target object from AD
        $adObject = Search-ADSyncToolsADobject -Identity $Identity -Properties 'Mobile','OtherMobile'
        Write-Verbose "Found Object '$($adObject.Name)' | Mobile: $($adObject.Mobile) | OtherMobile: $($adObject.OtherMobile)"
        
        If (![string]::IsNullOrEmpty($MobileInAD))
        {
            $targetAttribute = 'Mobile'
            $targetValue = $MobileInAD

            # Set new value on target object in AD
            Set-ADSyncToolsADobject -ADObject $adObject -AttributeName $targetAttribute -AttributeValue $targetValue -Credential $Credential
        }

        If ($null -ne $OtherMobileInAD)
        {
            $targetAttribute = 'OtherMobile'
            $targetValue = $OtherMobileInAD

            # Set new value on target object in AD
            Set-ADSyncToolsADobject -ADObject $adObject -AttributeName $targetAttribute -AttributeValue $targetValue -Credential $Credential
        }
    }
    
    If ((![string]::IsNullOrEmpty($MobilePhoneInAAD) -or ![string]::IsNullOrEmpty($AlternateMobilePhonesInAAD)) -and !($Identity -match $upnRegex))
    {
        
        Throw "Please provide a valid UserPrincipalName to update user in Azure AD."
    }

    If (![string]::IsNullOrEmpty($MobilePhoneInAAD))
    {
        Try
        {
            Set-MsolUser -UserPrincipalName $Identity -MobilePhone $MobilePhoneInAAD -ErrorAction Stop
            Write-Verbose "Attribute 'MobilePhone' updated to '$MobilePhoneInAAD' in '$Identity' object successfully"
        }
        Catch
        {
            Throw "Unable to update 'MobilePhone' with '$OtherMobileInAD' in '$Identity' object: $($_.Exception.Message)"
        }
    }

    If (![string]::IsNullOrEmpty($AlternateMobilePhonesInAAD))
    {
        Try
        {
            Set-MsolUser -UserPrincipalName $Identity -AlternateMobilePhones $AlternateMobilePhonesInAAD -ErrorAction Stop
            Write-Verbose "Attribute 'AlternateMobilePhones' updated to '$AlternateMobilePhonesInAAD' in '$Identity' object successfully"

        }
        Catch
        {
            Throw "Unable to update 'AlternateMobilePhones' with '$AlternateMobilePhonesInAAD' in '$Identity' object: $($_.Exception.Message)"
        }
    }
}


<#
.Synopsis
   Clears the on-premises AD Mobile and OtherMobile and/or Azure AD MobilePhone and AlternateMobilePhones attributes
.DESCRIPTION
   Updates the value in Mobile/OtherMobile and/or MobilePhone/AlternateMobilePhones in the target directory.
   Supports Active Directory objects in multi-domain forests.
.EXAMPLE
   Clear-ADSyncToolsDirSyncOverridesUser -Identity 'User1@Contoso.com' -MobileInAD
.EXAMPLE
   Clear-ADSyncToolsDirSyncOverridesUser -Identity 'CN=User1,OU=Sync,DC=Contoso,DC=com' -MobileInAD -OtherMobileInAD
.EXAMPLE
   Clear-ADSyncToolsDirSyncOverridesUser -Identity 'CN=User1,OU=Sync,DC=Contoso,DC=com' -MobileInAD -Credential <Domain Credential>
.EXAMPLE
   Clear-ADSyncToolsDirSyncOverridesUser 'User1@Contoso.com' -MobilePhoneInAAD
.EXAMPLE
   Clear-ADSyncToolsDirSyncOverridesUser 'User1@Contoso.com' -MobilePhoneInAAD -AlternateMobilePhonesInAAD
#>

Function Clear-ADSyncToolsDirSyncOverridesUser
{
    [CmdletBinding()]
    Param 
    (
        # Target object in AD to upodate
        [Parameter(Mandatory=$true,
                   Position=0,
                   ValueFromPipeline=$true,
                   ValueFromPipelineByPropertyName=$true)]
        [ValidateNotNullOrEmpty()]
        $Identity,

        # Value to set Mobile in AD
        [Parameter(Mandatory=$false,
                   Position=1,
                   ValueFromPipeline=$true,
                   ValueFromPipelineByPropertyName=$true)]
        [switch] $MobileInAD,

        # Value to set OtherMobile in AD
        [Parameter(Mandatory=$false,
                   Position=2,
                   ValueFromPipeline=$true,
                   ValueFromPipelineByPropertyName=$true)]
        [switch] $OtherMobileInAD,

        # Value to set MobilePhone in Azure AD
        [Parameter(Mandatory=$false,
                   Position=3,
                   ValueFromPipeline=$true,
                   ValueFromPipelineByPropertyName=$true)]
        [switch] $MobilePhoneInAAD,

        # Value to set AlternateMobilePhones in Azure AD
        [Parameter(Mandatory=$false,
                   Position=4,
                   ValueFromPipeline=$true,
                   ValueFromPipelineByPropertyName=$true)]
        [switch] $AlternateMobilePhonesInAAD,

        # Credential for target AD Domain
        [Parameter(Mandatory=$false,
                   Position=5)]
        [pscredential]
        $Credential
    )
    
    If ($MobileInAD -or $OtherMobileInAD)
    {
        # Get the target object from AD
        $adObject = Search-ADSyncToolsADobject -Identity $Identity -Properties 'Mobile','OtherMobile'
        Write-Verbose "Found Object '$($adObject.Name)' | Mobile: $($adObject.Mobile) | OtherMobile: $($adObject.OtherMobile)"
        
        If ($MobileInAD)
        {
            $targetAttribute = 'Mobile'

            # Set new value on target object in AD
            Clear-ADSyncToolsADobject -ADObject $adObject -AttributeName $targetAttribute -Server $targetDC -Credential $Credential
        }

        If ($OtherMobileInAD)
        {
            $targetAttribute = 'OtherMobile'

            # Set new value on target object in AD
            Clear-ADSyncToolsADobject -ADObject $adObject -AttributeName $targetAttribute -Server $targetDC -Credential $Credential
        }
    }
    

    If (($MobilePhoneInAAD -or $AlternateMobilePhonesInAAD) -and !($Identity -match $upnRegex))
    {
        
        Throw "Please provide a valid UserPrincipalName to update user in Azure AD."
    }

    If ($MobilePhoneInAAD)
    {
        Try
        {
            Set-MsolUser -UserPrincipalName $Identity -MobilePhone "" -ErrorAction Stop
            Write-Verbose "Attribute 'MobilePhone' cleared in '$Identity' object successfully"
        }
        Catch
        {
            Throw "Unable to clear 'MobilePhone' in '$Identity' object: $($_.Exception.Message)"
        }
    }

    If ($AlternateMobilePhonesInAAD)
    {
        Try
        {
            Set-MsolUser -UserPrincipalName $Identity -AlternateMobilePhones @() -ErrorAction Stop
            Write-Verbose "Attribute 'AlternateMobilePhones' cleared in '$Identity' object successfully"

        }
        Catch
        {
            Throw "Unable to clear 'AlternateMobilePhones' in '$Identity' object: $($_.Exception.Message)"
        }
    }
}

#endregion
#=======================================================================================

#=======================================================================================
#region Microsoft Graph - Onpremises Attributes
#=======================================================================================


<#
.SYNOPSIS
    Converts Key/Value pairs into Json Body for Graph parameters
    Convers null values to a non-string 'null'
#>

Function ConvertTo-ADSyncToolsGraphJsonBody
{
    [CmdletBinding()]
    param 
    (
        [Parameter(Mandatory = $true,
                    ValueFromPipelineByPropertyName=$true,
                    ValueFromPipeline=$true,
                    Position=0)]
        $NameValuePair
    )
    Write-Verbose "ConvertTo-ADSyncToolsGraphJsonBody: input value type = $($NameValuePair.GetType())"
    $h = @{}
    # Convert Key/Value pairs into Hashtable
    foreach ($nvp in $NameValuePair)
    {
        if ([string]::IsNullOrEmpty($nvp.Value))
        {
            $h[$nvp.Key] = 'nullString'
        }
        else
        {
            $h[$nvp.Key] = $nvp.Value
        }
    }
    # Convert Hashtable to Json Body
    $hJson = $h | ConvertTo-Json
    # return Json Body converting null values
    return $($hJson.Replace('"nullString"', "null"))
}



<#
.SYNOPSIS
    Invoke Microsoft Graph call
#>

function Get-ADSyncToolsGraphInvoke
{
    [CmdletBinding()]
    param 
    (
        # URI string
        [Parameter(Mandatory=$true,
                   ValueFromPipelineByPropertyName=$true,
                   ValueFromPipeline=$true,
                   Position=0)]
        [string] $Uri,

        # Filter string
        [Parameter(Mandatory=$false,
                   Position=1)]
        [string] $Filter,

        # Headers string
        [Parameter(Mandatory=$false,
                   Position=2)]
        [hashtable] $Headers,

        # Properties to select
        [Parameter(Mandatory=$false,
                   Position=3)]
        [string] $Property
    )
    
    #begin
    if (-not [string]::IsNullOrEmpty($Filter))
    {
        $Uri += '?$filter=' + $Filter
    }
        
    if (-not [string]::IsNullOrEmpty($Property))
    {
        If ($Uri.Contains('?'))
        {
            $Uri += '&$select=' + $Property
        }
        else 
        {
            $Uri += '?$select=' + $Property
        }
        
    }
    Write-Verbose "GET $Uri"

    #process
    do 
    {
        if ($null -eq $Headers)
        {
            $response = Invoke-MgGraphRequest GET $Uri -OutputType psobject
        }
        else 
        {
            $response = Invoke-MgGraphRequest GET $Uri -OutputType psobject -Headers $Headers
        }
        $Uri = $response.'@odata.nextLink'
        if ($response.PSobject.Properties.name -match 'value')
        {
            $response.value
        }
        else 
        {
            $response | Select-Object $($Property -split ',')
        }
  
    }
    while ($Uri)
    #end
}


<#
.SYNOPSIS
    Get one user or all users containing onpremises properties in Entra ID
.DESCRIPTION
   This function can be used to get the onpremises attributes listed below from all users or a specific user in Entra ID:
    onPremisesDistinguishedName
    onPremisesDomainName
    onPremisesImmutableId
    onPremisesSamAccountName
    onPremisesSecurityIdentifier
    onPremisesUserPrincipalName
 
   Note: It only returns the users that have onpremises attributes populated.
   By Default it returns all cloud-only users, but you can specify -IncludeSyncedUsers to return all users,
    including users synced from on-premises AD.
   Requires Microsoft Graph PowerShell SDK, authenticated with: Connect-MgGraph -Scopes "User.Read.All"
.EXAMPLE
    Get the onpremises attributes of all cloud-users that have onpremises attributes populated.
    Get-ADSyncToolsOnPremisesAttribute
.EXAMPLE
    Get the onpremises attributes of all users that have onpremises attributes populated.
    Get-ADSyncToolsOnPremisesAttribute -IncludeSyncedUsers
.EXAMPLE
    Get the onpremises attributes for one specific user (verbose)
    Get-ADSyncToolsOnPremisesAttribute -Id '2b3e5a05-6f08-40b2-8b66-233430d395a2' -Verbose
.EXAMPLE
    Get the onpremises attributes for one specific user (pipelining)
    'User1@Contoso.com' | Get-ADSyncToolsOnPremisesAttribute
.EXAMPLE
    Get only specific onpremises attributes of a user (pipelining)
    'User1@Contoso.com' | Get-ADSyncToolsOnPremisesAttribute -Property @('onPremisesSyncEnabled','onPremisesImmutableId')
.EXAMPLE
    Export onpremises attributes of all the users
    Get-ADSyncToolsOnPremisesAttribute | Export-Csv backupOnpremisesAttributes.csv -Delimiter ';'
#>

function Get-ADSyncToolsOnPremisesAttribute
{
    [CmdletBinding()]
    param 
    (
        [Parameter(Mandatory = $true,
                    ParameterSetName = 'SingleUser',
                    ValueFromPipelineByPropertyName=$true,
                    ValueFromPipeline=$true,
                    Position=0)]
        [Alias("Identity")]
        [string] $Id,
    
        [Parameter(Mandatory = $false,
                    ParameterSetName = 'AllUsers',
                    ValueFromPipelineByPropertyName=$true,
                    Position=0)]
        [switch] $IncludeSyncedUsers,
    
        [Parameter(Mandatory = $false,
                    ValueFromPipelineByPropertyName=$true,
                    Position=1)]
        [string[]] $Property
    )
    
    begin 
    {
        Write-Verbose "ParameterSetName: $($PSCmdlet.ParameterSetName)"
        Write-Verbose "Id: $Id"
        Write-Verbose "IncludeSyncedUsers: $IncludeSyncedUsers"
        Write-Verbose "Property: $Property"
        
        Confirm-ADSyncToolsGraphConnection -RequiredScope "User.Read.All"
    
        $baseUriV1 = 'https://graph.microsoft.com/v1.0/users/'
    
        # Properties to include in $select
        if ($Property)
        {
            # Always force output of 'id' in case there's no other valid properties
            $mandatoryAttributes = @('onPremisesSyncEnabled','userPrincipalName','id')
            $onpremPropertiesList = $mandatoryAttributes +  $Property | Select-Object -Unique | Sort-Object
            $onpremProperties = $onpremPropertiesList -join ','
        }
        else
        {
            [string[]] $onpremPropertyList = @('id','userPrincipalName','onPremisesSyncEnabled')
            $onpremPropertyList += $defaultGraphOnPremisesProperties
            $onpremProperties = ($onpremPropertyList  | Select-Object -Unique) -join ','
        }
        Write-Verbose "Properties to get: `$select=$onpremProperties"
    }


    process
    {
        if ($PSCmdlet.ParameterSetName -eq 'SingleUser')
        {
            # Get a specific user
            Write-Verbose "Get single User: $Id"
            Get-ADSyncToolsGraphInvoke -Uri "$baseUriV1$Id" -Property $onpremProperties
        }
        elseif ($PSCmdlet.ParameterSetName -eq 'AllUsers')
        {
            # Get All users
            Write-Verbose "Get all Users"
            
            # Headers to prevent: Filter operator 'NotEqualsMatch' is not supported.
            $headers = @{'ConsistencyLevel'= 'eventual'}
    
            # Query Filter for users with OnPremises properties
            # NOTE: Can only use OnPremisesDistinguishedName and onPremisesSecurityIdentifier with conditional operators (or/and),
            # other properties return "An unsupported property was specified" error.
            If ($IncludeSyncedUsers)
            {
                # All users with onPremisesDistinguishedName, including synced users - $count is required for NotEqualsMatch
                $filter = 'OnPremisesDistinguishedName ne null or onPremisesSecurityIdentifier ne null&$count=true'
            }
            else 
            {
                # Cloud-only users with onPremisesDistinguishedName - $count is required for NotEqualsMatch
                $filter = '(OnPremisesDistinguishedName ne null or onPremisesSecurityIdentifier ne null) and onPremisesSyncEnabled ne true&$count=true'
            }
            Write-Verbose "Query filter: $filter"
    
            Get-ADSyncToolsGraphInvoke -Uri $baseUriV1 -Filter $filter -Headers $headers -Property $onpremProperties
        }
    }

    end {}
}



<#
.SYNOPSIS
    Set onpremises attributes for a cloud user in Entra ID
.DESCRIPTION
.DESCRIPTION
   This function can be used to set any of the OnPremises attributes listed below:
    onPremisesDistinguishedName
    onPremisesDomainName
    onPremisesImmutableId
    onPremisesSamAccountName
    onPremisesSecurityIdentifier *
    onPremisesUserPrincipalName
 
   It also supports clearing an attribute if an empty string "" is specified (see examples).
   Requires Microsoft Graph PowerShell SDK, authenticated with: Connect-MgGraph -Scopes "User.ReadWrite.All"
    
   * Must have the correct Security Identifier format, e.g.: "S-1-5-21-4097605469-3104078553-1111111111-1111"
.EXAMPLE
    Set only onPremisesImmutableId (pipelining)
    'User1@Contoso.com' | Set-ADSyncToolsOnPremisesAttribute -onPremisesImmutableId 'nofCJe0gZk6D8J4gRgrt+A=='
.EXAMPLE
    Set onPremisesSamAccountName and clear onPremisesImmutableId
    Set-ADSyncToolsOnPremisesAttribute 'User1@Contoso.com' -onPremisesSamAccountName 'User1' `
                                                           -onPremisesImmutableId ""
.EXAMPLE
    Set each onpremises attributes explicitly
    Set-ADSyncToolsOnPremisesAttribute 'User1@Contoso.com' -onPremisesUserPrincipalName "User1@Contoso.com" `
                                                           -onPremisesDistinguishedName "CN=User1,OU=Sync,DC=Contoso,DC=com" `
                                                           -onPremisesDomainName 'Contoso.com' `
                                                           -onPremisesImmutableId 'nofCJe0gZk6D8J4gRgrt+A==' `
                                                           -onPremisesSamAccountName 'User1' `
                                                           -onPremisesSecurityIdentifier "S-1-5-21-4097605469-3104078553-1111111111-1111"
.EXAMPLE
    Set onpremises attributes based on a json parameter body (-BodyParameter)
    $jsonBody = @'
{
    "onPremisesDistinguishedName": "User1@Contoso.com",
    "onPremisesDomainName": 'Contoso.com',
    "onPremisesImmutableId": 'nofCJe0gZk6D8J4gRgrt+A==',
    "onPremisesSamAccountName": 'User1',
    "onPremisesSecurityIdentifier": "S-1-5-21-4097605469-3104078553-1111111111-1111",
    "onPremisesUserPrincipalName": "User1@Contoso.com"
}
'@
    Set-ADSyncToolsOnPremisesAttribute -Identity '98765432-6f08-40b2-8b66-123456789012' -BodyParameter $jsonBody
#>

function Set-ADSyncToolsOnPremisesAttribute
{
    [CmdletBinding()]
    param 
    (
        [Parameter(Mandatory = $true,
                    ValueFromPipelineByPropertyName=$true,
                    ValueFromPipeline=$true,
                    Position=0)]
        [Alias("Identity")]
        [string] $Id,
    
        [Parameter(Mandatory = $false,
                    ParameterSetName = 'ByProperty',
                    ValueFromPipelineByPropertyName=$true,
                    Position=0)]
        [string] $onPremisesDistinguishedName,
    
        [Parameter(Mandatory = $false,
                    ParameterSetName = 'ByProperty',
                    ValueFromPipelineByPropertyName=$true,
                    Position=1)]
        [string] $onPremisesDomainName,

        [Parameter(Mandatory = $false,
                    ParameterSetName = 'ByProperty',
                    ValueFromPipelineByPropertyName=$true,
                    Position=2)]
        [string] $onPremisesImmutableId,

        [Parameter(Mandatory = $false,
                    ParameterSetName = 'ByProperty',
                    ValueFromPipelineByPropertyName=$true,
                    Position=3)]
        [string] $onPremisesSamAccountName,

        [Parameter(Mandatory = $false,
                    ParameterSetName = 'ByProperty',
                    ValueFromPipelineByPropertyName=$true,
                    Position=4)]
        [string] $onPremisesSecurityIdentifier,

        [Parameter(Mandatory = $false,
                    ParameterSetName = 'ByProperty',
                    ValueFromPipelineByPropertyName=$true,
                    Position=5)]
        [string] $onPremisesUserPrincipalName,

        [Parameter(Mandatory = $true,
                    ParameterSetName = 'ByBodyParameter',
                    ValueFromPipelineByPropertyName=$true,
                    Position=0)]
        [string] $BodyParameter
    )

    begin
    {
        Write-Verbose "ParameterSetName: $($PSCmdlet.ParameterSetName)"
        Write-Verbose "Id: $Id"
    
        Confirm-ADSyncToolsGraphConnection -RequiredScope "User.ReadWrite.All"
        
        # Set target endpoint/resource
        $baseUriBeta = "https://graph.microsoft.com/beta/users/"
    }

    process
    {
        if ($PSCmdlet.ParameterSetName -eq 'ByProperty')
        {
            Write-Verbose "Set by property: $Id"
            # get input parameters
            $inputParameters = $PSBoundParameters
            $inputOnPremisesParam = @($inputParameters.GetEnumerator() | Where-Object {$_.Key -like 'onPremises*'})
            Write-Verbose "InputParameterOnPremises: $($inputOnPremisesParam -join ',') | Count=$($inputOnPremisesParam.Count)"
            
            if ($inputOnPremisesParam.Count -gt 0)
            {
                # Convert input values to json body parameter
                Write-Verbose "Converting (attribute, value) to Json: $($inputOnPremisesParam)"
                [string] $BodyParameter = ConvertTo-ADSyncToolsGraphJsonBody ($inputOnPremisesParam)
                Write-Verbose "`n$BodyParameter"
            }
            else 
            {
                Throw "Invalid function parameters: Missing a BodyParameter or an attribute name parameter."
            }            
        }
        elseif ($PSCmdlet.ParameterSetName -eq 'ByBodyParameter')
        {
            Write-Verbose "Set by BodyParameter: $Id"
            
        }
        Invoke-MgGraphRequest -uri $baseUriBeta$Id -Body $BodyParameter -Method PATCH
    }

    end {}
    
}


<#
.SYNOPSIS
    Clear onpremises attributes on a cloud user in Entra ID
.DESCRIPTION
   This function can be used to clear any of the OnPremises attributes listed below:
    onPremisesDistinguishedName
    onPremisesDomainName
    onPremisesSamAccountName
    onPremisesSecurityIdentifier
    onPremisesUserPrincipalName
    onPremisesImmutableId *
     
   For safety reasons the onPremisesImmutableId will not be included in -All parameter because this attribute
   was never cleared as part of DirSync disablement. Keeping onPremisesImmutableId populated is not harmful and
   can allow you to hard-match an existent on-premises AD user with the Entra ID user.
   If you also want to clear the onPremisesImmutableId use the -BodyParameter option or run
   Clear-ADSyncToolsOnPremisesAttribute with '-onPremisesImmutableId' parameter instead of -All.
   Requires Microsoft Graph PowerShell SDK, authenticated with: Connect-MgGraph -Scopes "User.ReadWrite.All"
.EXAMPLE
    Clear all onpremises attributes for one user (pipelining)
    'User1@Contoso.com' | Clear-ADSyncToolsOnPremisesAttribute -All
.EXAMPLE
    Clear all onpremises attributes for all users (pipelining and verbose) - with a backup to CSV first
    Get-ADSyncToolsOnPremisesAttribute | Export-Csv backupOnpremisesAttributes.csv -Delimiter ';'
 
    Get-ADSyncToolsOnPremisesAttribute | Select-Object id | Clear-ADSyncToolsOnPremisesAttribute -All -Verbose
.EXAMPLE
    Clear only onPremisesImmutableId attribute
    Clear-ADSyncToolsOnPremisesAttribute -Identity '98765432-6f08-40b2-8b66-123456789012' -onPremisesImmutableId
.EXAMPLE
    Clear all onpremises attributes explicitly
    Clear-ADSyncToolsOnPremisesAttribute 'User1@Contoso.com' -onPremisesDistinguishedName -onPremisesDomainName `
                                                              -onPremisesImmutableId -onPremisesSamAccountName `
                                                              -onPremisesSecurityIdentifier -onPremisesUserPrincipalName
.EXAMPLE
    Clear all onpremises attributes based on a json parameter body (-BodyParameter)
    $jsonBody = @'
{
    "onPremisesDistinguishedName": null,
    "onPremisesDomainName": null,
    "onPremisesImmutableId": null,
    "onPremisesSamAccountName": null,
    "onPremisesSecurityIdentifier": null,
    "onPremisesUserPrincipalName": null
}
'@
    Clear-ADSyncToolsOnPremisesAttribute -Identity $userId -BodyParameter $jsonBody
#>

function Clear-ADSyncToolsOnPremisesAttribute
{
    [CmdletBinding()]
    param 
    (
        [Parameter(Mandatory = $true,
                    ValueFromPipelineByPropertyName=$true,
                    ValueFromPipeline=$true,
                    Position=0)]
        [Alias("Identity")]
        [string] $Id,
    
        [Parameter(Mandatory = $false,
                    ParameterSetName = 'ByProperty',
                    ValueFromPipelineByPropertyName=$true,
                    Position=0)]
        [switch] $onPremisesDistinguishedName,
    
        [Parameter(Mandatory = $false,
                    ParameterSetName = 'ByProperty',
                    ValueFromPipelineByPropertyName=$true,
                    Position=1)]
        [switch] $onPremisesDomainName,

        [Parameter(Mandatory = $false,
                    ParameterSetName = 'ByProperty',
                    ValueFromPipelineByPropertyName=$true,
                    Position=2)]
        [switch] $onPremisesImmutableId,

        [Parameter(Mandatory = $false,
                    ParameterSetName = 'ByProperty',
                    ValueFromPipelineByPropertyName=$true,
                    Position=3)]
        [switch] $onPremisesSamAccountName,

        [Parameter(Mandatory = $false,
                    ParameterSetName = 'ByProperty',
                    ValueFromPipelineByPropertyName=$true,
                    Position=4)]
        [switch] $onPremisesSecurityIdentifier,

        [Parameter(Mandatory = $false,
                    ParameterSetName = 'ByProperty',
                    ValueFromPipelineByPropertyName=$true,
                    Position=5)]
        [switch] $onPremisesUserPrincipalName,

        [Parameter(Mandatory = $true,
                    ParameterSetName = 'ByBodyParameter',
                    ValueFromPipelineByPropertyName=$true,
                    Position=0)]
        [string] $BodyParameter,

        [Parameter(Mandatory = $true,
                    ParameterSetName = 'ClearAll',
                    ValueFromPipelineByPropertyName=$true,
                    Position=0)]
                    [switch] $All
    )

    begin
    {
        Write-Verbose "ParameterSetName: $($PSCmdlet.ParameterSetName)"
        Write-Verbose "Id: $Id"
    
        Confirm-ADSyncToolsGraphConnection -RequiredScope "User.ReadWrite.All"
    
        # Set target endpoint/resource
        $baseUriBeta = "https://graph.microsoft.com/beta/users/"    
    }

    process
    {
        if ($PSCmdlet.ParameterSetName -eq 'ByProperty')
        {
            Write-Verbose "Set by property: $Id"
            # get input parameters
            $inputParameters = $PSBoundParameters
            $inputOnPremisesParam = @($inputParameters.GetEnumerator() | Where-Object {$_.Key -like 'onPremises*'})
            Write-Verbose "InputParameterOnPremises: $($inputOnPremisesParam -join ',') | Count=$($inputOnPremisesParam.Count)"
    
            if ($inputOnPremisesParam.Count -gt 0)
            {
                # Convert input parameters to Hashtable
                $ht = New-Object System.Collections.Hashtable
                $inputOnPremisesParam | ForEach-Object {$ht.Add($_.Key, "nullString")}
                # Convert Hashtable to Json Body
                $htJson = $ht | ConvertTo-Json
                # create Json Body placing null strings
                $body = $htJson.Replace('"nullString"', "null")
            }
            else 
            {
                Throw "Invalid function parameters: Missing a BodyParameter or an attribute name parameter."
            }
            
        }
        elseif ($PSCmdlet.ParameterSetName -eq 'ByBodyParameter')
        {
            Write-Verbose "Clear by BodyParameter: $Id"
            $body = $BodyParameter
        }
        elseif ($PSCmdlet.ParameterSetName -eq 'ClearAll')
        {
            Write-Verbose "Clear All Onpremises attributes: $Id"
            $body = @'
{
    "onPremisesDistinguishedName": null,
    "onPremisesDomainName": null,
    "onPremisesSamAccountName": null,
    "onPremisesSecurityIdentifier": null,
    "onPremisesUserPrincipalName": null
}
'@
        
        }
        Invoke-MgGraphRequest -uri $baseUriBeta$Id -Body $body -Method PATCH    
    }

    end {}
}


#endregion
#=======================================================================================

#=======================================================================================
#region Internal Notes
#=======================================================================================
<#
    #TODO: Review naming convention of output files:
    ADSyncTools-SyncTrace_20210812-225734.log << correct format
    LdapTrace_20210811200546-attributeTypes.txt
    ADimportTrace_20210811222443.log
    20210812223506_ADSyncAADHybridJoinCertificateReport.csv
 
    TODO: Use -Credential $ActiveDirectoryCredential in Search AD object, Get/Set AD CG
    Support queries to parent domains from child domains
        # Target Domain Credential
        [Parameter(Mandatory=$false,
                   Position=1)]
        [ValidateNotNullOrEmpty()]
        $Credential,
 
        # Target Domain Server
        [Parameter(Mandatory=$false,
                   Position=2)]
        [ValidateNotNullOrEmpty()]
        $Server
#>


#endregion
#=======================================================================================


#=======================================================================================
#region NetApi32 Init
#=======================================================================================

Add-Type -TypeDefinition @"
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
  
public static class NetApi32
{
    [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
    public struct DomainControllerInfo
    {
        public string DomainControllerName;
        public string DomainControllerAddress;
        public int DomainControllerAddressType;
        public Guid DomainGuid;
        public string DomainName;
        public string DnsForestName;
        public int Flags;
        public string DcSiteName;
        public string ClientSiteName;
    }
 
    [StructLayout(LayoutKind.Sequential, CharSet=CharSet.Unicode)]
    public struct USER_INFO_1
    {
        public string sUsername;
        public string sPassword;
        public uint uiPasswordAge;
        public uint uiPriv;
        public string sHome_Dir;
        public string sComment;
        public uint uiFlags;
        public string sScript_Path;
    }
 
    //uiPriv
    public const uint USER_PRIV_GUEST = 0;
    public const uint USER_PRIV_USER = 1;
    public const uint USER_PRIV_ADMIN = 2;
 
    //uiFlags (flags)
    public const uint UF_PASSWD_CANT_CHANGE = 0x40;
    public const uint UF_DONT_EXPIRE_PASSWD = 0x10000;
    public const uint UF_MNS_LOGON_ACCOUNT = 0x20000;
    public const uint UF_SMARTCARD_REQUIRED = 0x40000;
    public const uint UF_TRUSTED_FOR_DELEGATION = 0x80000;
    public const uint UF_NOT_DELEGATED = 0x100000;
    public const uint UF_USE_DES_KEY_ONLY = 0x200000;
    public const uint UF_DONT_REQUIRE_PREAUTH = 0x400000;
    public const uint UF_PASSWORD_EXPIRED = 0x800000;
    public const uint UF_TRUSTED_TO_AUTHENTICATE_FOR_DELEGATION = 0x1000000;
    public const uint UF_NO_AUTH_DATA_REQUIRED = 0x2000000;
    public const uint UF_PARTIAL_SECRETS_ACCOUNT = 0x4000000;
    public const uint UF_USE_AES_KEYS = 0x8000000;
 
    //uiFlags (choice)
    public const uint UF_TEMP_DUPLICATE_ACCOUNT = 0x0100;
    public const uint UF_NORMAL_ACCOUNT = 0x0200;
    public const uint UF_INTERDOMAIN_TRUST_ACCOUNT = 0x0800;
    public const uint UF_WORKSTATION_TRUST_ACCOUNT = 0x1000;
    public const uint UF_SERVER_TRUST_ACCOUNT = 0x2000;
  
    [DllImport("NetApi32.dll", CharSet=CharSet.Unicode)]
    public static extern int NetUserGetInfo(string servername, string username, int level, out IntPtr buffer);
 
    [DllImport("advapi32.dll", SetLastError = true)]
    public static extern bool LogonUser(string user, string domain, string password, int logonType, int logonProvider, out IntPtr token);
  
    [DllImport("kernel32.dll", SetLastError = true)]
    public static extern bool CloseHandle(IntPtr handle);
 
    [DllImport("NetApi32.dll")]
    public static extern int NetApiBufferFree(IntPtr buffer);
 
    [DllImport("Netapi32.dll", CharSet=CharSet.Unicode)]
    public static extern int DsGetDcName(string ComputerName, string DomainName, int DomainGuid, string SiteName, int Flags, out IntPtr pDOMAIN_CONTROLLER_INFO);
}
"@


#endregion
#=======================================================================================


Export-ModuleMember Install-ADSyncToolsPrerequisites, ` # Installs all PowerShell depedencies
                    Connect-ADSyncTools, ` # Connects ADSyncTools Module to Azure AD and Exchange Online
                    Search-ADSyncToolsADobject, ` # Searches for an AD object in Active Directory Forest
                    Set-ADSyncToolsADobject, ` # Sets an object's attribute in Active Directory Forest
                    Get-ADSyncToolsMsDsConsistencyGuid, ` # Gets an AD object ms-ds-ConsistencyGuid
                    Set-ADSyncToolsMsDsConsistencyGuid, ` # Sets an AD object ms-ds-ConsistencyGuid
                    Clear-ADSyncToolsMsDsConsistencyGuid, ` # Clears an AD object mS-DS-ConsistencyGuid
                    ConvertFrom-ADSyncToolsImmutableID, ` # Converts Base64 ImmutableId (SourceAnchor) to GUID value
                    ConvertTo-ADSyncToolsImmutableID, ` # Converts GUID (ObjectGUID / ms-Ds-Consistency-Guid) to a Base64 string
                    ConvertFrom-ADSyncToolsAadDistinguishedName, ` # Converts AAD Connector DistinguishedName to ImmutableId
                    ConvertTo-ADSyncToolsAadDistinguishedName, ` # Converts ImmutableId to AAD Connector DistinguishedName
                    ConvertTo-ADSyncToolsCloudAnchor, ` # Converts Base64 Anchor to CloudAnchor
                    Export-ADSyncToolsAadDisconnectors, ` # Exports Azure AD Disconnector objects
                    Get-ADSyncToolsAadObject, ` # Gets synced objects for a given SyncObjectType
                    Set-ADSyncToolsAadObject, ` # Sets synced objects for a given SyncObjectType
                    Remove-ADSyncToolsAadObject, ` # Removes orphaned object(s) from Azure AD
                    Export-ADSyncToolsAadPublicFolders, ` # Exports all synced Mail-Enabled Public Folder objects from AzureAD to a CSV file
                    Remove-ADSyncToolsAadPublicFolders, ` # Removes orphaned Mail-Enabled Public Folder object(s) from Azure AD
                    Get-ADSyncToolsRunHistory, ` # Gets ADSync Run History
                    Get-ADSyncToolsRunStepHistory, ` # Gets ADSync Run Profile history including each Run Step result
                    Export-ADSyncToolsRunHistory, ` # Exports ADSync Run History
                    Import-ADSyncToolsRunHistory , ` # Imports ADSync Run History
                    Get-ADSyncToolsRunHistoryLegacyWmi, ` # Gets ADSync Run History for older versions of AAD Connect (WMI)
                    Remove-ADSyncToolsExpiredCertificates, ` # Removes Expired Certificates from a users in AD
                    Trace-ADSyncToolsADImport, ` # Generates a trace file with AD Import step data
                    Trace-ADSyncToolsLdapSchemaQuery, ` # Traces LDAP queries against Active Directory Schema
                    Trace-ADSyncToolsLdapQuery, ` # Traces LDAP queries
                    Export-ADSyncToolsHybridAadJoinReport, ` # Generates a report of certificates stored in Active Directory Computer objects
                    Start-ADSyncToolsLogmanTrace, ` # Starts an ETW (Verbose) trace for SyncRulesPipeline debugging
                    Stop-ADSyncToolsLogmanTrace, ` # Stops the ETW (Verbose) trace for SyncRulesPipeline debugging
                    Get-ADSyncToolsLogmanTraceLevel, ` # Gets the current ETW trace level for SyncRulesPipeline debugging
                    Set-ADSyncToolsLogmanTraceLevel, ` # Sets the ETW trace level (Warning/Verbose) for SyncRulesPipeline debugging
                    Convert-ADSyncToolsLogmanTrace, ` # Decodes an ETW trace for SyncRulesPipeline debugging into a CSV text file
                    Resolve-ADSyncToolsLogmanTrace, ` # Decodes an ETW trace for SyncRulesPipeline debugging and translates the ObjectIds to object names
                    Start-ADSyncToolsCustomSyncScheduler, ` # Custom Sync Scheduler with a specific Connector's order
                    Export-ADSyncToolsObjects, ` # Dumps internal ADsync object(s) to XML file(s)
                    Import-ADSyncToolsObjects, ` # Imports internal ADSync object from XML file
                    Export-ADSyncToolsADpermissionsReport,          # Exports AD effective/permissions that AD DS Connector Account has over an object
                    Import-ADSyncToolsADpermissionsReport,          # Imports AD permissions data from XML file and returns a DACL table
                    Import-ADSyncToolsSourceAnchor, ` # Imports ImmutableId values from Azure AD
                    Export-ADSyncToolsSourceAnchorReport, ` # Exports a list of mS-DS-ConsistencyGuid values to update in local AD
                    Update-ADSyncToolsSourceAnchor, ` # Updates mS-DS-ConsistencyGuid values for users in local AD
                    Get-ADSyncToolsTenantAzureEnvironment, ` # Gets the tenant azure environment
                    Get-ADSyncToolsADconnectorAccount, ` # Gets the current AD DS Connector account(s) configured in Azure AD Connect
                    Get-ADSyncToolsServiceAccount, ` # Gets the current ADSync service account configured for Azure AD Connect
                    Test-ADSyncToolsPasswordWriteback, ` # Test Password Writeback operations in local Active Directory
                    Start-ADSyncToolsSingleObjectSync, ` # Automates troubleshooting with Single Object Sync tool
                    Repair-ADSyncToolsAutoUpgradeState, ` # Fixes AutoUpgrade Suspended state
                    Get-ADSyncToolsTls12, ` # Gets Client\Server TLS 1.2 settings for .NET Framework
                    Set-ADSyncToolsTls12, ` # Sets Client\Server TLS 1.2 settings for .NET Framework
                    Get-ADSyncToolsDuplicateUsersSourceAnchor,` # Gets duplicate user details which contain 'Source Anchor has changed' error
                    Set-ADSyncToolsDuplicateUsersSourceAnchor, ` # Sets correct source anchor(MsDsConsistencyGuid) values for duplicate users which contain 'Source Anchor has changed' error
                    Compare-ADSyncToolsDirSyncOverrides, ` # Compares between on-premises AD and Azure AD the users with DirSyncOverrides set on Mobile and/or OtherMobile
                    Get-ADSyncToolsDirSyncOverridesUser, ` # Gets the on-premises AD Mobile and OtherMobile and/or Azure AD MobilePhone and AlternateMobilePhones attributes
                    Set-ADSyncToolsDirSyncOverridesUser, ` # Sets the on-premises AD Mobile and OtherMobile and/or Azure AD MobilePhone and AlternateMobilePhones attributes
                    Clear-ADSyncToolsDirSyncOverridesUser, ` # Clears the on-premises AD Mobile and OtherMobile and/or Azure AD MobilePhone and AlternateMobilePhones attributes
                    Get-ADSyncToolsOnPremisesAttribute, ` # Gets the on-premises attributes from a user or for all users with on-premises attributes present (e.g., onPremisesDistinguishedName, etc)
                    Set-ADSyncToolsOnPremisesAttribute, ` # Sets the on-premises attributes from a user (e.g., onPremisesDistinguishedName, etc)
                    Clear-ADSyncToolsOnPremisesAttribute, ` # Clears the on-premises attributes from a user (e.g., onPremisesDistinguishedName, onPremisesDomainName, onPremisesImmutableId, etc)
                    New-ADSyncToolsSqlConnection, ` # SQL Diagnostics
                    Connect-ADSyncToolsSqlDatabase, ` # SQL Diagnostics
                    Invoke-ADSyncToolsSqlQuery,` # SQL Diagnostics
                    Resolve-ADSyncToolsSqlHostAddress, ` # SQL Diagnostics
                    Test-ADSyncToolsSqlNetworkPort, ` # SQL Diagnostics
                    Get-ADSyncToolsSqlBrowserInstances, ` # SQL Diagnostics
                    Get-ADSyncToolsSqlProtocols ` # SQL Diagnostics
                   

#=======================================================================================
#region Main
#=======================================================================================

Write-Host "`nADSyncTools for Entra Connect Synchronization" -ForegroundColor Cyan
Write-Host "To show all available cmdlets, type: Get-Command -Module ADSyncTools"
Write-Host "To show more help information, type: Get-Help <cmdlet> -Full`n"

Confirm-ADSyncToolsPowerShellV7
Import-ADSyncToolsModule -ModuleName Microsoft.Graph.Authentication -CheckOnly

"`n"

#endregion
#=======================================================================================