Shared/ADHelpers.psm1

# private helpers for the other NestedModules
Set-StrictMode -Version Latest
$ErrorActionPreference = [Management.Automation.ActionPreference]::Stop
. $PSScriptRoot\Variables.ps1
Add-Type -AssemblyName 'System.DirectoryServices.Protocols'


function Invoke-SearchRequest {
    [OutputType([PSCustomObject])]
    [CmdletBinding()]
    param (
        # The filter to search for entries. Uses normal LDAP Search syntax, *not*
        # PS ActiveDirectory search.
        [string] $LDAPFilter,
        
        # The base path to search within on the given server
        [Parameter()]
        [string] $SearchBase,

        # The domain controller to query.
        [Parameter()]
        [string] $Server,

        # Credentials for the domain controller.
        [Parameter()]
        [PSCredential] $Credential
    )
    process {
        $ldapConnection = New-LDAPConnection $Server $Credential

        $searchRequest = [DirectoryServices.Protocols.SearchRequest]::new(
            $SearchBase, # DN
            $LDAPFilter, # filter
            'Subtree', # mode
            '*' # attributes
        )

        $ldapConnection.SendRequest($searchRequest)
    }
}


function ConvertFrom-LDAPSearchResponse {
    <#
    .SYNOPSIS
        Convert LDAP request response object into a PSCustomObject with all the members.
    #>

    [CmdletBinding()]
    param (
        [Parameter(ValueFromPipeline)]
        [DirectoryServices.Protocols.SearchResponse] $Response,

        [Parameter(Mandatory)]
        [scriptblock] $ObjectPropertyConverter
    )
    process {
        foreach ($entry in $Response.Entries) {
            $attributesTable = @{}

            # Build attributesTable out of response attributes. Some attributes
            # need special handling to parse their binary forms.
            foreach ($key in $entry.Attributes.Keys | Sort-Object) {
                $valueCollection = $entry.Attributes[$key]
                $attributesTable[$key] = if ($key -eq 'objectSid' -and $valueCollection[0] -is [byte[]]) {
                    [Security.Principal.SecurityIdentifier]::new($valueCollection[0], 0)
                } elseif ($key -eq 'objectGuid') {
                    [Guid]::new($valueCollection[0])
                } elseif ($valueCollection.Count -gt 1) {
                    # flatten & parse array of UTF-8 binary. Needed for objectClass, possibly others.
                    $valueCollection | Foreach-Object {
                        if ($_) { [Text.Encoding]::UTF8.GetString($_) }
                    }
                } else {
                    $valueCollection[0]
                }
            }

            # Use the provided converter to convert the attributes hashtable
            # into object properties, the make the object.
            $ObjectPropertyTable = Invoke-Command -ScriptBlock $ObjectPropertyConverter -ArgumentList $attributesTable

            $resultObject = [PSCustomObject] $ObjectPropertyTable
            Set-LDAPEntryAttributeTable $resultObject $attributesTable

            # output
            $resultObject
        }
    }
}


function New-LDAPConnection {
    <#
    .SYNOPSIS
        Construct a new DirectoryServices.Protocols.LdapConnection using given parameters.
    .OUTPUTS
        A DirectoryServices.Protocols.LdapConnection
    #>

    [OutputType([DirectoryServices.Protocols.LdapConnection])]
    [CmdletBinding()]
    param (
        # The domain controller to query.
        [Parameter(ValueFromPipelineByPropertyName)]
        [string] $Server,

        # Credentials for the domain controller.
        [Parameter(ValueFromPipelineByPropertyName)]
        [PSCredential] $Credential
    )
    process {
        $directoryIdentifier = [DirectoryServices.Protocols.LdapDirectoryIdentifier]::new($Server)
        $networkCredential = if ($Credential) {
            $Credential.GetNetworkCredential()
        }
        $ldapConnection = [DirectoryServices.Protocols.LdapConnection]::new(
            $directoryIdentifier, $networkCredential
        )
        $ldapConnection.Bind()
        
        #output
        $ldapConnection
    }
}


