From a8d47dfbf3ff6856e1acb20c50f31742a6798038 Mon Sep 17 00:00:00 2001 From: SingularVector Date: Sat, 24 Oct 2026 00:21:41 +0200 Subject: [PATCH] Correct UPN generation and make directory lookup scope explicit --- .github/workflows/upn.yml | 38 +++ Get-UniqueUPN.ps1 | 232 ++++++++---------- .../ConvertTo-ITToolBoxLdapFilterValue.ps1 | 23 ++ README.md | 4 + Test-UpnExist.ps1 | 138 ++++++++--- Tests/Upn.Tests.ps1 | 148 +++++++++++ docs/UPN.md | 47 ++++ 7 files changed, 466 insertions(+), 164 deletions(-) create mode 100644 .github/workflows/upn.yml create mode 100644 Private/ConvertTo-ITToolBoxLdapFilterValue.ps1 create mode 100644 Tests/Upn.Tests.ps1 create mode 100644 docs/UPN.md diff --git a/.github/workflows/upn.yml b/.github/workflows/upn.yml new file mode 100644 index 0000000..0935c07 --- /dev/null +++ b/.github/workflows/upn.yml @@ -0,0 +1,38 @@ +name: UPN functions + +on: + push: + branches: [master] + paths: ['Get-UniqueUPN.ps1', 'Test-UpnExist.ps1', 'Private/**', 'Tests/Upn.Tests.ps1', '.github/workflows/upn.yml'] + pull_request: + paths: ['Get-UniqueUPN.ps1', 'Test-UpnExist.ps1', 'Private/**', 'Tests/Upn.Tests.ps1', '.github/workflows/upn.yml'] + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + defaults: + run: + shell: pwsh + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Install and run Pester + run: | + $ErrorActionPreference = 'Stop' + Install-Module Pester -RequiredVersion 5.7.1 -Scope CurrentUser -Force -Repository PSGallery + Import-Module Pester -RequiredVersion 5.7.1 -Force + $configuration = New-PesterConfiguration + $configuration.Run.Path = './Tests' + $configuration.Run.Exit = $true + $configuration.Output.Verbosity = 'Detailed' + Invoke-Pester -Configuration $configuration diff --git a/Get-UniqueUPN.ps1 b/Get-UniqueUPN.ps1 index 58a9ece..e54272e 100644 --- a/Get-UniqueUPN.ps1 +++ b/Get-UniqueUPN.ps1 @@ -1,169 +1,143 @@ +#Requires -Version 7.4 + +. (Join-Path $PSScriptRoot 'Test-UpnExist.ps1') +. (Join-Path $PSScriptRoot 'Private/Test-ITToolBoxDnsName.ps1') + function Get-UniqueUPN { -<# + <# .SYNOPSIS - Cmdlet will generate a forest wide unique UPN. - + Finds an available user principal name in the selected directory scope. .DESCRIPTION - Cmdlet will generate a forest wide unique UPN according to generation rules - defined by the user. - - Cmdlet accept different types of objects to generate the UPN to allow greater flexibility - - ADObject - For example and object from Get-AdUser cmdlet - Strings - Representing First Name, Last Name etc. - DirectoryService Objects - For example when using native .Net methods to retrieve the identity - - .PARAMETER ADObject - An ADObject for example output of the Get-ADUser cmdlet - - .PARAMETER FirstName - A string representing the First Name of the user - - .PARAMETER LastName - A string representing the Last Name of the user - - .PARAMETER MiddleName - A string representing the Middle Name of the user, parameter is optional. - - .PARAMETER UPNSuffix - A string representing the UPN suffix to be used. - - .PARAMETER FirstNameFormat - A string representing the format to be for the First Name part of the UPN. - - .PARAMETER LastNameFormat - A string representing the format to be for the Last Name part of the UPN. - - .PARAMETER IncludeMiddleName - When paramenter is specified user Middle Name, if present, will be included in the UPN generation process. - - .PARAMETER ADServer - A string representing the name of the AD Domain Controller that will be used to query Active Directory. - - If no server is specified the closest Global Catalog will be automatically selected. - - .PARAMETER Separator - A string representing the separator to be used between UPN parts, defaults to a '.'. -#> - + Accepts explicit name strings or ADUser, DirectoryEntry and SearchResult + inputs. First and last name formatting are independent. Collisions append + an integer to the complete local part; the search has a bounded attempt count. + Availability is a preflight check, not a reservation or atomic uniqueness guarantee. + .PARAMETER MaxAttempts + Maximum candidates to check, including the initial unsuffixed name. Defaults to 1000. + .PARAMETER AdServer + Directory hostname, optionally with port. See Test-UPNExist for scope behaviour. + #> [CmdletBinding(DefaultParameterSetName = 'Strings')] - param - ( - [Parameter(ParameterSetName = 'ADObject', - Mandatory = $true)] + [OutputType([string])] + param ( + [Parameter(ParameterSetName = 'ADObject', Mandatory)] + [ValidateNotNull()] [object]$ADObject, - [Parameter(ParameterSetName = 'Strings', - Mandatory = $true)] + + [Parameter(ParameterSetName = 'Strings', Mandatory)] [ValidateNotNullOrEmpty()] [string]$FirstName, - [Parameter(ParameterSetName = 'Strings', - Mandatory = $true)] + + [Parameter(ParameterSetName = 'Strings', Mandatory)] [ValidateNotNullOrEmpty()] [string]$LastName, + [Parameter(ParameterSetName = 'Strings')] - [ValidateNotNullOrEmpty()] [string]$MiddleName, - [Parameter(Mandatory = $true)] + + [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$UPNSuffix, - [ValidateSet('FullName', 'FirstLetter', IgnoreCase = $true)] - [ValidateNotNullOrEmpty()] - [string]$FirstNameFormat = 'Full', - [ValidateSet('FullName', 'FirstLetter', IgnoreCase = $true)] - [ValidateNotNullOrEmpty()] + + [ValidateSet('FullName', 'FirstLetter')] + [string]$FirstNameFormat = 'FullName', + + [ValidateSet('FullName', 'FirstLetter')] [string]$LastNameFormat = 'FullName', + [switch]$IncludeMiddleName, - [ValidateNotNullOrEmpty()] + + [Alias('Server')] + [ValidatePattern('^[A-Za-z0-9][A-Za-z0-9._-]*(?::[0-9]{1,5})?$')] [string]$ADServer, - [ValidateNotNullOrEmpty()] - [string]$Separator = '.' + + [ValidatePattern('^[^\s@\p{Cc}]*$')] + [AllowEmptyString()] + [string]$Separator = '.', + + [ValidateSet('Forest', 'Domain')] + [string]$SearchScope = 'Forest', + + [ValidateRange(1, 100000)] + [int]$MaxAttempts = 1000 ) + $givenName = $FirstName + $surname = $LastName + $middleNameValue = $MiddleName + if ($PSCmdlet.ParameterSetName -eq 'ADObject') { - switch ($ADObject.GetType().FullName) + if ($ADObject.PSObject.TypeNames -contains 'Microsoft.ActiveDirectory.Management.ADUser') { - 'Microsoft.ActiveDirectory.Management.ADUser' - { - [string]$firstName = $ADObject.GivenName - [string]$lastName = $ADObject.Surname - [string]$middleName = $ADObject.MiddleName - - break - } - 'System.DirectoryServices.DirectoryEntry' - { - [string]$firstName = $ADObject.Properties['givenName'][0] - [string]$lastName = $ADObject.Properties['sn'][0] - [string]$middleName = $ADObject.Properties['middleName'][0] - - break - } - 'System.DirectoryServices.SearchResult' - { - [string]$firstName = $ADObject.Properties['givenName'][0] - [string]$lastName = $ADObject.Properties['sn'][0] - [string]$middleName = $ADObject.Properties['middleName'][0] - - break - } - default - { - throw "Unsupported AD object type: $($ADObject.GetType().FullName)" - } + $givenName = [string]$ADObject.GivenName + $surname = [string]$ADObject.Surname + $middleNameValue = [string]$ADObject.MiddleName } - } - else - { - [string]$firstName = $FirstName - [string]$lastName = $LastName - [string]$middleName = $MiddleName - } - - # Format first name - $firstName = switch ($FirstNameFormat) - { - 'FullName' + elseif ($ADObject.PSObject.TypeNames -contains 'System.DirectoryServices.DirectoryEntry' -or + $ADObject.PSObject.TypeNames -contains 'System.DirectoryServices.SearchResult') { - $firstName + $givenName = [string](@($ADObject.Properties['givenName'])[0]) + $surname = [string](@($ADObject.Properties['sn'])[0]) + $middleNameValue = [string](@($ADObject.Properties['middleName'])[0]) } - 'FirstLetter' + else { - $firstName.Substring(0, 1) + throw "Unsupported AD object type: $($ADObject.GetType().FullName)" } } - # Format last name - $LastName = switch ($FirstNameFormat) + foreach ($name in @($givenName, $surname)) { - 'FullName' + if ([string]::IsNullOrWhiteSpace($name) -or $name.Trim() -match '[\s@\p{Cc}]') { - $LastName + throw 'First and last names must contain nonblank components without embedded whitespace, controls or @. Normalize compound names before calling.' } - 'FirstLetter' + } + if (-not (Test-ITToolBoxDnsName -Name $UPNSuffix)) + { + throw 'UPNSuffix must be a DNS/IDN name without whitespace or a trailing root dot.' + } + + $first = $givenName.Trim() + $last = $surname.Trim() + if ($FirstNameFormat -eq 'FirstLetter') + { + $first = [System.Globalization.StringInfo]::GetNextTextElement($first) + } + if ($LastNameFormat -eq 'FirstLetter') + { + $last = [System.Globalization.StringInfo]::GetNextTextElement($last) + } + $parts = [System.Collections.Generic.List[string]]::new() + $parts.Add($first) + if ($IncludeMiddleName -and -not [string]::IsNullOrWhiteSpace($middleNameValue)) + { + $middle = $middleNameValue.Trim() + if ($middle -match '[\s@\p{Cc}]') { - $LastName.Substring(0, 1) + throw 'Middle name must not contain embedded whitespace, controls or @.' } + $parts.Add($middle) } - - # Use middle name - [string]$middleNamePart = if ($IncludeMiddleName -and $MiddleName) + $parts.Add($last) + $localPart = ($parts -join $Separator).ToLowerInvariant() + $suffix = $UPNSuffix.ToLowerInvariant() + $lookup = @{ SearchScope = $SearchScope; ErrorAction = 'Stop' } + if ($PSBoundParameters.ContainsKey('ADServer')) { - '{0}{1}' -f $Separator, $MiddleName + $lookup.AdServer = $ADServer } - # Setup required attributes - [string]$baseUPN = ('{0}{1}{2}{3}@{4}' -f $FirstName, $middleNamePart, $Separator, $LastName, $UPNSuffix).ToLower() - [string]$uniqueUPN = $baseUPN - [int]$counter = 1 - - while (Test-UPNExist -UPN $uniqueUPN -Server $ADServer) + for ($attempt = 0; $attempt -lt $MaxAttempts; $attempt++) { - $uniqueUPN = '{0}{1}@{2}' -f ($baseUPN.Split('@')[0]), $counter, $UPNSuffix - - $counter++ + $counter = if ($attempt -eq 0) { '' } else { [string]$attempt } + $candidate = '{0}{1}@{2}' -f $localPart, $counter, $suffix + if (-not (Test-UPNExist -UPN $candidate @lookup)) + { + return $candidate + } } - - return $uniqueUPN -} \ No newline at end of file + throw "No available UPN found after $MaxAttempts candidates." +} diff --git a/Private/ConvertTo-ITToolBoxLdapFilterValue.ps1 b/Private/ConvertTo-ITToolBoxLdapFilterValue.ps1 new file mode 100644 index 0000000..4076861 --- /dev/null +++ b/Private/ConvertTo-ITToolBoxLdapFilterValue.ps1 @@ -0,0 +1,23 @@ +function ConvertTo-ITToolBoxLdapFilterValue +{ + param ([string]$Value) + + $builder = [System.Text.StringBuilder]::new() + + # LDAP filter values escape control bytes and special characters before the value is returned. + foreach ($byte in [System.Text.UTF8Encoding]::new($false, $true).GetBytes($Value)) + { + # These bytes are reserved by the filter syntax or are outside the printable ASCII range. + if ($byte -in @(0, 40, 41, 42, 92) -or $byte -ge 128) + { + [void]$builder.Append('\').Append($byte.ToString('x2')) + } + else + { + # Keep printable characters as-is so the output stays readable. + [void]$builder.Append([char]$byte) + } + } + + return $builder.ToString() +} diff --git a/README.md b/README.md index de6c587..b213d05 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,10 @@ The standalone OAuth-style request helper now follows the IT-ToolBox implementat `New-StringConversion`, `Test-IsEmail`, `Test-IsValidDn` and `Get-ElapsedTime` now use the IT-ToolBox implementations with a PowerShell 7.4 baseline. Dot-source the existing root files as before, keeping `Private/` alongside them. No IT-ToolBox installation is required. See [alignment and migration notes](./docs/ToolboxAlignment.md) and [tests](./Tests/ToolboxAlignment.Tests.ps1). +### UPN generation and directory lookup + +`Get-UniqueUPN` and `Test-UPNExist` now use explicit forest/domain lookup scope, escaped LDAP equality filters, bounded collision checks and independent name formatting. They require PowerShell 7.4; actual lookups additionally require the ActiveDirectory module and directory connectivity. See [usage and compatibility notes](./docs/UPN.md). These are availability checks, not atomic reservations. + ## Standalone functions The repository also contains standalone utilities including: diff --git a/Test-UpnExist.ps1 b/Test-UpnExist.ps1 index d1d8775..84f5c50 100644 --- a/Test-UpnExist.ps1 +++ b/Test-UpnExist.ps1 @@ -1,52 +1,120 @@ +#Requires -Version 7.4 + +. (Join-Path $PSScriptRoot 'Private/ConvertTo-ITToolBoxLdapFilterValue.ps1') + function Test-UPNExist { -<# + <# .SYNOPSIS - Cmdlet will check if a given UPN exists in the forest. - + Tests whether a user principal name exists in a forest or domain. .DESCRIPTION - Cmdlet is a diagnostic tool to check if a given UPN is already assigned to a user in the forest. - - .PARAMETER UPN - A string representing the UPN to check for uniqueness. - + Requires the ActiveDirectory module commands at invocation, not script load. + Forest searches use a Global Catalog and an empty search base to search all + partitions. Domain searches use the server's default naming context. + Query and discovery errors terminate; they are not treated as an unused UPN. .PARAMETER AdServer - A string representing the name of the domain controller to be used for the check, if parameter - is not specified the closest Global Catalog is used. - - .EXAMPLE - PS C:\> Test-UPNExist -UPN 'John.Doe@example.com' -#> - + Server hostname, optionally with a port. Server is retained as an alias. + Forest scope accepts GC ports 3268 or 3269 and defaults to 3268. + The supplied host must actually provide a Global Catalog service. + .PARAMETER SearchScope + Forest (default) or Domain. This selects the directory coverage, not an LDAP + Base/OneLevel/Subtree option; the LDAP search itself uses Subtree. + #> [CmdletBinding()] - param - ( - [Parameter(Mandatory = $true)] + [OutputType([bool])] + param ( + [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$UPN, - [ValidateNotNullOrEmpty()] - [string]$AdServer + + [Alias('Server')] + [ValidatePattern('^[A-Za-z0-9][A-Za-z0-9._-]*(?::[0-9]{1,5})?$')] + [string]$AdServer, + + [ValidateSet('Forest', 'Domain')] + [string]$SearchScope = 'Forest' ) - if ([string]::IsNullOrEmpty($AdServer) -eq $true) + $ErrorActionPreference = 'Stop' + if ([string]::IsNullOrWhiteSpace($UPN)) { - $adForest = [System.DirectoryServices.ActiveDirectory.Forest]::GetCurrentForest() - [string]$ldapPath = '{0}{1}' -f 'GC://', $($adForest.FindGlobalCatalog().Name) + throw [ArgumentException]::new('UPN must not be blank.') } - else + + if (-not (Get-Command Get-ADUser -ErrorAction SilentlyContinue)) { - [string]$ldapPath = '{0}{1}' -f 'LDAP://', $AdServer + throw [InvalidOperationException]::new('Test-UPNExist requires Get-ADUser from the ActiveDirectory module and a reachable directory.') } - # Instantiate required objects and run query - $adDomain = New-Object System.DirectoryServices.DirectoryEntry($ldapPath) - $adSearcher = New-Object System.DirectoryServices.DirectorySearcher($adDomain) - $adSearcher.SearchScope = 'Subtree' - $adSearcher.PageSize = 1000 - $adSearcher.Filter = "(&(objectCategory=person)(userPrincipalName=$UPN))" - [void]($adSearcher.PropertiesToLoad.Add("userPrincipalName")) + $query = @{ + LDAPFilter = '(&(objectCategory=person)(objectClass=user)(userPrincipalName={0}))' -f (ConvertTo-ITToolBoxLdapFilterValue -Value $UPN) + SearchScope = 'Subtree' + ResultSetSize = 1 + ErrorAction = 'Stop' + } + + if ($SearchScope -eq 'Forest') + { + $server = $AdServer + if (-not $PSBoundParameters.ContainsKey('AdServer')) + { + if (-not (Get-Command Get-ADDomainController -ErrorAction SilentlyContinue)) + { + throw 'Forest discovery requires Get-ADDomainController from the ActiveDirectory module.' + } + $controller = Get-ADDomainController -Discover -Service GlobalCatalog -ErrorAction Stop + $server = [string]$controller.HostName + if ([string]::IsNullOrWhiteSpace($server)) + { + throw 'Global Catalog discovery returned no hostname.' + } + } + + if ($server -match ':([0-9]+)$') + { + if ($Matches[1] -notin @('3268', '3269')) + { + throw 'Forest scope requires a Global Catalog port (3268 or 3269).' + } + } + else + { + $server += ':3268' + } - [array]$searchResult = $adSearcher.FindOne() + $query.Server = $server + $query.SearchBase = '' + } + else + { + if (-not (Get-Command Get-ADRootDSE -ErrorAction SilentlyContinue)) + { + throw 'Domain scope requires Get-ADRootDSE from the ActiveDirectory module.' + } + $rootQuery = @{ ErrorAction = 'Stop' } + if ($PSBoundParameters.ContainsKey('AdServer')) + { + $rootQuery.Server = $AdServer + $query.Server = $AdServer + } + $root = Get-ADRootDSE @rootQuery + $base = [string]$root.DefaultNamingContext + if ([string]::IsNullOrWhiteSpace($base)) + { + throw 'Directory discovery returned no default naming context.' + } + # Pin the user search to the same controller used to discover the domain. + if (-not $query.ContainsKey('Server')) + { + if ([string]::IsNullOrWhiteSpace([string]$root.DnsHostName)) + { + throw 'Directory discovery returned no controller hostname.' + } + $query.Server = [string]$root.DnsHostName + } + $query.SearchBase = $base + } - return $null -ne $searchResult -} \ No newline at end of file + $found = @(Get-ADUser @query) + return ($found.Count -gt 0) +} diff --git a/Tests/Upn.Tests.ps1 b/Tests/Upn.Tests.ps1 new file mode 100644 index 0000000..b325311 --- /dev/null +++ b/Tests/Upn.Tests.ps1 @@ -0,0 +1,148 @@ +BeforeAll { + . (Join-Path $PSScriptRoot '../Get-UniqueUPN.ps1') + # Offline seams: accidental unmocked directory calls fail, never contact AD. + function Get-ADUser { [CmdletBinding()] param($LDAPFilter,$SearchScope,$SearchBase,$Server,$ResultSetSize) throw 'Unexpected directory call' } + function Get-ADRootDSE { [CmdletBinding()] param($Server) throw 'Unexpected root lookup' } + function Get-ADDomainController { [CmdletBinding()] param([switch]$Discover,$Service) throw 'Unexpected discovery' } +} + +Describe 'UPN generation' { + BeforeEach { Mock Test-UPNExist { $false } } + It 'uses full names by default' { + Get-UniqueUPN -FirstName John -LastName Doe -UPNSuffix example.com | Should -BeExactly 'john.doe@example.com' + } + It 'formats first and last names independently: /' -ForEach @( + @{ First='FullName';Last='FirstLetter';Expected='john.d@example.com' } + @{ First='FirstLetter';Last='FullName';Expected='j.doe@example.com' } + @{ First='FirstLetter';Last='FirstLetter';Expected='j.d@example.com' } + ) { + Get-UniqueUPN -FirstName John -LastName Doe -UPNSuffix example.com -FirstNameFormat $First -LastNameFormat $Last | Should -BeExactly $Expected + } + It 'includes an available middle name and permits an empty separator' { + Get-UniqueUPN -FirstName John -LastName Doe -MiddleName A -IncludeMiddleName -UPNSuffix example.com | Should -BeExactly 'john.a.doe@example.com' + Get-UniqueUPN -FirstName John -LastName Doe -Separator '' -UPNSuffix example.com | Should -BeExactly 'johndoe@example.com' + Get-UniqueUPN -FirstName John -LastName Doe -IncludeMiddleName -UPNSuffix example.com | Should -BeExactly 'john.doe@example.com' + } + It 'forwards server and scope using the real dependency parameter name' { + Get-UniqueUPN -FirstName John -LastName Doe -UPNSuffix example.com -Server dc.example.com -SearchScope Domain | Out-Null + Should -Invoke Test-UPNExist -Times 1 -Exactly -ParameterFilter { $AdServer -eq 'dc.example.com' -and $SearchScope -eq 'Domain' } + } + It 'appends counters until an unused candidate is found' { + Mock Test-UPNExist { $UPN -ne 'john.doe2@example.com' } + Get-UniqueUPN -FirstName John -LastName Doe -UPNSuffix example.com | Should -BeExactly 'john.doe2@example.com' + Should -Invoke Test-UPNExist -Times 3 -Exactly + } + It 'bounds retries and includes the unsuffixed candidate in MaxAttempts' { + Mock Test-UPNExist { $true } + { Get-UniqueUPN -FirstName John -LastName Doe -UPNSuffix example.com -MaxAttempts 2 } | Should -Throw '*after 2 candidates*' + Should -Invoke Test-UPNExist -Times 2 -Exactly + } + It 'propagates directory failures instead of returning availability' { + Mock Test-UPNExist { throw 'Directory offline' } + { Get-UniqueUPN -FirstName John -LastName Doe -UPNSuffix example.com } | Should -Throw '*Directory offline*' + } + It 'extracts an ADUser-shaped object' { + $user=[pscustomobject]@{GivenName='Jane';Surname='Doe';MiddleName='A'} + $user.PSObject.TypeNames.Insert(0,'Microsoft.ActiveDirectory.Management.ADUser') + Get-UniqueUPN -ADObject $user -UPNSuffix example.com -IncludeMiddleName | Should -BeExactly 'jane.a.doe@example.com' + } + It 'extracts directory property collections: ' -ForEach @( + @{Type='System.DirectoryServices.DirectoryEntry'} + @{Type='System.DirectoryServices.SearchResult'} + ) { + $user=[pscustomobject]@{Properties=@{givenName=@('Jane');sn=@('Doe');middleName=@('A')}} + $user.PSObject.TypeNames.Insert(0,$Type) + Get-UniqueUPN -ADObject $user -UPNSuffix example.com -IncludeMiddleName | Should -BeExactly 'jane.a.doe@example.com' + } + It 'rejects incomplete or unsupported objects before any lookup' { + {Get-UniqueUPN -ADObject ([pscustomobject]@{}) -UPNSuffix example.com} | Should -Throw '*Unsupported*' + $user=[pscustomobject]@{GivenName='Jane';Surname=$null} + $user.PSObject.TypeNames.Insert(0,'Microsoft.ActiveDirectory.Management.ADUser') + {Get-UniqueUPN -ADObject $user -UPNSuffix example.com} | Should -Throw '*nonblank*' + Should -Invoke Test-UPNExist -Times 0 -Exactly + } + It 'rejects invalid input before any lookup' { + {Get-UniqueUPN -FirstName ' ' -LastName Doe -UPNSuffix example.com} | Should -Throw + {Get-UniqueUPN -FirstName 'John Paul' -LastName Doe -UPNSuffix example.com} | Should -Throw + {Get-UniqueUPN -FirstName John -LastName Doe -UPNSuffix 'bad suffix'} | Should -Throw + {Get-UniqueUPN -FirstName John -LastName Doe -UPNSuffix example.com -Separator '@'} | Should -Throw + {Get-UniqueUPN -FirstName John -LastName Doe -UPNSuffix example.com -MaxAttempts 0} | Should -Throw + Should -Invoke Test-UPNExist -Times 0 -Exactly + } + It 'lowercases invariantly under Turkish culture' { + $old=[System.Globalization.CultureInfo]::CurrentCulture + try { + [System.Globalization.CultureInfo]::CurrentCulture=[System.Globalization.CultureInfo]::new('tr-TR') + Get-UniqueUPN -FirstName IVAN -LastName DOE -UPNSuffix EXAMPLE.COM | Should -BeExactly 'ivan.doe@example.com' + } finally { [System.Globalization.CultureInfo]::CurrentCulture=$old } + } +} + +Describe 'Directory query construction' { + BeforeEach { + Mock Get-ADUser { } + Mock Get-ADDomainController { [pscustomobject]@{HostName='gc.example.com'} } + Mock Get-ADRootDSE { [pscustomobject]@{DefaultNamingContext='DC=example,DC=com';DnsHostName='dc.example.com'} } + } + It 'runs the real generator and lookup together with mocked AD commands' { + Get-UniqueUPN -FirstName John -LastName Doe -UPNSuffix example.com -AdServer gc.example.com | Should -BeExactly 'john.doe@example.com' + Should -Invoke Get-ADUser -Times 1 -Exactly -ParameterFilter { $Server -eq 'gc.example.com:3268' -and $LDAPFilter -like '*john.doe@example.com*' } + } + It 'searches every GC partition by default' { + Test-UPNExist -UPN 'john@example.com' | Should -BeFalse + Should -Invoke Get-ADUser -Times 1 -Exactly -ParameterFilter { + $Server -eq 'gc.example.com:3268' -and $SearchBase -ceq '' -and $SearchScope -eq 'Subtree' -and $ResultSetSize -eq 1 -and + $LDAPFilter -eq '(&(objectCategory=person)(objectClass=user)(userPrincipalName=john@example.com))' + } + } + It 'accepts the Server alias without discovery and uses GC coverage' { + Test-UPNExist -UPN 'john@example.com' -Server gc.other.example | Should -BeFalse + Should -Invoke Get-ADDomainController -Times 0 -Exactly + Should -Invoke Get-ADUser -Times 1 -Exactly -ParameterFilter { $Server -eq 'gc.other.example:3268' -and $SearchBase -ceq '' } + } + It 'preserves an explicit secure Global Catalog port' { + Test-UPNExist -UPN 'john@example.com' -AdServer gc.example.com:3269 | Out-Null + Should -Invoke Get-ADUser -Times 1 -Exactly -ParameterFilter { $Server -eq 'gc.example.com:3269' } + } + It 'rejects a non-GC port in forest scope' { + {Test-UPNExist -UPN 'john@example.com' -AdServer dc.example.com:389} | Should -Throw '*Global Catalog port*' + Should -Invoke Get-ADUser -Times 0 -Exactly + } + It 'uses only the selected domain naming context' { + Test-UPNExist -UPN 'john@example.com' -SearchScope Domain -AdServer dc.other.example | Out-Null + Should -Invoke Get-ADRootDSE -Times 1 -Exactly -ParameterFilter {$Server -eq 'dc.other.example'} + Should -Invoke Get-ADUser -Times 1 -Exactly -ParameterFilter {$Server -eq 'dc.other.example' -and $SearchBase -eq 'DC=example,DC=com'} + } + It 'pins default domain lookup to the RootDSE controller' { + Test-UPNExist -UPN 'john@example.com' -SearchScope Domain | Out-Null + Should -Invoke Get-ADUser -Times 1 -Exactly -ParameterFilter {$Server -eq 'dc.example.com' -and $SearchBase -eq 'DC=example,DC=com'} + } + It 'returns exactly one Boolean when a user matches' { + Mock Get-ADUser {[pscustomobject]@{UserPrincipalName='john@example.com'}} + $result=@(Test-UPNExist -UPN 'john@example.com') + $result.Count | Should -Be 1 + $result[0] | Should -BeOfType ([bool]) + $result[0] | Should -BeTrue + } + It 'escapes literal wildcard, brackets, backslash, NUL and UTF-8 bytes' { + $inputUpn="a*(b)\$([char]0)é@example.com" + Test-UPNExist -UPN $inputUpn -AdServer gc.example.com | Out-Null + Should -Invoke Get-ADUser -Times 1 -Exactly -ParameterFilter { + $LDAPFilter -eq '(&(objectCategory=person)(objectClass=user)(userPrincipalName=a\2a\28b\29\5c\00\c3\a9@example.com))' + } + } + It 'propagates directory errors' { + Mock Get-ADUser {throw 'Access denied'} + {Test-UPNExist -UPN 'john@example.com'} | Should -Throw '*Access denied*' + } + It 'rejects blank discovery and domain results' { + Mock Get-ADDomainController {[pscustomobject]@{HostName=''}} + {Test-UPNExist -UPN 'john@example.com'} | Should -Throw '*no hostname*' + Mock Get-ADRootDSE {[pscustomobject]@{DefaultNamingContext=''}} + {Test-UPNExist -UPN 'john@example.com' -SearchScope Domain} | Should -Throw '*no default naming context*' + } + It 'rejects missing ActiveDirectory commands clearly' { + Mock Get-Command { $null } -ParameterFilter {$Name -eq 'Get-ADUser'} + {Test-UPNExist -UPN 'john@example.com'} | Should -Throw '*requires Get-ADUser*' + } +} diff --git a/docs/UPN.md b/docs/UPN.md new file mode 100644 index 0000000..13e30a9 --- /dev/null +++ b/docs/UPN.md @@ -0,0 +1,47 @@ +# UPN availability and generation + +## Requirements and loading + +PowerShell 7.4 or later. Dot-source the existing root Get-UniqueUPN.ps1 file: it loads Test-UpnExist.ps1 and the required Private/ helpers. Test-UpnExist.ps1 can also be loaded alone. IT-ToolBox is not required or changed. Keep these files together when copying them. + +Actual lookup uses Get-ADUser from the ActiveDirectory module (for example, an appropriately configured Windows management host with RSAT). Forest discovery also needs Get-ADDomainController; Domain scope needs Get-ADRootDSE. Dependencies are checked at invocation, so scripts load and offline tests run without the AD module. This replaces the historical System.DirectoryServices query and is a deliberate new dependency. Live operation from macOS/Linux is not asserted by the offline tests. + +## Generation + +```powershell +. ./Get-UniqueUPN.ps1 +Get-UniqueUPN -FirstName John -LastName Doe -UPNSuffix example.com -ADServer gc.example.com +# john.doe@example.com, if no matching user is found + +Get-UniqueUPN -FirstName John -LastName Doe -UPNSuffix example.com -FirstNameFormat FirstLetter -LastNameFormat FullName -ADServer gc.example.com +# j.doe@example.com, if available +``` + +FullName is the corrected default. Each formatting option applies independently. IncludeMiddleName inserts the supplied/extracted middle name when nonblank. Outer name whitespace is trimmed. Embedded whitespace, controls and @ are rejected in name components; normalize compound names to your organization's policy before calling. Separators may be empty but cannot contain whitespace, controls or @. The suffix must pass IT-ToolBox's DNS/IDN syntax policy; syntax validation does not check registered AD UPN suffixes. + +ADObject accepts ADUser, DirectoryEntry and SearchResult inputs as before. Ensure required name attributes are loaded; missing first/last names terminate before querying. The tests use representative typed objects, not a live directory. Casing uses ToLowerInvariant; initials use the first .NET text element. + +Collisions try localpart@suffix, localpart1@suffix, localpart2@suffix, and so on. MaxAttempts defaults to 1000 and includes the initial unsuffixed candidate. Exhaustion throws. The generator calls the public lookup for each candidate, forwarding server and scope. Supply a specific controller to keep candidate checks on a chosen server and avoid repeated discovery. Name normalization and AD provisioning policy remain caller responsibilities. + +## Lookup scope + +Forest is the default. An explicit hostname selects that host on Global Catalog port 3268, or an explicit 3268/3269 port is retained. The host must actually provide the requested GC service; failure is not treated as absence. An empty SearchBase searches all GC partitions. Without a server, discover a Global Catalog in the current forest. + +```powershell +Test-UPNExist -UPN 'john@example.com' -AdServer gc.example.com -SearchScope Forest +Test-UPNExist -UPN 'john@example.com' -Server gc.example.com:3269 +Test-UPNExist -UPN 'john@example.com' -AdServer dc.example.com -SearchScope Domain +``` + +Server is a compatibility alias for AdServer on both functions. Domain scope uses the selected controller's DefaultNamingContext, with a subtree search. When no server is supplied, pin the user query to the RootDSE controller used to discover that naming context. Domain coverage must not be described as forest-wide. Hostname/optional-port strings are supported; LDAP URLs, arbitrary search roots and IPv6 literals are not accepted by this parameter contract. + +The internal Get-ADUser query uses an escaped LDAP userPrincipalName equality filter, restricts results to person/user objects, limits results to one, and returns one Boolean. AD command/discovery failures terminate. LDAP escaping reuses IT-ToolBox's ConvertTo-ITToolBoxLdapFilterValue implementation unchanged. It is filter-value escaping, not DN escaping. + +Microsoft documents the empty SearchBase / Global Catalog behavior here: +https://learn.microsoft.com/en-us/powershell/module/activedirectory/get-aduser + +## Limits and production validation + +An unused UPN is a point-in-time observation, not a reservation. Replication delays, concurrent provisioning and scoped directory permissions affect what can be observed. Handle collision errors in account creation and retry according to your provisioning workflow. Domain scope only checks that domain; alternate credentials and custom search roots are not added by this patch. + +Tests mock AD commands and validate request assembly, naming, errors and output contracts. Before production, check with a test forest: known-present/absent users, collisions, two domains, explicit GC and domain controllers, discovery, port 3269 if used, permissions and connectivity failures. Perform provisioning validation only with approved test accounts; the functions themselves do not create or modify users.