diff --git a/.github/workflows/upn.yml b/.github/workflows/upn.yml index 8d7223e..2aa27ce 100644 --- a/.github/workflows/upn.yml +++ b/.github/workflows/upn.yml @@ -3,9 +3,9 @@ name: UPN functions on: push: branches: [master] - paths: ['Get-UniqueUPN.ps1', 'Test-UpnExist.ps1', 'Private/**', 'Tests/Upn.Tests.ps1', '.github/workflows/upn.yml'] + paths: ['Get-UniqueUPN.ps1', 'Test-UpnExist.ps1', 'Test-IsValidUpn.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'] + paths: ['Get-UniqueUPN.ps1', 'Test-UpnExist.ps1', 'Test-IsValidUpn.ps1', 'Private/**', 'Tests/Upn.Tests.ps1', '.github/workflows/upn.yml'] workflow_dispatch: permissions: diff --git a/Get-UniqueUPN.ps1 b/Get-UniqueUPN.ps1 index e54272e..452c4d5 100644 --- a/Get-UniqueUPN.ps1 +++ b/Get-UniqueUPN.ps1 @@ -1,7 +1,7 @@ #Requires -Version 7.4 . (Join-Path $PSScriptRoot 'Test-UpnExist.ps1') -. (Join-Path $PSScriptRoot 'Private/Test-ITToolBoxDnsName.ps1') +. (Join-Path $PSScriptRoot 'Test-IsValidUpn.ps1') function Get-UniqueUPN { @@ -13,6 +13,9 @@ function Get-UniqueUPN 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. + Generated candidates must satisfy Test-IsValidUpn's ASCII username and DNS/IDN + suffix policy. Normalize unsupported names explicitly; no transliteration is applied. + Requires ActiveDirectory lookup commands when invoked; script loading is portable. .PARAMETER MaxAttempts Maximum candidates to check, including the initial unsuffixed name. Defaults to 1000. .PARAMETER AdServer @@ -50,6 +53,10 @@ function Get-UniqueUPN [Alias('Server')] [ValidatePattern('^[A-Za-z0-9][A-Za-z0-9._-]*(?::[0-9]{1,5})?$')] + [ValidateScript({ + if ($_ -match ':([0-9]+)$') { [int]$Matches[1] -ge 1 -and [int]$Matches[1] -le 65535 } + else { $true } + })] [string]$ADServer, [ValidatePattern('^[^\s@\p{Cc}]*$')] @@ -73,7 +80,8 @@ function Get-UniqueUPN { $givenName = [string]$ADObject.GivenName $surname = [string]$ADObject.Surname - $middleNameValue = [string]$ADObject.MiddleName + $middleProperty = $ADObject.PSObject.Properties['MiddleName'] + $middleNameValue = if ($null -ne $middleProperty) { [string]$middleProperty.Value } else { '' } } elseif ($ADObject.PSObject.TypeNames -contains 'System.DirectoryServices.DirectoryEntry' -or $ADObject.PSObject.TypeNames -contains 'System.DirectoryServices.SearchResult') @@ -124,6 +132,11 @@ function Get-UniqueUPN $parts.Add($last) $localPart = ($parts -join $Separator).ToLowerInvariant() $suffix = $UPNSuffix.ToLowerInvariant() + $initialCandidate = '{0}@{1}' -f $localPart, $suffix + if (-not (Test-IsValidUpn -UserUpn $initialCandidate)) + { + throw 'Generated UPN does not match the supported syntax policy (ASCII username and DNS/IDN suffix). Normalize the name or separator explicitly before calling.' + } $lookup = @{ SearchScope = $SearchScope; ErrorAction = 'Stop' } if ($PSBoundParameters.ContainsKey('ADServer')) { diff --git a/README.md b/README.md index 182b54d..63a808f 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ The standalone OAuth-style request helper now follows the IT-ToolBox implementat ### 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. +`Get-UniqueUPN` and `Test-UPNExist` now use explicit forest/domain lookup scope, escaped LDAP equality filters, bounded collision checks and independent name formatting. Generated names now follow the same ASCII username and DNS/IDN suffix policy as `Test-IsValidUpn`; unsupported names require explicit normalization. `Test-IsValidUpn` is also available standalone. 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. ### Convert-EmlFile diff --git a/Test-IsValidUpn.ps1 b/Test-IsValidUpn.ps1 new file mode 100644 index 0000000..5a7102c --- /dev/null +++ b/Test-IsValidUpn.ps1 @@ -0,0 +1,48 @@ +#Requires -Version 7.4 + +. (Join-Path $PSScriptRoot 'Private/Test-ITToolBoxDnsName.ps1') + +function Test-IsValidUpn +{ + <# + .SYNOPSIS + Tests a practical UPN syntax policy without directory access. + + .DESCRIPTION + Requires one @ separator. The ASCII username starts and ends with a letter or + digit and may contain dots, underscores, hyphens and apostrophes internally; + consecutive dots are rejected. The suffix is a DNS/IDN name, including a + single-label name or long suffix. No email parsing, account existence, suffix + registration or complete AD/Entra account-creation policy is implied. + #> + + [CmdletBinding()] + [OutputType([bool])] + param ( + [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)] + [AllowNull()] + [AllowEmptyString()] + [Alias('UPN', 'ADUpn', 'UniversalPrincipalName')] + [string]$UserUpn + ) + + process + { + # Reject blank values and any whitespace/control characters that do not belong in a UPN. + if ([string]::IsNullOrWhiteSpace($UserUpn) -or $UserUpn -match '[\s\p{Cc}]') + { + return $false + } + + $parts = $UserUpn.Split('@') + + # Require exactly one @ separator and a username that starts/ends with an alphanumeric character. + if ($parts.Count -ne 2 -or $parts[0].Contains('..') -or + $parts[0] -cnotmatch '^[a-zA-Z0-9](?:[a-zA-Z0-9._''-]*[a-zA-Z0-9])?$') + { + return $false + } + + return (Test-ITToolBoxDnsName -Name $parts[1]) + } +} diff --git a/Test-UpnExist.ps1 b/Test-UpnExist.ps1 index 84f5c50..91d4fcf 100644 --- a/Test-UpnExist.ps1 +++ b/Test-UpnExist.ps1 @@ -29,6 +29,10 @@ function Test-UPNExist [Alias('Server')] [ValidatePattern('^[A-Za-z0-9][A-Za-z0-9._-]*(?::[0-9]{1,5})?$')] + [ValidateScript({ + if ($_ -match ':([0-9]+)$') { [int]$Matches[1] -ge 1 -and [int]$Matches[1] -le 65535 } + else { $true } + })] [string]$AdServer, [ValidateSet('Forest', 'Domain')] diff --git a/Tests/Upn.Tests.ps1 b/Tests/Upn.Tests.ps1 index b325311..f117329 100644 --- a/Tests/Upn.Tests.ps1 +++ b/Tests/Upn.Tests.ps1 @@ -146,3 +146,71 @@ Describe 'Directory query construction' { {Test-UPNExist -UPN 'john@example.com'} | Should -Throw '*requires Get-ADUser*' } } + +Describe 'UPN generation policy alignment' { + BeforeEach { Mock Test-UPNExist { $false } } + It 'rejects unsupported generated names before directory lookup: ' -ForEach @( + @{First='José';Last='García';Separator='.'} + @{First='.John';Last='Doe';Separator='.'} + @{First='John.';Last='Doe';Separator='.'} + @{First='John';Last='Doe';Separator='/'} + @{First='John';Last='Doe';Separator='..'} + ) { + { Get-UniqueUPN -FirstName $First -LastName $Last -Separator $Separator -UPNSuffix example.com } | Should -Throw '*supported syntax policy*' + Should -Invoke Test-UPNExist -Times 0 -Exactly + } + It 'preserves supported punctuation and IDN suffixes without transliteration' { + $candidate=Get-UniqueUPN -FirstName Anne-Marie -LastName "O'Brien" -UPNSuffix 'bücher.example' + $candidate | Should -BeExactly "anne-marie.o'brien@bücher.example" + Test-IsValidUpn $candidate | Should -BeTrue + } + It 'accepts explicitly normalized names and returns a policy-valid collision candidate' { + Mock Test-UPNExist { $UPN -ne 'jose.garcia2@example.com' } + $candidate=Get-UniqueUPN -FirstName Jose -LastName Garcia -UPNSuffix example.com + $candidate | Should -BeExactly 'jose.garcia2@example.com' + Test-IsValidUpn $candidate | Should -BeTrue + } + It 'does not require an optional ADUser middle-name property under StrictMode' { + $user=[pscustomobject]@{GivenName='Jane';Surname='Doe'} + $user.PSObject.TypeNames.Insert(0,'Microsoft.ActiveDirectory.Management.ADUser') + & { + Set-StrictMode -Version Latest + Get-UniqueUPN -ADObject $user -IncludeMiddleName -UPNSuffix example.com + } | Should -BeExactly 'jane.doe@example.com' + } +} +Describe 'UPN explicit server port validation' { + It 'rejects an invalid numeric port without a directory call: ' -ForEach @( + @{Port='0'}; @{Port='65536'}; @{Port='99999'} + ) { + Mock Get-ADUser { throw 'Unexpected directory call' } + { Test-UPNExist -UPN 'john@example.com' -SearchScope Domain -AdServer "dc.example.com:$Port" } | Should -Throw + { Get-UniqueUPN -FirstName John -LastName Doe -UPNSuffix example.com -AdServer "dc.example.com:$Port" } | Should -Throw + Should -Invoke Get-ADUser -Times 0 -Exactly + } +} + +Describe 'UPN syntax policy' { + It 'accepts supported usernames and suffixes: ' -ForEach @( + @{ Value = 'alice@example.com' }; @{ Value = 'a@localhost' } + @{ Value = 'first.last@example.technology' }; @{ Value = 'first_last@sub-domain.example' } + @{ Value = "o'brien@example.com" }; @{ Value = 'alice@bücher.example' } + ) { Test-IsValidUpn $Value | Should -BeTrue } + It 'rejects unsupported UPN syntax: ' -ForEach @( + @{ Value = $null }; @{ Value = '' }; @{ Value = ' ' } + @{ Value = 'alice' }; @{ Value = '@example.com' }; @{ Value = 'alice@' } + @{ Value = 'alice@@example.com' }; @{ Value = ' alice@example.com' } + @{ Value = 'alice@example.com ' }; @{ Value = 'a..b@example.com' } + @{ Value = '.alice@example.com' }; @{ Value = 'alice.@example.com' } + @{ Value = 'alice@bad_domain.example' }; @{ Value = 'alice@example..com' } + @{ Value = 'alice@-bad.example' }; @{ Value = 'alice@[127.0.0.1]' } + @{ Value = '"alice"@example.com' }; @{ Value = 'alice+tag@example.com' } + ) { Test-IsValidUpn $Value | Should -BeFalse } + It 'preserves aliases and returns independent pipeline results' { + Test-IsValidUpn -UPN 'alice@example.com' | Should -BeTrue + Test-IsValidUpn -ADUpn 'alice@example.com' | Should -BeTrue + Test-IsValidUpn -UniversalPrincipalName 'alice@example.com' | Should -BeTrue + $result = @('a@example.com', '', 'bad') | Test-IsValidUpn + ($result -join ',') | Should -Be 'True,False,False' + } +} diff --git a/docs/UPN.md b/docs/UPN.md index 13e30a9..e774bf3 100644 --- a/docs/UPN.md +++ b/docs/UPN.md @@ -2,7 +2,7 @@ ## 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. +PowerShell 7.4 or later. Dot-source the existing root Get-UniqueUPN.ps1 file: it loads Test-UpnExist.ps1, Test-IsValidUpn.ps1 and the required Private/ helpers. Test-UpnExist.ps1 can also be loaded alone. IT-ToolBox is not required; equivalent commands are also provided by IT-ToolBox 3.3.0. 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. @@ -45,3 +45,35 @@ https://learn.microsoft.com/en-us/powershell/module/activedirectory/get-aduser 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. + +## Consistent naming policy + +Every generated candidate must satisfy Test-IsValidUpn, using the same implementation +as IT-ToolBox: ASCII letters/digits at the username edges; dots, underscores, +hyphens and apostrophes internally; no consecutive dots; and a DNS/IDN suffix. +The initial candidate is validated before any directory call. Numeric collision +suffixes preserve that syntax policy. + +This deliberately changes older generation behavior. For example, José/García +and separators such as / or .. now cause a policy error. Normalize names according +to your organization's rules before calling, for example explicitly supplying +Jose and Garcia. No silent accent stripping or custom-map conversion is performed. +Unicode AD identities are not intrinsically invalid; this is the toolkit's +chosen generation policy. Test-UPNExist still searches literal existing identities, +including Unicode names, without imposing the generation policy on the lookup. + +An absent optional ADUser MiddleName property is treated as empty, including under +StrictMode. Explicit server ports must be within 1–65535; forest scope additionally +requires port 3268 or 3269. Existing aliases, bounded retries and scope defaults remain. + +Test-IsValidUpn can be dot-sourced alone for local cross-platform validation: + +```powershell +. ./Test-IsValidUpn.ps1 +Test-IsValidUpn 'first.last@example.com' +``` + +Keep its Private/Test-ITToolBoxDnsName.ps1 helper beside the script tree. The validator +does not need AD, perform discovery, check configured suffixes or establish uniqueness. +The Get-UniqueUPN command still performs a directory lookup: it does not claim an +unchecked generated string is unique when AD is unavailable.