PSWorkbench.psm1

#Region '.\Public\Get-ADBulkUserHashtable.ps1' -1

function Get-ADBulkUserHashtable {
    <#
    .SYNOPSIS
        Retrieves Active Directory user information for multiple users from primary and backup domains
 
    .DESCRIPTION
        Searches for multiple AD users simultaneously using an optimized LDAP filter with flexible search criteria. If users are not found in the default domain,
        performs a secondary search against a specified global catalog server. Returns user details in a hashtable for efficient lookups.
        Uses verbose output to report users not found in either domain.
 
    .PARAMETER UserList
        Array of user identifiers to search for in Active Directory. The identifier type is determined by the SearchBy parameter.
 
    .PARAMETER SearchBy
        Specifies which AD attribute to use for user lookups. Use 'Auto' (default) to detect per-value based on format:
        values containing '@' resolve to UserPrincipalName, 'CN=' prefix to DistinguishedName, all-digits to EmployeeID,
        whitespace to DisplayName, and everything else to SamAccountName. Mixed input lists are fully supported in Auto mode.
        Note: EmployeeID and Mail are not in the default Get-ADUser property set - include them via -Properties if needed.
 
    .PARAMETER Server
        Active Directory server to target for the primary search. When omitted, the default domain controller is used.
 
    .PARAMETER ADGlobalCatalog
        Global catalog server and port for backup domain searches when users are not found in the default domain.
 
    .PARAMETER Properties
        Optional array of additional AD properties to retrieve for each user
 
    .EXAMPLE
        Get-ADBulkUserHashtable -UserList 'user1', 'user2', 'user3'
        Searches for three users using Auto detection (all resolve to SamAccountName)
 
    .EXAMPLE
        Get-ADBulkUserHashtable -UserList 'user1@company.com', 'jdoe', 'CN=Jane Smith,OU=Users,DC=corp,DC=com'
        Mixed input list: UPN, SamAccountName, and DistinguishedName resolved automatically via Auto detection
 
    .EXAMPLE
        Get-ADBulkUserHashtable -UserList 'user1@company.com', 'user2@company.com' -SearchBy 'UserPrincipalName' -Verbose
        Explicit SearchBy - all values searched by UPN with verbose output for tracking search results
 
    .EXAMPLE
        Get-ADBulkUserHashtable -UserList 'John Doe', 'Jane Smith' -SearchBy 'DisplayName' -Properties 'Department', 'Title'
        Retrieves users by display name with additional properties
 
    .NOTES
        Author: https://github.com/dan-metzler
        PowerShellVersion: PowerShell 5.1 or Later Recommended.
        Features: Bulk user lookup, flexible search criteria, automatic failover to global catalog, hashtable return for efficient lookups, verbose logging
    #>


    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string[]]$UserList,

        [Parameter()]
        [ValidateSet("Auto", "SamAccountName", "UserPrincipalName", "Mail", "DisplayName", "EmployeeID", "DistinguishedName")]
        [string]$SearchBy = "Auto",

        # Parameter help description
        [Parameter()]
        [string]$Server,

        # this parameter is when users are not found in the default domain, we check a backup domain, this is the server for the backup domain.
        [Parameter()]
        [string]$ADGlobalCatalog,

        # Optional properties parameter
        [Parameter()]
        [string[]]$Properties
    )

    # Map parameter values to actual LDAP attribute names
    $ldapAttributeMap = @{
        "SamAccountName"    = "sAMAccountName"
        "UserPrincipalName" = "userPrincipalName"
        "Mail"              = "mail"
        "DisplayName"       = "displayName"
        "EmployeeID"        = "employeeID"
        "DistinguishedName" = "distinguishedName"
    }

    # When SearchBy is Auto, detect each input value's attribute type by its distinct form:
    # ^\s*(?i:(CN|OU|DC|...)) -> DistinguishedName | ^[^@\s]+@[^\s]+$ -> UserPrincipalName
    # ^\d+$ -> EmployeeID | whitespace -> DisplayName | default -> SamAccountName
    if ($SearchBy -eq 'Auto') {
        $inputAttributeMap = @{}
        foreach ($inputValue in $UserList) {
            $inputAttributeMap[$inputValue] = switch -Regex ($inputValue) {
                '^\s*(?i:(CN|OU|DC|O|L|ST|C)=)' { 'DistinguishedName'; break }
                '^[^@\s]+@[^\s]+$'               { 'UserPrincipalName'; break }
                '^\d+$'                          { 'EmployeeID'; break }
                '\s'                             { 'DisplayName'; break }
                default                          { 'SamAccountName' }
            }
        }
    }
    else {
        $ldapAttribute = $ldapAttributeMap[$SearchBy]
    }

    # DisplayName, EmployeeID, and Mail are not in the default Get-ADUser property set.
    # In Auto mode, auto-inject whichever of those were detected so the post-search matching
    # can read them back from the returned user objects.
    $effectiveProperties = $Properties
    if ($SearchBy -eq 'Auto') {
        $autoProps = ($inputAttributeMap.Values | Select-Object -Unique | Where-Object { $_ -in 'DisplayName', 'EmployeeID', 'Mail' })
        if ($autoProps) {
            $effectiveProperties = @($autoProps) + @($Properties | Where-Object { $_ }) | Select-Object -Unique
        }
        Write-Verbose "Auto-detected attribute types: $(($inputAttributeMap.GetEnumerator() | ForEach-Object { "'$($_.Key)' -> '$($_.Value)'" }) -join ' | ')"
    }

    # Construct LDAP filter - in Auto mode each value uses its detected attribute; otherwise all share the same attribute
    $hashtable = @{}
    if ($SearchBy -eq 'Auto') {
        $ldapFilter = "(|" + ($UserList | ForEach-Object { "($($ldapAttributeMap[$inputAttributeMap[$_]])=$_)" }) + ")"
    }
    else {
        $ldapFilter = "(|" + ($UserList | ForEach-Object { "($ldapAttribute=$_)" }) + ")"
    }

    # Use splatting to conditionally include Properties parameter
    $getUserParams = @{
        LDAPFilter = $ldapFilter
    }

    if ($effectiveProperties) {
        $getUserParams['Properties'] = $effectiveProperties
    }

    if ($Server) {
        $getUserParams['Server'] = $Server
    }

    $userDetailsList = @(Get-ADUser @getUserParams)

    # Create lookup mapping from search input to found users
    $searchInputToUser = @{}
    for ($i = 0; $i -lt $userDetailsList.Count; $i++) {
        $currentUser = $userDetailsList[$i]
        # Find which input value(s) match this user - in Auto mode each input value checks its own detected attribute
        $matchingInputs = $UserList | Where-Object {
            $attr = if ($SearchBy -eq 'Auto') { $inputAttributeMap[$_] } else { $SearchBy }
            $currentUser.$attr -eq $_
        }
        foreach ($input in $matchingInputs) {
            $searchInputToUser[$input] = $currentUser
        }
    }

    # add the results to the hashtable keyed by SamAccountName for consistent lookups regardless of input form
    foreach ($inputValue in $UserList) {
        if ($searchInputToUser.ContainsKey($inputValue)) {
            $hashtable[$searchInputToUser[$inputValue].SamAccountName] = $searchInputToUser[$inputValue]
        }
    }

    if ($hashtable.Count -ne $UserList.Count) {
        # Use the input-tracking map (not the hashtable) to find which inputs didn't resolve to a user
        $notFoundUsers = $UserList | Where-Object { -not $searchInputToUser.ContainsKey($_) }
        Write-Verbose "$($notFoundUsers.Count) users were not found in Default Active Directory, searching global catalog..."

        # for users not found in the original search we need a sub search against the global catalog, we can reuse the same parameters but need to change the server and ldap filter
        if ($SearchBy -eq 'Auto') {
            $notFoundUsersLdapFilter = "(|" + ($notFoundUsers | ForEach-Object { "($($ldapAttributeMap[$inputAttributeMap[$_]])=$_)" }) + ")"
        }
        else {
            $notFoundUsersLdapFilter = "(|" + ($notFoundUsers | ForEach-Object { "($ldapAttribute=$_)" }) + ")"
        }

        $getUserParams['Server'] = $ADGlobalCatalog
        $getUserParams['LDAPFilter'] = $notFoundUsersLdapFilter

        $userBackupDetailsList = @(Get-ADUser @getUserParams)

        # add the backup results to the hashtable, nothing added if the userBackupDetailsList is empty
        $backupSearchInputToUser = @{}
        for ($i = 0; $i -lt $userBackupDetailsList.Count; $i++) {
            $currentUser = $userBackupDetailsList[$i]
            # Find which input value(s) match this user from backup search - same Auto-mode attribute detection
            $matchingInputs = $notFoundUsers | Where-Object {
                $attr = if ($SearchBy -eq 'Auto') { $inputAttributeMap[$_] } else { $SearchBy }
                $currentUser.$attr -eq $_
            }
            foreach ($input in $matchingInputs) {
                $backupSearchInputToUser[$input] = $currentUser
            }
        }

        # add the backup results keyed by SamAccountName
        foreach ($inputValue in $notFoundUsers) {
            if ($backupSearchInputToUser.ContainsKey($inputValue)) {
                $hashtable[$backupSearchInputToUser[$inputValue].SamAccountName] = $backupSearchInputToUser[$inputValue]
            }
        }

        # Check if there are still users not found after backup search
        $stillNotFoundUsers = $notFoundUsers | Where-Object { -not $backupSearchInputToUser.ContainsKey($_) }
        if ($stillNotFoundUsers.Count -gt 0) {
            foreach ($user in $stillNotFoundUsers) {
                Write-Warning "User not found in ($env:USERDNSDOMAIN) or ($ADGlobalCatalog): $user"
            }
        }
    }

    if ($hashtable.count -gt 0) {
        $hashtable
    }
    else {
        $null
    }
}
#EndRegion '.\Public\Get-ADBulkUserHashtable.ps1' 215
#Region '.\Public\Install-Certificate.ps1' -1

