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 � 2022 Microsoft Corporation. All rights reserved.
#
#-------------------------------------------------------------------------------------------------------------------------------------
#
# NAME: Azure AD Connect ADSyncTools PowerShell Module
#
#-------------------------------------------------------------------------------------------------------------------------------------

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

[string[]] $defaultADobjProperties = @('ObjectClass','UserPrincipalName','ObjectGUID','ObjectSID','mS-DS-ConsistencyGuid','sAMAccountName','CanonicalName','msDS-PrincipalName')
[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] $netbiosDomainRegex = "w*\\\w+"


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

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

class DuplicateUserSourceAnchorInfo
{
    [string] $UserName

    [string] $DistinguishedName

    [string] $ADDomainName

    [Byte[]] $CurrentMsDsConsistencyGuid

    [Byte[]] $ExpectedMsDsConsistencyGuid
}

#=======================================================================================
#endregion class definitions
#=======================================================================================

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

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

Function Import-ADSyncToolsActiveDirectoryModule
{
    [CmdletBinding()]
    Param ()
    
    If (-not (Get-Module ActiveDirectory))
    {
        Try
        {
            # Load ActiveDirectory module
            Import-Module ActiveDirectory -ErrorAction Stop
        }
        Catch
        {

            Throw "Unable to import ActiveDirectory PowerShell Module. Run 'Install-WindowsFeature RSAT-AD-Tools' to install Active Directory RSAT. Error Details: $($_.Exception.Message)"
        }
    }
}


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

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

    IsAADConnectPresent -MinVersion "1.6"
    $binariesPath = Get-ADSyncToolsADsyncFolder
    
    If ($binariesPath -eq '')
    {
        Write-Warning "Azure AD Connect installation was not found, some functionality may be unavailable. Using current directory '$PSScriptRoot'."
        Try
        {
            Add-Type -Path $(Join-Path -Path $PSScriptRoot -ChildPath "Microsoft.MetadirectoryServicesEx.dll")
            Add-Type -Path $(Join-Path -Path $PSScriptRoot -ChildPath "Microsoft.MetadirectoryServices.PasswordHashSynchronization.Types.dll")
            Add-Type -Path $(Join-Path -Path $PSScriptRoot -ChildPath "Microsoft.Azure.ActiveDirectory.Connector.dll")
        }
        Catch
        {
            Throw  "Unable to import AADConnector binaries. Error Details: $($_.Exception.Message)"
        }
    }
    Else
    {
        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\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 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 "Azure AD Connect 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 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
    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-ADSyncToolsActiveDirectoryModule

    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-ADSyncToolsActiveDirectoryModule
    
    $domainDN = Get-ADSyncToolsDomainDN $DistinguishedName
    $domain = Get-ADDomain -Identity $domainDN
    Write-Verbose "Domain FQDN: $($domain.DNSRoot)"
    If ($domain -eq $null)
    {
        Throw "Cannot find Domain for object '$DistinguishedName'."
    }

    Return $domain.DNSRoot
}


<#
.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
    )

    Import-ADSyncToolsActiveDirectoryModule

    $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 $defaultADobjProperties -SearchBase $domainDN -SearchScope Subtree -Server $targetDC"           
    Try
    {
        $seachResult = Get-ADObject -Filter "distinguishedName -eq '$DistinguishedName'" -Properties $defaultADobjProperties -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
    )

    Import-ADSyncToolsActiveDirectoryModule

    $targetDC = Get-ADSyncToolsADtargetDC -Service GlobalCatalog
    
    # Get the AD object from target DC
    Write-Verbose "Executing: Get-ADObject -Filter `"UserPrincipalName -eq '$UserPrincipalName'`" -Server `"$($targetDC):3268`""
    Try
    {
        $globalCatalogObj = Get-ADObject -Filter "UserPrincipalName -eq '$UserPrincipalName'" -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
    }    

    Return $seachResult
}

