PSADSync.psm1

$config = Import-PowerShellDataFile -Path "$PSScriptRoot\Configuration.psd1"
$Defaults = $config.Defaults
$AdToCsvFieldMap = $config.FieldMap

## Load the System.Web type to generate random password
Add-Type -AssemblyName 'System.Web'

function Get-CompanyAdUser
{
    [OutputType([System.DirectoryServices.AccountManagement.UserPrincipal])]
    [CmdletBinding()]
    param
    (
        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [switch]$All,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [pscredential]$Credential = $Defaults.Credential
    )
    begin
    {
        $ErrorActionPreference = 'Stop'
        Write-Verbose -Message "Finding all enabled AD users in domain with field(s) used: $($Defaults.FieldMatchIds.AD -join ',')"
    }
    process
    {
        try
        {
            ## Find all users that have the unique AD ID and are enabled
            $params = @{
                NoResultLimit = $true
            }
            if ($Credential)
            {
                $params.Credential = $Credential
            }

            $whereFilter = { $adUser = $_; $Defaults.FieldMatchIds.AD | where { $adUser.$_ }}
            if (-not $All.IsPresent) {
                $params.LDAPFilter = "(!userAccountControl:1.2.840.113556.1.4.803:=2)"
            }
            @(Get-AdsiUser @params).where($whereFilter)
        }
        catch
        {
            Write-Error -Message "Function: $($MyInvocation.MyCommand.Name) Error: $($_.Exception.Message)"
        }
    }
}

function GetCsvColumnHeaders
{
    [OutputType([string])]
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$CsvFilePath
    )
    
    (Get-Content -Path $CsvFilePath | Select-Object -First 1).Split(',') -replace '"'
}

function TestCsvHeaderExists
{
    [OutputType([bool])]
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$CsvFilePath = $Defaults.InputCsvFilePath,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string[]]$Header
    )

    $csvHeaders = GetCsvColumnHeaders -CsvFilePath $CsvFilePath
    $matchedHeaders = $csvHeaders | where { $_ -in $Header }
    if (@($matchedHeaders).Count -ne @($Header).Count) {
        $false
    } else {
        $true
    }
}

function Get-CompanyCsvUser
{
    [OutputType([pscustomobject])]
    [CmdletBinding()]
    param
    (
        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [ValidateScript({Test-Path -Path $_ -PathType Leaf})]
        [string]$CsvFilePath = $Defaults.InputCsvFilePath,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [hashtable]$Exclude
    )
    begin
    {
        $ErrorActionPreference = 'Stop'
        Write-Verbose -Message "Enumerating all users in CSV file [$($CsvFilePath)]"
    }
    process
    {
        try
        {
            $whereFilter = { '*' }
            if ($PSBoundParameters.ContainsKey('Exclude'))
            {
                $conditions = $Exclude.GetEnumerator() | foreach { "(`$_.'$($_.Key)' -ne '$($_.Value)')" }
                $whereFilter = [scriptblock]::Create($conditions -join ' -and ')
            }
            Import-Csv -Path $CsvFilePath | Where-Object -FilterScript $whereFilter
        }
        catch
        {
            Write-Error -Message "Function: $($MyInvocation.MyCommand.Name) Error: $($_.Exception.Message)"
        }
    }
}

function CompareCompanyUser
{
    [OutputType([hashtable])]
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [System.DirectoryServices.AccountManagement.UserPrincipal[]]$AdUsers,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [pscustomobject[]]$CsvUsers
    )

    $ErrorActionPreference = 'Stop'
    Write-Verbose -Message "Beginning Company AD <--> CSV compare..."
    Write-Verbose -Message "Found [$(@($AdUsers).Count)] enabled AD users."
    Write-Verbose -Message "Found [$(@($csvUsers).Count)] users in CSV."
    
    @($csvUsers).foreach({
        $output = @{
            CsvUser = $_
            AdUser = $null
            IdMatchedOn = $null
            Match = $false
        }
        if ($adUserMatch = FindUserMatch -AdUsers $AdUsers -CsvUser $_) {
            $output.AdUser = $adUserMatch.MatchedAdUser
            $output.IdMatchedOn = $adUserMatch.IdMatchedOn
            $output.Match = $true 
        }
        $output
    })
}

