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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# IT-ToolBox - Change History

## 3.3.0 - 2026-10-24

- Port Get-UniqueUPN and Test-UPNExist, bringing the supported API to 33 commands while keeping AD dependencies optional at import.
- Validate generated candidates against Test-IsValidUpn before directory lookup; reject unsupported names and separators without silent transliteration.
- Preserve explicit forest/Global Catalog and domain coverage, escaped LDAP equality filters, bounded collision attempts and propagated query failures.
- Handle an absent optional ADUser middle-name property under StrictMode and validate explicit server ports within 1–65535.
- Add offline query-construction and policy tests; document live AD validation boundaries and the preflight-only uniqueness guarantee.

## 3.2.0 - 2026-10-24

- Port Test-IsRegistryKey from PowerShell-Functions, bringing the supported API to 31 commands.
Expand Down
6 changes: 4 additions & 2 deletions IT-ToolBox.psd1
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
@{
RootModule = 'IT-ToolBox.psm1'
ModuleVersion = '3.2.0'
ModuleVersion = '3.3.0'
GUID = '02c0c748-00ba-4e8f-b8ce-6aa7e4cefe08'
Author = 'Daniele Catanesi - SingularVector'
CompanyName = 'Community'
Expand Down Expand Up @@ -30,6 +30,8 @@
'Test-RegistryValue'
'Test-IsValidDn'
'Test-IsValidUpn'
'Test-UPNExist'
'Get-UniqueUPN'
'Get-ReportChain'
'New-StringEncryption'
'New-StringDecryption'
Expand All @@ -46,7 +48,7 @@
AliasesToExport = @()
PrivateData = @{
PSData = @{
ReleaseNotes = '3.2.0: Add Windows-only Test-IsRegistryKey; share registry path validation and key access with Test-RegistryValue. Exports 31 commands with no new dependencies. See CHANGELOG.md.'
ReleaseNotes = '3.3.0: Add Get-UniqueUPN and Test-UPNExist with explicit AD search scope and consistent UPN naming policy. Exports 33 commands; ActiveDirectory remains optional at import. See CHANGELOG.md.'
Tags = @('Infrastructure', 'Automation', 'Logging')
LicenseUri = 'https://github.com/SingularVector/IT-ToolBox/blob/master/LICENSE'
ProjectUri = 'https://github.com/SingularVector/IT-ToolBox'
Expand Down
2 changes: 2 additions & 0 deletions IT-ToolBox.psm1
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ Export-ModuleMember -Function @(
'Test-RegistryValue'
'Test-IsValidDn'
'Test-IsValidUpn'
'Test-UPNExist'
'Get-UniqueUPN'
'Get-ReportChain'
'New-StringEncryption'
'New-StringDecryption'
Expand Down
151 changes: 151 additions & 0 deletions Public/Get-UniqueUPN.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
function Get-UniqueUPN
{
<#
.SYNOPSIS
Finds an available user principal name in the selected directory scope.
.DESCRIPTION
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.
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
Directory hostname, optionally with port. See Test-UPNExist for scope behaviour.
#>
[CmdletBinding(DefaultParameterSetName = 'Strings')]
[OutputType([string])]
param (
[Parameter(ParameterSetName = 'ADObject', Mandatory)]
[ValidateNotNull()]
[object]$ADObject,

[Parameter(ParameterSetName = 'Strings', Mandatory)]
[ValidateNotNullOrEmpty()]
[string]$FirstName,

[Parameter(ParameterSetName = 'Strings', Mandatory)]
[ValidateNotNullOrEmpty()]
[string]$LastName,

[Parameter(ParameterSetName = 'Strings')]
[string]$MiddleName,

[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string]$UPNSuffix,

[ValidateSet('FullName', 'FirstLetter')]
[string]$FirstNameFormat = 'FullName',

[ValidateSet('FullName', 'FirstLetter')]
[string]$LastNameFormat = 'FullName',

[switch]$IncludeMiddleName,

[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}]*$')]
[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')
{
if ($ADObject.PSObject.TypeNames -contains 'Microsoft.ActiveDirectory.Management.ADUser')
{
$givenName = [string]$ADObject.GivenName
$surname = [string]$ADObject.Surname
$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')
{
$givenName = [string](@($ADObject.Properties['givenName'])[0])
$surname = [string](@($ADObject.Properties['sn'])[0])
$middleNameValue = [string](@($ADObject.Properties['middleName'])[0])
}
else
{
throw "Unsupported AD object type: $($ADObject.GetType().FullName)"
}
}

foreach ($name in @($givenName, $surname))
{
if ([string]::IsNullOrWhiteSpace($name) -or $name.Trim() -match '[\s@\p{Cc}]')
{
throw 'First and last names must contain nonblank components without embedded whitespace, controls or @. Normalize compound names before calling.'
}
}
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}]')
{
throw 'Middle name must not contain embedded whitespace, controls or @.'
}
$parts.Add($middle)
}
$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'))
{
$lookup.AdServer = $ADServer
}

for ($attempt = 0; $attempt -lt $MaxAttempts; $attempt++)
{
$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
}
}
throw "No available UPN found after $MaxAttempts candidates."
}
120 changes: 120 additions & 0 deletions Public/Test-UpnExist.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
function Test-UPNExist
{
<#
.SYNOPSIS
Tests whether a user principal name exists in a forest or domain.
.DESCRIPTION
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
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()]
[OutputType([bool])]
param (
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string]$UPN,

[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')]
[string]$SearchScope = 'Forest'
)

$ErrorActionPreference = 'Stop'
if ([string]::IsNullOrWhiteSpace($UPN))
{
throw [ArgumentException]::new('UPN must not be blank.')
}

if (-not (Get-Command Get-ADUser -ErrorAction SilentlyContinue))
{
throw [InvalidOperationException]::new('Test-UPNExist requires Get-ADUser from the ActiveDirectory module and a reachable directory.')
}

$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'
}

$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
}

$found = @(Get-ADUser @query)
return ($found.Count -gt 0)
}
Loading
Loading