#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 in AD to set ConsistencyGuid
        [Parameter(Mandatory=$true,
                   Position=0,
                   ValueFromPipeline=$true,
                   ValueFromPipelineByPropertyName=$true)]
        [ValidateNotNullOrEmpty()]
        $Identity
    )

    Import-ADSyncToolsActiveDirectoryModule

    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
        }
        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"
                
                $seachResult = Search-ADSyncToolsADobjectByUPN -UserPrincipalName $Identity
            }
            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
                }
                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 $defaultADobjProperties -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 = "" | Select Name, ObjectClass, DistinguishedName, ObjectGUID, mS-DS-ConsistencyGuid, ObjectSID, sAMAccountName, UserPrincipalName
        
        $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 ($seachResult.'mS-DS-ConsistencyGuid' -ne $null)
        {
            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
   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
    )
    
    # 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
        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
    )

    # 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)"
    }

    # PROCESS
    $results = @()
    Do
    {
        Try
        {
            $batch = $enumerator.EnumerateNextBatch()
        }
        Catch
        {
            Throw "There was a problem with the Enumerator component. Error Details: $($_.Exception.Message)"
        }

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

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

                    # Add all non-null properties as a string value
                    $properties = $entry.PropertyValues
                    # Also Supports CloudLegacyExchangeDN an CloudMSExchRecipientDisplayType properties
                    $propertyKeys = @('SourceAnchor','DisplayName','Mail')
                    ForEach ($propKey in $propertyKeys)
                    {
                        $propValue = $properties[$propKey]
                        if ($propValue -ne $null)
                        {
                            $propValue = $propValue.ToString()
                        }
                        Else
                        {
                            $propValue = ""
                        }
                        Add-Member -InputObject $r -MemberType NoteProperty -Name $propKey -Value $propValue -Force
                    }
                    
                    # Add ProxyAddresses as an array
                    $proxyAddresses = @()
                    If ($($properties['ProxyAddresses']).Count -gt 0)
                    {
                        ForEach ($address in $properties['ProxyAddresses'])
                        {
                            $proxyAddresses += $address.ToString()
                        }

                    }
                    Add-Member -InputObject $r -MemberType NoteProperty -Name ProxyAddresses -Value $proxyAddresses -Force
                    
                    # Add UserPrincipalName
                    If ($SyncObjectType -eq 'User')
                    {
                        $propKey = 'UserPrincipalName'
                        $propValue = $properties[$propKey]
                        if ($propValue -ne $null)
                        {
                            $propValue = $propValue.ToString()
                        }
                        Else
                        {
                            $propValue = ""
                        }
                        Add-Member -InputObject $r -MemberType NoteProperty -Name $propKey -Value $propValue -Force
                    }
                    $results += $r
                }
            }
        }
    }
    Until ($batch.AadBatch.MoreToRead -eq $false)

    #END
    $enumerator.Dispose()
    $results
}



<#
.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
        [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

    )

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

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

    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)"
    }

    If ($($PSCmdlet.ParameterSetName) -eq 'CsvInput')
    {
        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)"
        }
    }
    Else
    {
        $object = "" | Select SyncObjectType,SourceAnchor
        $object.SyncObjectType = $SyncObjectType
        $object.SourceAnchor = $SourceAnchor
        Write-Verbose "ObjectInput: $($object.SyncObjectType) | $($object.SourceAnchor)"
        $objects = @($object)
    }

    Try
    {
        $entries = @()
        ForEach ($obj in $objects)
        {
            # Lower 1st char
            [string] $syncObjectTypeLowerCase = $($obj.SyncObjectType[0].ToString().ToLower()) + $obj.SyncObjectType.Substring(1)
            Write-Verbose "Processing: DeleteEntry = $syncObjectTypeLowerCase | $($obj.SourceAnchor)"
            # Add DeleteEntry
            $entries += [Microsoft.Azure.ActiveDirectory.Connector.Diagnostics.DeletionEntry]::FromSourceAnchor($syncObjectTypeLowerCase, $obj.SourceAnchor)
        }
    }
    Catch
    {
        Throw "There was a problem creating DeletionEntry list. 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-ADSyncToolsActiveDirectoryModule
    
    # 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-ADSyncToolsLdapQuery -RootDN "DC=Contoso,DC=com" -Credential $Credential
#>

Function Trace-ADSyncToolsLdapQuery
{
    [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
   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-ADSyncToolsActiveDirectoryModule

    # 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 Update-ADSyncToolsLogmanLog
{
    [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)
    {
        Update-ADSyncToolsLogmanLog -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 = Update-ADSyncToolsLogmanLog '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
    )
    
    Update-ADSyncToolsLogmanLog '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(
        # Wait time to check for the next sync cycle
        [Parameter(Mandatory=$false,
                   ValueFromPipelineByPropertyName=$true,
                   ValueFromPipeline=$true,
                   Position=0)]
        [int] 
        $SleepTimerSecs = 60
    )

    $script:ADSyncToolsLogmanLogInit = $false
    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 "Same 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 "Press CTRL+C any time to stop monitoring Sync Scheduler and type 'Stop-ADSyncToolsLogmanTrace' to stop the current trace."
        }
    }
    While ($true)
}        

#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-ADSyncToolsActiveDirectoryModule
    
    ## 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-ADSyncToolsActiveDirectoryModule
    
        # 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-ADSyncToolsActiveDirectoryModule

        # 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

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

    If ($regItem -eq $null)
    {
        $output.Value = "Not Found"
    }
    Else
    {
        $output.Value = $regItem.$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
   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-ADSyncToolsActiveDirectoryModule
    $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 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
                    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
                    Remove-ADSyncToolsAadObject, ` # Removes orphaned synced object 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-ADSyncToolsLdapQuery, ` # Traces LDAP queries
                    Export-ADSyncToolsHybridAadJoinReport, ` # Generates a report of certificates stored in Active Directory Computer objects
                    Start-ADSyncToolsLogmanTrace, ` # Starts an automated ETW trace on each synchronization cycle
                    Stop-ADSyncToolsLogmanTrace, ` # Stops the automated ETW trace on each synchronization cycle
                    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
                    New-ADSyncToolsSqlConnection, ` # SQL Diagnostics
                    Connect-ADSyncToolsSqlDatabase, ` # SQL Diagnostics
                    Invoke-ADSyncToolsSqlQuery,` # SQL Diagnostics
                    Resolve-ADSyncToolsSqlHostAddress, ` # SQL Diagnostics
                    Test-ADSyncToolsSqlNetworkPort, ` # SQL Diagnostics
                    Get-ADSyncToolsSqlBrowserInstances ` # SQL Diagnostics


Write-Host "`nADSyncTools for Azure AD 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"