function Get-DistinguishedNameComponent {
    <#
    .SYNOPSIS
        Filter the components of a DistinguishedName. Assumes that DN is
        ActiveDirectory-style, meaning CNs then OUs then DCs, no O or C or
        whatever.
    #>

    [OutputType([string])]
    [CmdletBinding()]
    param (
        [Parameter([string])]
        $DistinguishedName,

        [switch] $CommonName,

        [switch] $OrganizationalUnit,

        [switch] $DomainComponent
    )
    process
    {
        # (?<!XXX) is negative lookbehind to handle escaped commas \,
        $components = $DistinguishedName -split '(?<!\\),' 
        $result = @()
        if ($CommonName) {
            $result += $components | Where-Object -Match "^CN=.*$"
        }
        if ($OrganizationalUnit) {
            $result += $components | Where-Object -Match "^OU=.*$"
        }
        if ($DomainName) {
            $result += $components | Where-Object -Match "^DC=.*$"
        }

        # output
        $result -join ','
    }
}


function Convert-ADIdentityToFilter {
    [OutputType([string])]
    [CmdletBinding()]
    param (
        [ValidateNotNullOrEmpty()]
        [Parameter(ValueFromPipeline, Mandatory)]
        [string] $Identity
    )
    process {
        if ($Identity -match "^\*$") {
            throw [ArgumentException]::new("'*' cannot be used for -Identity parameters", 'Identity')
        }
        if ($Identity -match "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") {
            "(objectGUID=$Identity)"
        } elseif ($Identity -match "^S-\d-\d+-(\d+-){1,14}\d+$") {
            "(objectSid=$Identity)"
        } elseif ($Identity -match "^(?:(?<cn>CN=(?<name>[^,]*)),)?(?:(?<path>(?:(?:CN|OU)=[^,]+,?)+),)?(?<domain>(?:DC=[^,]+,?)+)$") {
            # regex from https://regexr.com/3l4au
            "(distinguishedName=$Identity)"
        } else {
            "(sAMAccountName=$Identity)"
        }
    }
}


function Add-DirectoryAttributeModification {
    <#
    .SYNOPSIS
        Creates a DirectoryAttributeModification object with the Add operation.
    .OUTPUTS
        [DirectoryServices.Protocols.DirectoryAttributeModification] if PassThru is set.
    #>

    [OutputType([DirectoryServices.Protocols.DirectoryAttributeModification])]
    [CmdletBinding()]
    param (
        [Parameter(Position=0, Mandatory)]
        [AllowEmptyCollection()]
        [Collections.ArrayList] $AttributeModificationList,

        # The type of modification to perform.
        [Parameter(Position=1, Mandatory, ValueFromPipelineByPropertyName)]
        [DirectoryServices.Protocols.DirectoryAttributeOperation] $Operation,

        # The name of the attribute to modify.
        [Parameter(Position=2, Mandatory, ValueFromPipelineByPropertyName)]
        [string] $Name,

        # The value(s) to set on the attribute.
        [Parameter(Position=3, ValueFromPipelineByPropertyName)]
        [object[]] $Value,

        [Switch]
        $PassThru
    )
    process {
        $attributeModification = [DirectoryServices.Protocols.DirectoryAttributeModification]::new()
        $attributeModification.Name = $Name
        foreach ($val in $Value) {
            $attributeModification.Add($val) | Out-Null
        }
        $attributeModification.Operation = $Operation

        $AttributeModificationList.Add($attributeModification) | Out-Null
        if ($PassThru) {
            # output
            $modification
        }
    }
}


function Convert-ADDateTime {
    <#
    .SYNOPSIS
        Converts an LDAP date code (bigint 100-nanosecond intervals since
        1601-01-01) to local time.
    .OUTPUTS
        [Nullable[DateTime]] in local timezone. [DateTimeOffset] would be better
        but used [DateTime] for compatibility.
    #>

    [OutputType([Nullable[DateTime]])]
    [CmdletBinding()]
    param (
        # A time code expressed as "File Time"; The LDAP format represents
        # 100-nanosecond intervals since January 1, 1601. It appears that
        # [Int64]::MaxValue is used to represent max date.
        [Parameter(ValueFromPipeline)]
        [Nullable[Int64]] $FileTimeValue
    )
    process {
        if ($FileTimeValue) {
            if ($FileTimeValue -eq [Int64]::MaxValue) {
                [DateTime]::MaxValue.ToLocalTime()
            } else {
                [DateTime]::FromFileTime($FileTimeValue).ToLocalTime()
            }
        }
    }
}