function FindUserMatch
{
    [OutputType([pscustomobject])]
    [CmdletBinding()]
    param
    (
        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [object[]]$AdUsers,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [object]$CsvUser
    )
    $ErrorActionPreference = 'Stop'

    foreach ($matchId in $Defaults.FieldMatchIds) {
        $adMatchField = $matchId.AD
        $csvMatchField = $matchId.CSV
        Write-Verbose "Match fields: CSV - [$($csvMatchField)], AD - [$($adMatchField)]"
        if ($csvMatchVal = $CsvUser.$csvMatchField) {
            Write-Verbose -Message "CsvFieldMatchValue is [$($csvMatchVal)]"
            if ($matchedAdUser = @($AdUsers).where({ $_.$adMatchField -eq $csvMatchVal })) {
                Write-Verbose -Message "Found AD match for CSV user [$csvMatchVal]: [$($matchedAdUser.$adMatchField)]"
                [pscustomobject]@{
                    MatchedAdUser = $matchedAdUser
                    IdMatchedOn = $csvMatchField
                }
                ## Stop after making a single match
                break
            } else {
                Write-Verbose -Message "No user match found for CSV user [$csvMatchVal]"
            }
        } else {
            Write-Verbose -Message "CSV field match value [$($csvMatchField)] could not be found."
        }
    }
}

function FindAttributeMismatch
{
    [OutputType([pscustomobject])]
    [CmdletBinding()]
    param
    (
        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [object]$AdUser,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [pscustomobject]$CsvUser
    )

    $ErrorActionPreference = 'Stop'

    Write-Verbose "AD-CSV field map values are [$($AdToCsvFieldMap.Values | Out-String)]"
    $csvPropertyNames = $CsvUser.PSObject.Properties.Name
    $AdPropertyNames = ($AdUser | Get-Member -MemberType Property).Name
    Write-Verbose "CSV properties are: [$($csvPropertyNames -join ',')]"
    Write-Verbose "ADUser props: [$($AdPropertyNames -join ',')]"
    foreach ($csvProp in ($csvPropertyNames | Where { ($_ -in @($AdToCsvFieldMap.Values)) })) {
        
        ## Ensure we're going to be checking the value on the correct CSV property and AD attribute
        $matchingAdAttribName = ($AdToCsvFieldMap.GetEnumerator() | where { $_.Value -eq $csvProp }).Name
        Write-Verbose -Message "Matching AD attrib name is: [$($matchingAdAttribName)]"
        Write-Verbose -Message "Matching CSV field is: [$($csvProp)]"
        if ($adAttribMatch = $AdPropertyNames | where { $_ -eq $matchingAdAttribName }) {
            Write-Verbose -Message "ADAttribMatch: [$($adAttribMatch)]"
            if (-not $AdUser.$adAttribMatch) {
                Write-Verbose -Message "[$($adAttribMatch)] value is null. Converting to empty string,.."
                $AdUser | Add-Member -MemberType NoteProperty -Name $adAttribMatch -Force -Value ''
            }
            if (-not $CsvUser.$csvProp) {
                $CsvUser.$csvProp = ''
            }
            if ($AdUser.$adAttribMatch -ne $CsvUser.$csvProp) {
                [pscustomobject]@{
                    CSVAttributeName = $csvProp
                    CSVAttributeValue = $CsvUser.$csvProp
                    ADAttributeName = $adAttribMatch
                    ADAttributeValue = $AdUser.$adAttribMatch
                }
                Write-Verbose -Message "AD attribute mismatch found on CSV property: [$($csvProp)]. Value is [$($AdUser.$adAttribMatch)] and should be [$($CsvUser.$csvProp)]"
            }
        }
    }
}

function SyncCompanyUser
{
    [OutputType()]
    [CmdletBinding(SupportsShouldProcess,ConfirmImpact = 'High')]
    param
    (
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [System.DirectoryServices.AccountManagement.UserPrincipal]$AdUser,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [pscustomobject]$CsvUser,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [pscustomobject[]]$Attributes,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$Identifier,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [pscredential]$Credential = $Defaults.Credential,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]$DomainController = $Defaults.DomainController
    )

    $ErrorActionPreference = 'Stop'

    $replaceHt = @{}
    foreach ($obj in $Attributes) {
        $replaceHt.($obj.ADAttributeName) = $obj.CSVAttributeValue
    }

    $adIdentity = $AdUser.$Identifier

    $params = @{
        Identity = $adIdentity
        Replace = $replaceHt
    }
    if ($Credential) {
        $params.Credential = $Credential
    }
    if ($DomainController) {
        $params.Server = $DomainController
    }
    if ($PSCmdlet.ShouldProcess("User: [$Identifier] AD attribs: $($replaceHt.Keys -join ',')",'Set AD attributes'))
    {
        Write-Verbose -Message "Setting the following AD attributes for user [$Identifier]: $($replaceHt | Out-String)"
        Set-AdUser @params    
    }
}

