Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/upn.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
17 changes: 15 additions & 2 deletions Get-UniqueUPN.ps1
Original file line number Diff line number Diff line change
@@ -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
{
Expand All @@ -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
Expand Down Expand Up @@ -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}]*$')]
Expand All @@ -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')
Expand Down Expand Up @@ -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'))
{
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
48 changes: 48 additions & 0 deletions Test-IsValidUpn.ps1
Original file line number Diff line number Diff line change
@@ -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])
}
}
4 changes: 4 additions & 0 deletions Test-UpnExist.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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')]
Expand Down
68 changes: 68 additions & 0 deletions Tests/Upn.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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: <First>' -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: <Port>' -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: <Value>' -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: <Value>' -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'
}
}
34 changes: 33 additions & 1 deletion docs/UPN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Loading