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\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