function NewRandomPassword
{
    [CmdletBinding()]
    [OutputType([System.Security.SecureString])]
    param
    (
        [Parameter()]
        [ValidateRange(8, 64)]
        [uint32]$Length = (Get-Random -Minimum 20 -Maximum 32),

        [Parameter()]
        [ValidateRange(0, 8)]
        [uint32]$Complexity = 3
    )

    try
    {
        $ErrorActionPreference = [System.Management.Automation.ActionPreference]::Stop;

        # Generate a password with the specified length and complexity.
        $password = [System.Web.Security.Membership]::GeneratePassword($Length, $Complexity);

        # Remove any restricted characters that makes the password unfriendly to XML.
        @('"', "'", '<', '>', '&', '/') | ForEach-Object {
            $password = $password.Replace($_, '');
        }

        # Convert the password to a secure string so we don't put plain text passwords on the pipeline.
        [pscustomobject]@{
            SecurePassword = ConvertTo-SecureString -String $password -AsPlainText -Force
            PlainTextPassword = $password
        }
    }
    catch
    {
        Write-Error -Message $_.Exception.Message
    }
}

function WriteLog
{
    [OutputType([void])]
    [CmdletBinding()]
    param
    (
        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]$FilePath = "$PSScriptRoot\CsvToActiveDirectorySync.log",

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]$CsvIdentifierField,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$CsvIdentifierValue,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [pscustomobject[]]$Attributes
    )
    
    $ErrorActionPreference = 'Stop'
    
    $time = Get-Date -Format 'g'
    $Attributes | foreach {
        $_ | Add-Member -MemberType NoteProperty -Name 'CsvIdentifierValue' -Force -Value $CsvIdentifierValue
        $_ | Add-Member -MemberType NoteProperty -Name 'CsvIdentifierField' -Force -Value $CsvIdentifierField
        $_ | Add-Member -MemberType NoteProperty -Name 'Time' -Force -Value $time
    }
    
    $Attributes | Export-Csv -Path $FilePath -Append -NoTypeInformation

}

function TestNullCsvIdField
{
    [OutputType([bool])]
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [object]$CsvUser
    )


    if (-not ($Defaults.FieldMatchIds.CSV | where { $CSVUser.$_ })) {
        $false
    } else {
        $true
    }
}

function Invoke-AdSync
{
    [OutputType()]
    [CmdletBinding(SupportsShouldProcess)]
    param
    (
        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]$CsvFilePath = $Defaults.InputCsvFilePath,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [switch]$ReportOnly,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [hashtable]$Exclude
    )
    begin
    {
        $ErrorActionPreference = 'Stop'
    }
    process
    {
        try
        {
            $getCsvParams = @{
                CsvFilePath = $CsvFilePath
            }
            if ($PSBoundParameters.ContainsKey('Exclude'))
            {
                if (-not (TestCsvHeaderExists -CsvFilePath $CsvFilePath -Header ([array]$Exclude.Keys))) {
                    throw 'One or more CSV headers excluded with -Exclude do not exist in the CSV file.'
                }
                $getCsvParams.Exclude = $Exclude
            }
            
            $compParams = @{
                CsvUsers = Get-CompanyCsvUser @getCsvParams
                AdUsers = Get-CompanyAdUser
            }
            $userCompareResults = CompareCompanyUser @compParams
            foreach ($user in $userCompareResults) {
                if ($user.Match) {
                    $csvIdValue = $user.CsvUser.($user.IdMatchedOn)
                    $csvIdField = $user.IdMatchedOn
                    $attribMismatches = FindAttributeMismatch -AdUser $user.ADUser -CsvUser $user.CSVUser
                    if ($attribMismatches) {
                        $logAttribs = $attribMismatches
                        if (-not $ReportOnly.IsPresent) {
                            SyncCompanyUser -AdUser $user.ADUser -CsvUser $user.CSVUser -Attributes $attribMismatches -Identifier $user.IdMatchedOn
                        }
                    } else {
                        Write-Verbose -Message "No attributes found to be mismatched between CSV and AD user account for user [$csvIdValue]"
                        $logAttribs = [pscustomobject]@{
                            CSVAttributeName = 'AlreadyInSync'
                            CSVAttributeValue = 'AlreadyInSync'
                            ADAttributeName = 'AlreadyInSync'
                            ADAttributeValue = 'AlreadyInSync'
                        }
                    }
                } else {
                    
                    if (-not (TestNullCsvIdField -CsvUser $user.CsvUser)) {
                        $csvIdValue = 'N/A'
                        Write-Warning -Message 'The CSV user identifier field could not be found!'
                    } else {
                        $csvIdFields = $Defaults.FieldMatchIds.CSV
                        $csvIdField = $csvIdFields -join ','
                        $csvIdValue = ($csvIdFields | foreach { $user.CSVUser.$_ }) -join ','
                        $logAttribs = ([pscustomobject]@{
                            CSVAttributeName = 'NoMatch'
                            CSVAttributeValue = 'NoMatch'
                            ADAttributeName = 'NoMatch'
                            ADAttributeValue = 'NoMatch'
                        })
                    }
                }
                WriteLog -CsvIdentifierField $csvIdField -CsvIdentifierValue $csvIdValue -Attributes $logAttribs
            }
        }
        catch
        {
            Write-Error -Message "Function: $($MyInvocation.MyCommand.Name) Error: $($_.Exception.Message)"
        }
    }
}