function Install-Certificate {
    <#
    .SYNOPSIS
        Writes a certificate file into a Windows certificate store.
 
    .DESCRIPTION
        Accepts a filesystem path or HTTPS URL pointing to a certificate file (PFX, CER,
        SST, P7B, or PEM) and writes it into the target Windows certificate store.
 
        The destination store location is resolved automatically from the execution context.
        Processes running as NT AUTHORITY\SYSTEM write to LocalMachine; all other contexts
        write to the CurrentUser store of the calling user.
 
        Certificate collections (PFX, P7B, SST) are always committed as a unit to a single
        store. To route individual certificates from a collection into different stores,
        split the collection upstream and invoke this function once per certificate.
 
    .PARAMETER CertificatePath
        Filesystem path or HTTPS URL to the source certificate file.
        Accepted extensions: .pfx .cer .sst .p7b .pem
 
    .PARAMETER CertificateStore
        Target store identified by its Windows-friendly name. Must be one of:
            Personal
            Trusted Root Certification Authorities
            Third-Party Root Certification Authorities
            Trusted Publisher
            Intermediate Certification Authorities
            Untrusted Certificates
            Trusted People
            Other People
 
    .PARAMETER CertificatePassword
        SecureString password required to open the certificate file, if one is set.
 
    .PARAMETER OverwriteExisting
        Before writing, remove any certificate already present in the target store whose
        thumbprint matches the incoming certificate. Without this switch, a matching
        thumbprint causes the function to return without making any changes.
 
    .EXAMPLE
        $pw = ConvertTo-SecureString 'P@ssw0rd' -AsPlainText -Force
        Install-Certificate -CertificatePath 'C:\Certs\internal-ca.pfx' -CertificateStore 'Personal' -CertificatePassword $pw
 
    .EXAMPLE
        Install-Certificate -CertificatePath 'C:\Certs\root-ca.cer' -CertificateStore 'Trusted Root Certification Authorities' -OverwriteExisting
 
    .NOTES
        Execution context determines the store location written to:
          NT AUTHORITY\SYSTEM -> LocalMachine
          All other users -> CurrentUser
    #>


    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$CertificatePath,

        [Parameter(Mandatory)]
        [ValidateSet(
            "Personal",
            "Trusted Root Certification Authorities",
            "Third-Party Root Certification Authorities",
            "Trusted Publisher",
            "Intermediate Certification Authorities",
            "Untrusted Certificates",
            "Trusted People",
            "Other People"
        )]
        [string]$CertificateStore,

        [Parameter()]
        [SecureString]$CertificatePassword,

        [Parameter()]
        [switch]$OverwriteExisting
    )

    begin {

        function Test-IsSystemAccount {
            $currentIdentity = [System.Security.Principal.WindowsIdentity]::GetCurrent()
            return $currentIdentity.Name -like "NT AUTHORITY*" -or $currentIdentity.IsSystem
        }

        function Invoke-CertificateDownload {
            param (
                [Parameter(Mandatory)]
                [string]$SourceUrl,

                [Parameter(Mandatory)]
                [string]$DestinationPath,

                [Parameter()]
                [int]$MaxAttempts = 3,

                [Parameter()]
                [switch]$SkipDelay
            )

            Write-Verbose "Downloading certificate from '$SourceUrl'"

            $supportedTls = [enum]::GetValues('Net.SecurityProtocolType')
            if (($supportedTls -contains 'Tls13') -and ($supportedTls -contains 'Tls12')) {
                [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls13 -bor [System.Net.SecurityProtocolType]::Tls12
            }
            elseif ($supportedTls -contains 'Tls12') {
                [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12
            }
            else {
                Write-Warning "TLS 1.2 and TLS 1.3 are not supported on this system. The download may fail."
            }

            $attempt = 1
            while ($attempt -le $MaxAttempts) {
                if (-not $SkipDelay) {
                    $delaySecs = Get-Random -Minimum 3 -Maximum 15
                    Write-Verbose "Waiting $delaySecs seconds before attempt $attempt."
                    Start-Sleep -Seconds $delaySecs
                }

                Write-Verbose "Download attempt $attempt of $MaxAttempts"

                $previousProgress = $ProgressPreference
                $ProgressPreference = 'SilentlyContinue'
                try {
                    if ($PSVersionTable.PSVersion.Major -lt 4) {
                        $webClient = [System.Net.WebClient]::new()
                        $webClient.DownloadFile($SourceUrl, $DestinationPath)
                    }
                    else {
                        Invoke-WebRequest -Uri $SourceUrl -OutFile $DestinationPath -MaximumRedirection 10 -UseBasicParsing
                    }
                    $fileExists = Test-Path -Path $DestinationPath -ErrorAction SilentlyContinue
                }
                catch {
                    Write-Warning "Download attempt $attempt failed: $($_.Exception.Message)"
                    if (Test-Path -Path $DestinationPath -ErrorAction SilentlyContinue) {
                        Remove-Item -Path $DestinationPath -Force -Confirm:$false -ErrorAction SilentlyContinue
                    }
                    $fileExists = $false
                }

                $ProgressPreference = $previousProgress

                if ($fileExists) {
                    $attempt = $MaxAttempts
                }
                else {
                    Write-Warning "Download attempt $attempt did not produce a file."
                }

                $attempt++
            }

            if (-not (Test-Path $DestinationPath)) {
                throw "Failed to download certificate from '$SourceUrl'. Verify the URL is accessible and the certificate exists at that location."
            }

            return $DestinationPath
        }

        function ConvertTo-PlainText {
            # Converts a SecureString to a plain-text string via BSTR, then immediately zeroes the BSTR.
            # The returned string lives in managed memory and cannot be zeroed, so keep its lifetime as short as possible.
            param (
                [Parameter(Mandatory)]
                [SecureString]$SecureValue
            )
            $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecureValue)
            try {
                return [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr)
            }
            finally {
                [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
            }
        }

        # Map the friendly store name to the .NET StoreName enum value
        $storeName = switch ($CertificateStore) {
            "Personal" { [System.Security.Cryptography.X509Certificates.StoreName]::My }
            "Trusted Root Certification Authorities" { [System.Security.Cryptography.X509Certificates.StoreName]::Root }
            "Third-Party Root Certification Authorities" { [System.Security.Cryptography.X509Certificates.StoreName]::AuthRoot }
            "Trusted Publisher" { [System.Security.Cryptography.X509Certificates.StoreName]::TrustedPublisher }
            "Intermediate Certification Authorities" { [System.Security.Cryptography.X509Certificates.StoreName]::CertificateAuthority }
            "Untrusted Certificates" { [System.Security.Cryptography.X509Certificates.StoreName]::Disallowed }
            "Trusted People" { [System.Security.Cryptography.X509Certificates.StoreName]::TrustedPeople }
            "Other People" { [System.Security.Cryptography.X509Certificates.StoreName]::AddressBook }
        }
    }

    process {

        # Stage the certificate to a unique temp path to avoid file collisions
        $certGuid = [System.Guid]::NewGuid().Guid
        $certTempPath = "$env:TEMP\cert_$certGuid"

        if ($CertificatePath -like "*.*") {
            $fileExtension = $CertificatePath.Split(".")[-1].ToLower()
            switch ($fileExtension) {
                "pfx" { $certTempPath += ".pfx" }
                "cer" { $certTempPath += ".cer" }
                "sst" { $certTempPath += ".sst" }
                "p7b" { $certTempPath += ".p7b" }
                "pem" { $certTempPath += ".pem" }
                default {
                    throw "Unsupported certificate file extension '.$fileExtension'. Supported formats: pfx, cer, pem, sst, p7b."
                }
            }
        }
        else {
            throw "Certificate path '$CertificatePath' has no file extension. Supported formats: pfx, cer, pem, sst, p7b."
        }

        # Obtain the certificate file - download if a URL was provided, otherwise copy locally
        if ($CertificatePath -like "http*") {
            if ($CertificatePath -notmatch "^https?://") {
                throw "Invalid URL format: '$CertificatePath'"
            }
            if ($CertificatePath -match "^http://") {
                Write-Warning "Certificate is being downloaded over an unencrypted HTTP connection."
            }
            Invoke-CertificateDownload -SourceUrl $CertificatePath -DestinationPath $certTempPath
        }
        else {
            if (-not (Test-Path $CertificatePath)) {
                throw "Certificate file not found at path '$CertificatePath'."
            }
            try {
                Copy-Item -Path (Resolve-Path -Path $CertificatePath) -Destination $certTempPath -Force
            }
            catch {
                throw "Failed to copy certificate from '$CertificatePath': $($_.Exception.Message)"
            }
        }

        # Load the certificate into a .NET object
        $certObject = $null
        $importFailed = $false

        try {
            Write-Verbose "Detecting certificate content type"
            $certContentType = [System.Security.Cryptography.X509Certificates.X509Certificate2]::GetCertContentType($certTempPath)
            Write-Verbose "Certificate content type: $certContentType"

            $keyFlags = if (Test-IsSystemAccount) {
                [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::MachineKeySet -bor
                [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::PersistKeySet
            }
            else {
                [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::UserKeySet -bor
                [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::PersistKeySet
            }

            switch ($certContentType) {
                "Unknown" {
                    throw "Certificate file is empty or unreadable: '$certTempPath'"
                }
                { $_ -in @("Cert", "SerializedCert") } {
                    Write-Verbose "Loading $certContentType certificate"
                    $certObject = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($certTempPath)
                    Write-Verbose "Certificate loaded"
                }
                { $_ -in @("Pfx", "Pkcs12", "SerializedStore", "Pkcs7", "Authenticode") } {
                    Write-Verbose "Loading $certContentType certificate collection"
                    $certCollection = [System.Security.Cryptography.X509Certificates.X509Certificate2Collection]::new()
                    if ($null -ne $CertificatePassword -and $CertificatePassword.Length -gt 0) {
                        # X509Certificate2Collection.Import has no SecureString overload in .NET Framework 4.x;
                        # convert via BSTR and zero it immediately after the call.
                        $plainPassword = ConvertTo-PlainText -SecureValue $CertificatePassword
                        try {
                            $certCollection.Import($certTempPath, $plainPassword, $keyFlags)
                        }
                        finally {
                            $plainPassword = $null
                        }
                    }
                    else {
                        $certCollection.Import($certTempPath)
                    }
                    $certObject = $certCollection
                    Write-Verbose "Certificate collection loaded ($($certCollection.Count) certificate(s))"
                }
                default {
                    throw "Unrecognised certificate content type: $certContentType"
                }
            }
        }
        catch {
            $importFailed = $true
        }

        # Fall back to direct password-based construction if the initial load failed
        if ($importFailed) {
            try {
                # X509Certificate2 has a native SecureString constructor - no plain-text conversion needed here.
                $certObject = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($certTempPath, $CertificatePassword)
            }
            catch {
                Remove-Item -Path $certTempPath -Force -ErrorAction SilentlyContinue
                switch -Regex ($_.Exception.Message) {
                    "Cannot find the original signer" { throw "Certificate load failed: cannot find the original signer." }
                    default { throw "Failed to load certificate from '$CertificatePath': $($_.Exception.Message)" }
                }
            }
        }

        # Open the target certificate store
        $storeLocation = if (Test-IsSystemAccount) {
            [System.Security.Cryptography.X509Certificates.StoreLocation]::LocalMachine
        }
        else {
            [System.Security.Cryptography.X509Certificates.StoreLocation]::CurrentUser
        }

        try {
            $certStore = [System.Security.Cryptography.X509Certificates.X509Store]::new($storeName, $storeLocation)
        }
        catch {
            throw "Failed to create certificate store object for '$CertificateStore': $($_.Exception.Message)"
        }

        try {
            $certStore.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::MaxAllowed)
            Write-Verbose "Opened certificate store '$CertificateStore' ($storeLocation) with read/write access"
        }
        catch {
            throw "Failed to open certificate store '$CertificateStore': $($_.Exception.Message)"
        }

        # Flatten the cert object to a consistent list for thumbprint checks
        $certsToInstall = if (
            $certObject -is [System.Security.Cryptography.X509Certificates.X509Certificate2Collection] -or
            $certObject -is [System.Object[]]
        ) {
            $certObject
        }
        elseif ($certObject -is [System.Security.Cryptography.X509Certificates.X509Certificate2]) {
            @($certObject)
        }
        else {
            $certStore.Close()
            Remove-Item -Path $certTempPath -Force -ErrorAction SilentlyContinue
            throw "Unexpected certificate object type: $($certObject.GetType().FullName)"
        }

        # Check for existing installations and handle overwrite if requested
        foreach ($cert in $certsToInstall) {
            if ($certStore.Certificates.Thumbprint -contains $cert.Thumbprint) {
                if ($OverwriteExisting) {
                    $certStore.Certificates | Where-Object { $_.Thumbprint -eq $cert.Thumbprint } | ForEach-Object {
                        try {
                            Write-Verbose "Removing existing certificate: $($_.FriendlyName) (Thumbprint: $($_.Thumbprint))"
                            $certStore.Remove($_)
                            Write-Verbose "Existing certificate removed"
                        }
                        catch {
                            $certStore.Close()
                            throw "Failed to remove existing certificate '$($_.FriendlyName)': $($_.Exception.Message)"
                        }
                    }
                }
                else {
                    Write-Verbose "Certificate already present in store (Thumbprint: $($cert.Thumbprint)). Specify -OverwriteExisting to replace it."
                    $certStore.Close()
                    Remove-Item -Path $certTempPath -Force -ErrorAction SilentlyContinue
                    return
                }
            }
        }

        # Install certificates into the store
        try {
            if ($certObject -is [System.Security.Cryptography.X509Certificates.X509Certificate2Collection]) {
                $certStore.AddRange($certObject)
                $certObject | ForEach-Object {
                    Write-Verbose "Installed: $($_.FriendlyName) (Thumbprint: $($_.Thumbprint))"
                }
            }
            elseif ($certObject -is [System.Object[]]) {
                $certObject | ForEach-Object {
                    $certStore.Add($_)
                    Write-Verbose "Installed: $($_.FriendlyName) (Thumbprint: $($_.Thumbprint))"
                }
            }
            else {
                $certStore.Add($certObject)
                Write-Verbose "Installed: $($certObject.FriendlyName) (Thumbprint: $($certObject.Thumbprint))"
            }
        }
        catch {
            $certStore.Close()
            throw "Failed to install certificate into '$CertificateStore': $($_.Exception.Message)"
        }

        try {
            $certStore.Close()
            Write-Verbose "Certificate store '$CertificateStore' closed"
        }
        catch {
            Write-Warning "Failed to close certificate store '$CertificateStore': $($_.Exception.Message)"
        }

        Remove-Item -Path $certTempPath -Force -ErrorAction SilentlyContinue
        Write-Verbose "Certificate installation complete"
    }

    end {}
}
#EndRegion '.\Public\Install-Certificate.ps1' 411
#Region '.\Public\Resolve-ADGroupMember.ps1' -1

function Resolve-ADGroupMember {
    <#
    .SYNOPSIS
        Retrieves Active Directory group members with cross-domain resolution capabilities to handle complex multi-domain environments.
 
    .DESCRIPTION
        This function addresses common challenges in multi-domain Active Directory environments by retrieving group members
        and automatically handling cross-domain member resolution. It queries the specified Active Directory groups for
        their membership details, attempts to resolve each member object in the local domain first, and falls back to
        global catalog queries when members exist in different domains. The function provides robust error handling for
        orphaned or inaccessible member references and returns detailed member object information for analysis and reporting.
 
    .PARAMETER Identity
        Array of Active Directory group identities (names, distinguished names, or SIDs) for which to retrieve membership
        information. Supports pipeline input and processes multiple groups efficiently with cross-domain member resolution.
 
    .PARAMETER ADGlobalCatalog
        Global catalog server for cross-domain member resolution. Used as a fallback when a member object cannot be found
        in the local domain.
 
    .EXAMPLE
        Resolve-ADGroupMember -Identity "Domain-SecurityGroup-Name"
 
        Retrieves all members of the specified security group, resolving members across domains as needed.
 
    .EXAMPLE
        "Group1", "Group2", "Group3" | Resolve-ADGroupMember
 
        Uses pipeline input to process multiple groups and return comprehensive member information with cross-domain resolution.
 
    .NOTES
        Author: https://github.com/dan-metzler
        PowerShellVersion: PowerShell 5.1 or Later Recommended.
        Features: Cross-domain resolution, Global catalog queries, Pipeline support, Error handling, Multi-group processing
    #>


    [CmdletBinding()]
    param(
        [Parameter(
            Mandatory = $true,
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true,
            Position = 0
        )]
        [string[]]$Identity,

        # Active Directory global catalog server for cross-domain member resolution when local queries fail
        [Parameter(Mandatory)]
        [string]$ADGlobalCatalog
    )

    foreach ($GroupIdentity in $Identity) {
        $Group = $null
        $Group = Get-ADGroup -Identity $GroupIdentity -Properties Member
        if (-not $Group) {
            continue
        }
        Foreach ($Member in $Group.Member) {
            try {
                Get-ADObject $Member
            }
            catch {
                if ($_.Exception.GetType().FullName -eq 'Microsoft.ActiveDirectory.Management.ADIdentityNotFoundException') {
                    Get-ADObject $Member -Server $ADGlobalCatalog
                }
                else {
                    Write-Error "Error finding $Member in Root Global Domains"
                }
            }
        }
    }
}
#EndRegion '.\Public\Resolve-ADGroupMember.ps1' 73
#Region '.\Public\Update-GenericList.ps1' -1

function Update-GenericList {
    <#
    .SYNOPSIS
        Sanitizes and normalizes user input arrays by removing whitespaces, converting case, and filtering null/duplicate values
 
    .DESCRIPTION
        The **Update-GenericList** function processes user input arrays to standardize and clean the data. It provides flexible options to remove whitespaces,
        convert case (uppercase or lowercase), remove null/empty items, and eliminate duplicates.
 
    .PARAMETER UserInput
        Array of strings that require sanitization and normalization. This parameter is mandatory and accepts empty collections.
 
    .PARAMETER RemoveWhitespaces
        Switch parameter to remove all whitespace characters from each input item using regex replacement.
 
    .PARAMETER Trim
        Switch parameter to remove leading and trailing whitespace from each input item while preserving internal spaces.
 
    .PARAMETER ConvertToLowercase
        Switch parameter to convert all input items to lowercase. Cannot be used simultaneously with ConvertToUppercase.
 
    .PARAMETER ConvertToUppercase
        Switch parameter to convert all input items to uppercase. Cannot be used simultaneously with ConvertToLowercase.
 
    .PARAMETER RemoveNullOrEmptyItems
        Switch parameter to filter out null, empty, or whitespace-only items from the input array.
 
    .PARAMETER RemoveDuplicates
        Switch parameter to remove duplicate values from the processed array using Select-Object -Unique.
 
    .EXAMPLE
        Update-GenericList -UserInput @(" User1 ", "USER2", "user1") -RemoveWhitespaces -ConvertToLowercase -RemoveDuplicates
 
        Returns: @("user1", "user2") - removes spaces, converts to lowercase, and eliminates duplicates
 
    .EXAMPLE
        Update-GenericList -UserInput @("account1", "", "ACCOUNT2", $null) -ConvertToUppercase -RemoveNullOrEmptyItems
 
        Returns: @("ACCOUNT1", "ACCOUNT2") - converts to uppercase and removes null/empty items
 
    .EXAMPLE
        Update-GenericList -UserInput @(" John Doe ", " Jane Smith ", "Bob Jones") -Trim -RemoveDuplicates
 
        Returns: @("John Doe", "Jane Smith", "Bob Jones") - removes leading/trailing spaces while preserving internal spaces
 
    .NOTES
        Author: https://github.com/dan-metzler
        PowerShellVersion: PowerShell 5.1 or Later Recommended
 
        Features:
        - Flexible input sanitization with multiple processing options
        - Parameter validation prevents conflicting case conversion options
        - Efficient processing using .NET collections and regex
        - Verbose logging for processing operations performed
        - Support for empty collections and null handling
    #>


    [CmdletBinding()]
    param (
        [Parameter(
            Mandatory = $true,
            HelpMessage = "Array of strings that require sanitization and normalization."
        )]
        [AllowEmptyCollection()]
        [AllowEmptyString()]
        [string[]]$UserInput,

        [Parameter(
        )]
        [switch]$RemoveWhitespaces,

        [Parameter(
        )]
        [switch]$Trim,

        [Parameter()]
        [switch]$ConvertToLowercase,

        [Parameter()]
        [switch]$ConvertToUppercase,

        [Parameter()]
        [switch]$RemoveNullOrEmptyItems,

        [Parameter()]
        [switch]$RemoveDuplicates
    )

    # PARAMETER VALIDATION, ONE MUST BE SET TO TRUE
    if (-Not($RemoveWhitespaces -or $Trim -or $ConvertToLowercase -or $ConvertToUppercase)) {
        throw "At least one of the boolean parameters [RemoveWhitespaces, Trim, ConvertToLowercase, ConvertToUppercase] must be set to `$true"
    }

    if ($ConvertToLowercase -and $ConvertToUppercase) {
        throw "[ConvertToLowercase] and [ConvertToUppercase] CANNOT both be set at the same time, only one can be chosen"
    }

    # IF WHITE SPACE SWITCH IS SELECTED, CLEAN THE INPUT DATA AND GET RID OF ANY SPACES
    $LIST_CollectUpdatedItems = [System.Collections.Generic.List[string]]::New()

    if ($RemoveWhitespaces -and $ConvertToLowercase) {
        for ($i = 0; $i -lt $UserInput.Count; $i++) {
            $LIST_CollectUpdatedItems.Add($UserInput[$i].Replace(' ', '').ToLowerInvariant())
        }
        Write-Verbose "Cleaned User Input :: [Removed Whitespaces & Converted To Lowercase]"
    }
    elseif ($RemoveWhitespaces -and $ConvertToUppercase) {
        for ($i = 0; $i -lt $UserInput.Count; $i++) {
            $LIST_CollectUpdatedItems.Add($UserInput[$i].Replace(' ', '').ToUpperInvariant())
        }
        Write-Verbose "Cleaned User Input :: [Removed Whitespaces & Converted To Uppercase]"
    }
    elseif ($RemoveWhitespaces) {
        for ($i = 0; $i -lt $UserInput.Count; $i++) {
            $LIST_CollectUpdatedItems.Add($UserInput[$i].Replace(' ', ''))
        }
        Write-Verbose "Cleaned User Input :: [Removed Whitespaces Only]"
    }
    elseif ($ConvertToLowercase -and (-Not($RemoveWhitespaces))) {
        for ($i = 0; $i -lt $UserInput.Count; $i++) {
            $LIST_CollectUpdatedItems.Add($UserInput[$i].ToLowerInvariant())
        }
        Write-Verbose "Cleaned User Input :: [Converted To Lowercase]"
    }
    elseif ($ConvertToUppercase -and (-Not($RemoveWhitespaces))) {
        for ($i = 0; $i -lt $UserInput.Count; $i++) {
            $LIST_CollectUpdatedItems.Add($UserInput[$i].ToUpperInvariant())
        }
        Write-Verbose "Cleaned User Input :: [Converted To Uppercase]"
    }
    elseif ($Trim -and (-Not($RemoveWhitespaces -or $ConvertToLowercase -or $ConvertToUppercase))) {
        for ($i = 0; $i -lt $UserInput.Count; $i++) {
            $LIST_CollectUpdatedItems.Add($UserInput[$i].Trim())
        }
        Write-Verbose "Cleaned User Input :: [Trimmed Leading/Trailing Whitespace]"
    }

    # Apply Trim processing if specified along with other operations
    if ($Trim -and ($ConvertToLowercase -or $ConvertToUppercase -or $RemoveWhitespaces)) {
        for ($i = 0; $i -lt $LIST_CollectUpdatedItems.Count; $i++) {
            $LIST_CollectUpdatedItems[$i] = $LIST_CollectUpdatedItems[$i].Trim()
        }
        Write-Verbose "Cleaned User Input :: [Trimmed Leading/Trailing Whitespace]"
    }

    if ($RemoveNullOrEmptyItems) {
        $revised_list = [System.Collections.Generic.List[string]]::New()
        $removed_null_empty_counter = 0

        for ($i = 0; $i -lt $LIST_CollectUpdatedItems.Count; $i++) {
            if ([string]::IsNullOrEmpty($LIST_CollectUpdatedItems[$i].Trim())) {
                #Write-Verbose "String is Null or Empty, Removed From List :: [$i]"
                $removed_null_empty_counter++
            }
            else {
                $revised_list.Add($LIST_CollectUpdatedItems[$i])
            }
        }

        if ($removed_null_empty_counter -ne 0) {
            Write-Verbose "Number of null or empty items removed from list :: [$($removed_null_empty_counter)]"
        }

        if ($RemoveDuplicates) {
            $deduped = [System.Collections.Generic.List[string]]::new()
            foreach ($item in ($revised_list | Select-Object -Unique)) {
                $deduped.Add($item)
            }
            Write-Output -NoEnumerate $deduped
        }
        else {
            Write-Output -NoEnumerate $revised_list
        }
    }
    else {
        if ($RemoveDuplicates) {
            $deduped = [System.Collections.Generic.List[string]]::new()
            foreach ($item in ($LIST_CollectUpdatedItems | Select-Object -Unique)) {
                $deduped.Add($item)
            }
            Write-Output -NoEnumerate $deduped
        }
        else {
            Write-Output -NoEnumerate $LIST_CollectUpdatedItems
        }
    }
}
#EndRegion '.\Public\Update-GenericList.ps1' 188