UnipharSecurityAuth.psm1
|
# UnipharSecurityAuth.psm1 # Authentication and utility functions for Uniphar security automation # Version: 1.0.0 # Last Modified: 2026-01-14 function New-RandomPassword { <# .SYNOPSIS Generates a secure random password with complexity requirements. .DESCRIPTION Creates a cryptographically random password containing uppercase letters, lowercase letters, numbers, and special characters. Ensures at least one character from each category is included. .PARAMETER Length The length of the generated password. Default is 90 characters for maximum security. .EXAMPLE New-RandomPassword Generates a 90-character random password. .EXAMPLE New-RandomPassword -Length 16 Generates a 16-character random password. .OUTPUTS System.String Returns the generated password as a plain text string. #> param( [Parameter(Mandatory = $false)] [int]$Length = 90 ) # Define character sets for password complexity $upperCase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' $lowerCase = 'abcdefghijklmnopqrstuvwxyz' $numbers = '0123456789' $specialChars = '!@#$%^&*()_-+={}[]|:;<>?,./' # Combine all character sets $allChars = $upperCase + $lowerCase + $numbers + $specialChars # Ensure password has at least one character from each set $password = @() $password += $upperCase[(Get-Random -Maximum $upperCase.Length)] $password += $lowerCase[(Get-Random -Maximum $lowerCase.Length)] $password += $numbers[(Get-Random -Maximum $numbers.Length)] $password += $specialChars[(Get-Random -Maximum $specialChars.Length)] # Fill the rest with random characters from all sets for ($i = 4; $i -lt $Length; $i++) { $password += $allChars[(Get-Random -Maximum $allChars.Length)] } # Shuffle the password array to randomize the positions $shuffledPassword = $password | Sort-Object { Get-Random } # Convert array to secure string return ($shuffledPassword -join '') } function Protect-LdapFilterValue { <# .SYNOPSIS Escapes special characters in LDAP filter values to prevent injection attacks. .DESCRIPTION Protects LDAP queries by escaping special characters including backslash, asterisk, parentheses, and null characters according to LDAP filter escaping rules. .PARAMETER Value The LDAP filter value to escape. .EXAMPLE Protect-LdapFilterValue -Value 'user(test)' Returns: user\28test\29 .OUTPUTS System.String Returns the escaped LDAP filter value. #> param([string]$Value) if ([string]::IsNullOrEmpty($Value)) { return $Value } $escaped = $Value.Replace('\', '\5c').Replace('*', '\2a').Replace('(', '\28').Replace(')', '\29') # Only replace null character if it exists if ($escaped.Contains([char]0)) { $escaped = $escaped.Replace([char]0, '\00') } return $escaped } function Invoke-WithRetry { <# .SYNOPSIS Executes a script block with automatic retry logic and exponential backoff. .DESCRIPTION Provides automatic retry functionality for transient failures including throttling (429), service unavailable (503), and temporary errors. Uses exponential backoff and respects Retry-After headers when present. .PARAMETER Script The script block to execute with retry logic. .PARAMETER MaxAttempts Maximum number of retry attempts. Default is 5. .PARAMETER BaseDelaySeconds Base delay in seconds for exponential backoff calculation. Default is 1 second. .EXAMPLE Invoke-WithRetry -Script { Get-MgUser -UserId 'user@domain.com' } Executes Get-MgUser with automatic retry on transient failures. .EXAMPLE Invoke-WithRetry -Script { Connect-MgGraph -Identity } -MaxAttempts 3 Retries Graph connection up to 3 times. .OUTPUTS System.Object Returns the result of the script block execution. #> [CmdletBinding()] param( [Parameter(Mandatory)][ScriptBlock]$Script, [int]$MaxAttempts = 5, [int]$BaseDelaySeconds = 1 ) $attempt = 0 while ($true) { $attempt++ try { return & $Script } catch { $ex = $_.Exception if ($attempt -ge $MaxAttempts) { throw $ex } # Check for 429 (throttling) or 503 (service unavailable) $shouldRetry = $false $delay = [math]::Pow(2, $attempt - 1) * $BaseDelaySeconds if ($ex.Message -match '429|503') { $shouldRetry = $true # Check if the error contains Retry-After if ($ex.Message -match 'Retry-After[:\s]+(\d+)') { $retryAfter = [int]$matches[1] $delay = $retryAfter } } elseif ($ex.Message -match 'throttl|rate limit|too many requests' -or $ex.Message -match 'temporary|transient|timeout') { $shouldRetry = $true } if (-not $shouldRetry) { throw $ex } Write-Warning "Attempt $attempt/$MaxAttempts failed: $($ex.Message). Retrying in $delay seconds..." Start-Sleep -Seconds $delay } } } function Get-KeyVaultSecretPlain { <# .SYNOPSIS Retrieves a secret from Azure Key Vault as plain text. .DESCRIPTION Gets a secret value from Azure Key Vault and returns it as a plain text string. Requires appropriate Key Vault permissions (Get secret). .PARAMETER VaultName Name of the Azure Key Vault containing the secret. .PARAMETER SecretName Name of the secret to retrieve. .EXAMPLE Get-KeyVaultSecretPlain -VaultName 'uni-core-kv' -SecretName 'sendgrid-api-key' Retrieves the SendGrid API key as plain text. .OUTPUTS System.String Returns the secret value as plain text, or $null if retrieval fails. #> param( [Parameter(Mandatory = $true)][string]$VaultName, [Parameter(Mandatory = $true)][string]$SecretName ) try { return (Get-AzKeyVaultSecret -VaultName $VaultName -Name $SecretName -AsPlainText -ErrorAction Stop) } catch { Write-Warning "Failed to retrieve secret '$SecretName' from Key Vault '$VaultName': $($_.Exception.Message)" return $null } } function Get-ServicePrincipalCredentialFromKeyVault { <# .SYNOPSIS Builds service principal credentials from Azure Key Vault secrets. .DESCRIPTION Retrieves client ID, client secret, and tenant ID from Key Vault and constructs a PSCredential object suitable for service principal authentication. .PARAMETER KeyVaultName Name of the Azure Key Vault containing the service principal secrets. .PARAMETER ClientIdSecretName Name of the Key Vault secret containing the client (application) ID. Default is 'ServicePrincipalClientId'. .PARAMETER ClientSecretSecretName Name of the Key Vault secret containing the client secret. Default is 'ServicePrincipalClientSecret'. .PARAMETER TenantIdSecretName Name of the Key Vault secret containing the tenant ID. Default is 'TenantId'. .EXAMPLE $sp = Get-ServicePrincipalCredentialFromKeyVault -KeyVaultName 'uni-core-kv' Connect-AzAccount -ServicePrincipal -Tenant $sp.TenantId -ApplicationId $sp.ClientId -Credential $sp.Credential .OUTPUTS System.Collections.Hashtable Returns hashtable with ClientId, Credential, and TenantId properties, or $null if retrieval fails. #> param( [Parameter(Mandatory = $true)][string]$KeyVaultName, [string]$ClientIdSecretName = 'ServicePrincipalClientId', [string]$ClientSecretSecretName = 'ServicePrincipalClientSecret', [string]$TenantIdSecretName = 'TenantId' ) try { $clientId = Get-KeyVaultSecretPlain -VaultName $KeyVaultName -SecretName $ClientIdSecretName $clientSecret = Get-KeyVaultSecretPlain -VaultName $KeyVaultName -SecretName $ClientSecretSecretName $tenantId = Get-KeyVaultSecretPlain -VaultName $KeyVaultName -SecretName $TenantIdSecretName if (-not $clientId -or -not $clientSecret -or -not $tenantId) { return $null } $secure = ConvertTo-SecureString -String $clientSecret -AsPlainText -Force $cred = New-Object System.Management.Automation.PSCredential ($clientId, $secure) return @{ ClientId = $clientId; Credential = $cred; TenantId = $tenantId } } catch { Write-Warning "Failed to build service principal credential from Key Vault: $($_.Exception.Message)" return $null } } function Connect-AzureContext { <# .SYNOPSIS Authenticates to Azure using Managed Identity or interactive login. .DESCRIPTION Establishes Azure context using authentication methods in order of preference: 1. Managed Identity (Azure Automation/Azure resources) 2. Interactive login (local development) .EXAMPLE Connect-AzureContext Connects using Managed Identity (in Azure Automation) or interactive login (locally). .OUTPUTS System.Boolean Returns $true if authentication succeeds, $false otherwise. #> param() Write-Verbose 'Connecting to Azure context...' try { Write-Verbose 'Attempting Connect-AzAccount -Identity (Managed Identity)' Connect-AzAccount -Identity -ErrorAction Stop Write-Verbose 'Azure connected using Managed Identity' return $true } catch { Write-Verbose 'Managed Identity not available for Azure login' } # Interactive fallback for local sessions if ($Host.Name -match 'ConsoleHost|Windows PowerShell|Visual Studio Code Host') { try { Write-Verbose 'Falling back to interactive Connect-AzAccount (local)' Connect-AzAccount -ErrorAction Stop return $true } catch { Write-Warning "Interactive Azure login failed: $($_.Exception.Message)" return $false } } Write-Warning 'Unable to authenticate to Azure in this environment' return $false } function Connect-GraphContext { <# .SYNOPSIS Authenticates to Microsoft Graph using Managed Identity. .DESCRIPTION Establishes Microsoft Graph connection using Managed Identity (Azure Automation only). Managed Identity permissions must be pre-assigned in Azure AD. .EXAMPLE Connect-GraphContext Connects to Microsoft Graph using Managed Identity. .NOTES Important: Managed Identity cannot use -Scopes parameter. Graph API permissions must be pre-assigned to the Automation Account's Managed Identity in Azure AD using Grant-AutomationAccountGraphPermissions.ps1 or Azure Portal. .OUTPUTS System.Boolean Returns $true if authentication succeeds, $false otherwise. #> [CmdletBinding()] param() Write-Verbose 'Connecting to Microsoft Graph context...' try { Write-Verbose 'Attempting Connect-MgGraph -Identity (Managed Identity)' Connect-MgGraph -Identity -NoWelcome -ErrorAction Stop | Out-Null if (Get-MgContext) { Write-Verbose 'Managed Identity Graph authentication succeeded' return $true } else { Write-Warning 'Connect-MgGraph succeeded but no context available' return $false } } catch { Write-Warning "Managed Identity Graph auth failed: $($_.Exception.Message)" Write-Warning "Ensure the Automation Account Managed Identity has the required Graph API permissions assigned" return $false } } function Connect-ExchangeOnlineContext { <# .SYNOPSIS Authenticates to Exchange Online using Managed Identity or interactive login. .DESCRIPTION Establishes Exchange Online connection using authentication methods: 1. Managed Identity (Azure Automation) 2. Interactive login (local development, if enabled) .PARAMETER Organization Exchange Online organization name. Default is 'uniphar.onmicrosoft.com'. .PARAMETER AllowInteractiveLocal If specified, allows interactive authentication for local development sessions. .EXAMPLE Connect-ExchangeOnlineContext Connects using Managed Identity (Azure Automation). .EXAMPLE Connect-ExchangeOnlineContext -AllowInteractiveLocal Tries Managed Identity, then interactive (local only). .OUTPUTS System.Boolean Returns $true if authentication succeeds, $false otherwise. #> param( [string]$Organization = 'uniphar.onmicrosoft.com', [switch]$AllowInteractiveLocal ) Write-Verbose 'Connecting to Exchange Online...' try { Write-Verbose "Attempting Connect-ExchangeOnline -ManagedIdentity -Organization $Organization" Connect-ExchangeOnline -ManagedIdentity -Organization $Organization -ShowBanner:$false -ErrorAction Stop Write-Verbose 'Connected to EXO using Managed Identity' return $true } catch { Write-Verbose "Managed Identity for EXO not available: $($_.Exception.Message)" } if ($AllowInteractiveLocal -and ($Host.Name -match 'ConsoleHost|Windows PowerShell|Visual Studio Code Host')) { try { Write-Verbose 'Attempting interactive Connect-ExchangeOnline (local)' Connect-ExchangeOnline -ShowBanner:$false -ErrorAction Stop Write-Verbose 'Connected to EXO interactively' return $true } catch { Write-Warning "Interactive EXO login failed: $($_.Exception.Message)" return $false } } Write-Warning 'Unable to authenticate to Exchange Online in this environment' return $false } function Get-OnPremAdCredential { <# .SYNOPSIS Retrieves on-premises Active Directory credentials from Azure Key Vault. .DESCRIPTION Gets domain admin username and password from Key Vault and constructs a PSCredential object for use with on-premises AD operations via Hybrid Runbook Worker. .PARAMETER KeyVaultName Name of Azure Key Vault containing on-premises AD credentials. .PARAMETER UsernameSecretName Name of Key Vault secret containing the AD username. Default is 'OnPremAdUsername'. .PARAMETER PasswordSecretName Name of Key Vault secret containing the AD password. Default is 'OnPremAdPassword'. .EXAMPLE $cred = Get-OnPremAdCredential -KeyVaultName 'uni-core-on-prem-kv' Disable-ADAccount -Identity 'user' -Server 'dc01.domain.com' -Credential $cred .OUTPUTS System.Management.Automation.PSCredential Returns PSCredential object for on-premises AD authentication, or $null if retrieval fails. #> param( [Parameter(Mandatory = $true)][string]$KeyVaultName, [Parameter(Mandatory = $false)][string]$UsernameSecretName = 'OnPremAdUsername', [Parameter(Mandatory = $false)][string]$PasswordSecretName = 'OnPremAdPassword' ) try { $username = Get-KeyVaultSecretPlain -VaultName $KeyVaultName -SecretName $UsernameSecretName $passwordPlain = Get-KeyVaultSecretPlain -VaultName $KeyVaultName -SecretName $PasswordSecretName if (-not $username -or -not $passwordPlain) { return $null } $passwordSecure = ConvertTo-SecureString -String $passwordPlain -AsPlainText -Force return New-Object System.Management.Automation.PSCredential ($username, $passwordSecure) } catch { Write-Warning "Failed to retrieve on-prem AD credentials from Key Vault: $($_.Exception.Message)" return $null } } function Test-OnPremAD { <# .SYNOPSIS Tests connectivity to on-premises Active Directory domain controller. .DESCRIPTION Verifies on-premises AD connectivity by attempting to query domain information. Automatically imports the ActiveDirectory module if available. .PARAMETER Server FQDN or IP address of the on-premises domain controller to test. .PARAMETER Credential PSCredential object for domain authentication. If not provided, uses current user context. .EXAMPLE Test-OnPremAD -Server 'unidc10.uniphar.local' Tests AD connectivity using current user credentials. .EXAMPLE $cred = Get-OnPremAdCredential -KeyVaultName 'uni-core-on-prem-kv' Test-OnPremAD -Server 'unidc10.uniphar.local' -Credential $cred Tests AD connectivity using credentials from Key Vault. .OUTPUTS System.Boolean Returns $true if connectivity succeeds, $false otherwise. #> param( [Parameter(Mandatory = $true)] [string]$Server, [Parameter(Mandatory = $false)] [PSCredential]$Credential ) try { # Import ActiveDirectory module if not already loaded if (-not (Get-Module -Name ActiveDirectory)) { if (Get-Module -ListAvailable -Name ActiveDirectory) { Import-Module ActiveDirectory -ErrorAction Stop Write-Verbose "ActiveDirectory module imported successfully" } else { Write-Warning "ActiveDirectory module is not available on this system" return $false } } # Test if we can reach the domain controller $testParams = @{ Server = $Server ErrorAction = 'Stop' } if ($Credential) { $testParams['Credential'] = $Credential } # Try to get domain info to verify connectivity $null = Get-ADDomain @testParams Write-Verbose "On-premises AD connection verified: $Server" return $true } catch { Write-Warning "Failed to connect to on-premises AD server ${Server}: $($_.Exception.Message)" return $false } } function Send-EmailViaSendGrid { <# .SYNOPSIS Sends email notifications via SendGrid API. .DESCRIPTION Sends email messages using SendGrid's v3 REST API with support for multiple recipients and file attachments. .PARAMETER ApiKey SendGrid API key for authentication. .PARAMETER From Sender email address. .PARAMETER To Array of recipient email addresses. .PARAMETER Subject Email subject line. .PARAMETER Body Email body content (plain text). .PARAMETER Attachments Array of hashtables containing attachment details with Content, Filename, and Type properties. .PARAMETER ApiEndpoint SendGrid API endpoint URL. Default is 'https://api.sendgrid.com/v3/mail/send'. .EXAMPLE $apiKey = Get-KeyVaultSecretPlain -VaultName 'uni-core-kv' -SecretName 'sendgrid-api-key' Send-EmailViaSendGrid -ApiKey $apiKey -From 'noreply@uniphar.com' -To @('admin@uniphar.com') -Subject 'Alert' -Body 'Test message' .EXAMPLE $attachment = @{ Content = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes('test content')) Filename = 'report.txt' Type = 'text/plain' } Send-EmailViaSendGrid -ApiKey $apiKey -From 'noreply@uniphar.com' -To @('user@uniphar.com') -Subject 'Report' -Body 'See attachment' -Attachments @($attachment) .OUTPUTS System.Boolean Returns $true if email is sent successfully, $false otherwise. #> param( [Parameter(Mandatory = $true)] [string]$ApiKey, [Parameter(Mandatory = $true)] [string]$From, [Parameter(Mandatory = $true)] [string[]]$To, [Parameter(Mandatory = $true)] [string]$Subject, [Parameter(Mandatory = $true)] [string]$Body, [Parameter(Mandatory = $false)] [hashtable[]]$Attachments, [Parameter(Mandatory = $false)] [ValidateSet('text/plain', 'text/html')] [string]$ContentType = 'text/plain', [Parameter(Mandatory = $false)] [string]$ApiEndpoint = 'https://api.sendgrid.com/v3/mail/send' ) try { # Build recipient list $recipientList = @() foreach ($email in $To) { $recipientList += @{ email = $email } } # Build email body $emailBody = @{ personalizations = @( @{ to = $recipientList } ) from = @{ email = $From } subject = $Subject content = @( @{ type = $ContentType value = $Body } ) } # Add attachments if provided if ($Attachments -and $Attachments.Count -gt 0) { $emailBody.attachments = @() foreach ($att in $Attachments) { $emailBody.attachments += @{ content = $att.Content filename = $att.Filename type = $att.Type disposition = 'attachment' } } } $headers = @{ 'Authorization' = "Bearer $ApiKey" 'Content-Type' = 'application/json' } $jsonBody = ConvertTo-Json -InputObject $emailBody -Depth 5 Invoke-RestMethod -Uri $ApiEndpoint -Method Post -Headers $headers -Body $jsonBody -ErrorAction Stop Write-Verbose "Email sent successfully via SendGrid to: $($To -join ', ')" return $true } catch { Write-Warning "Failed to send email via SendGrid: $($_.Exception.Message)" return $false } } function ConvertTo-SendGridAttachment { <# .SYNOPSIS Converts a file to SendGrid attachment format. .DESCRIPTION Reads a file from disk and converts it to the hashtable format required by Send-EmailViaSendGrid. Automatically detects MIME type based on file extension. .PARAMETER FilePath Path to the file to attach. File must exist. .EXAMPLE $attachment = ConvertTo-SendGridAttachment -FilePath 'C:\temp\report.csv' Send-EmailViaSendGrid -ApiKey $key -From $from -To $to -Subject 'Report' -Body 'See attached' -Attachments @($attachment) .EXAMPLE $attachments = @( (ConvertTo-SendGridAttachment -FilePath 'C:\temp\report.csv'), (ConvertTo-SendGridAttachment -FilePath 'C:\temp\log.txt') ) Send-EmailViaSendGrid -ApiKey $key -From $from -To $to -Subject 'Reports' -Body 'Multiple files attached' -Attachments $attachments .OUTPUTS System.Collections.Hashtable Returns hashtable with Content, Filename, and Type properties for SendGrid API. #> [CmdletBinding()] param( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [ValidateScript({ if (-not (Test-Path $_)) { throw "File not found: $_" } return $true })] [string]$FilePath ) process { try { $bytes = [System.IO.File]::ReadAllBytes($FilePath) $content = [Convert]::ToBase64String($bytes) $filename = [System.IO.Path]::GetFileName($FilePath) # Determine MIME type based on file extension $extension = [System.IO.Path]::GetExtension($FilePath).ToLowerInvariant() $mimeType = switch ($extension) { '.csv' { 'text/csv' } '.txt' { 'text/plain' } '.log' { 'text/plain' } '.html' { 'text/html' } '.htm' { 'text/html' } '.json' { 'application/json' } '.xml' { 'application/xml' } '.pdf' { 'application/pdf' } '.zip' { 'application/zip' } '.xlsx' { 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' } '.docx' { 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' } default { 'application/octet-stream' } } return @{ Content = $content Filename = $filename Type = $mimeType } } catch { Write-Error "Failed to convert file '$FilePath' to SendGrid attachment: $($_.Exception.Message)" throw } } } function ConvertTo-EmailRecipientArray { <# .SYNOPSIS Converts various recipient email formats to a normalized string array. .DESCRIPTION Parses recipient email addresses from comma-separated, semicolon-separated, or newline-separated strings or arrays. Trims whitespace, removes empty entries, and returns unique sorted addresses. Handles Azure Automation parameter quirks where arrays may be passed as strings. .PARAMETER Recipients Recipient email address(es) in any of these formats: - Single email string: 'user@domain.com' - Comma-separated string: 'user1@domain.com, user2@domain.com' - Semicolon-separated string: 'user1@domain.com; user2@domain.com' - String array: @('user1@domain.com', 'user2@domain.com') .EXAMPLE ConvertTo-EmailRecipientArray -Recipients 'user1@domain.com, user2@domain.com' Returns: @('user1@domain.com', 'user2@domain.com') .EXAMPLE ConvertTo-EmailRecipientArray -Recipients @('user1@domain.com', 'user2@domain.com') Returns: @('user1@domain.com', 'user2@domain.com') .EXAMPLE $recipients = ConvertTo-EmailRecipientArray -Recipients $SendGridRecipientEmailAddresses # Handles Azure Automation parameter conversion automatically .OUTPUTS System.String[] Returns normalized array of unique email addresses with whitespace trimmed. #> [CmdletBinding()] param( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [AllowEmptyString()] [AllowNull()] $Recipients ) process { if ($null -eq $Recipients) { return @() } $recipientArray = @() # Handle string input (comma, semicolon, or newline separated) if ($Recipients -is [string]) { $recipientArray = ($Recipients -split '[,;\r\n]') | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } } # Handle array or collection input - also split each element by comma/semicolon # to guard against @('a@b.com,c@d.com') style default values elseif ($Recipients -is [System.Collections.IEnumerable]) { $recipientArray = $Recipients | ForEach-Object { if ($null -ne $_) { $_.ToString() -split '[,;\r\n]' } } | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } } # Handle single object else { $recipientString = $Recipients.ToString().Trim() if (-not [string]::IsNullOrWhiteSpace($recipientString)) { $recipientArray = @($recipientString) } } # Return unique sorted addresses return $recipientArray | Sort-Object -Unique } } function ConvertTo-E164PhoneNumber { <# .SYNOPSIS Normalizes a raw phone number into the format required by Microsoft Graph phone authentication methods. .DESCRIPTION Microsoft Graph phone authentication methods require numbers in the format '+{country code} {number}' with an optional 'x{extension}' suffix (for example '+353 861687760' or '+1 5555551234x123'). This function accepts messy source values such as '+353 (86) 1687760', '+44 (7801) 930577', or '+353 (89) 7050141 x1' and returns a normalized value. Any parentheses, spaces, dashes, and other separators inside the number are removed while the country code and an optional extension are preserved. Only international numbers (starting with a '+') can be normalized reliably because the country code is required to split the number correctly. If the value is empty, is not in international format, or does not contain enough digits, $null is returned so the caller can skip or report it. .PARAMETER PhoneNumber The raw phone number to normalize. .EXAMPLE ConvertTo-E164PhoneNumber -PhoneNumber '+353 (86) 1687760' Returns: '+353 861687760' .EXAMPLE ConvertTo-E164PhoneNumber -PhoneNumber '+353 (89) 7050141 x1' Returns: '+353 897050141x1' .OUTPUTS System.String The normalized phone number, or $null when the value cannot be normalized. #> [CmdletBinding()] param( [Parameter(Mandatory = $false, ValueFromPipeline = $true)] [AllowEmptyString()] [AllowNull()] [string]$PhoneNumber ) process { if ([string]::IsNullOrWhiteSpace($PhoneNumber)) { return $null } $raw = $PhoneNumber.Trim() # Extract a trailing extension such as 'x1', 'ext 123', or 'extension 5' $extension = $null $extensionMatch = [regex]::Match($raw, '(?i)\s*(?:x|ext\.?|extension)\s*(\d+)\s*$') if ($extensionMatch.Success) { $extension = $extensionMatch.Groups[1].Value $raw = $raw.Substring(0, $extensionMatch.Index) } # Only international format (leading '+') can be normalized reliably $countryCodeMatch = [regex]::Match($raw, '^\s*\+(\d{1,3})') if (-not $countryCodeMatch.Success) { return $null } $countryCode = $countryCodeMatch.Groups[1].Value $remainder = $raw.Substring($countryCodeMatch.Index + $countryCodeMatch.Length) $nationalNumber = ($remainder -replace '\D', '') if ([string]::IsNullOrEmpty($nationalNumber) -or $nationalNumber.Length -lt 4) { return $null } $result = "+$countryCode $nationalNumber" if ($extension) { $result = "${result}x${extension}" } return $result } } function Get-CleanManagerName { <# .SYNOPSIS Cleans a manager display name coming from the Workday export so it can be matched against directory users. .DESCRIPTION The Workday export stores the manager as a display name only (no identifier) and frequently annotates it with a trailing parenthetical such as 'Tara McGuinness (On Leave)'. This function removes any trailing parenthetical group(s) and trims surrounding whitespace so the remaining name can be used for a best-effort display-name match. It does not change casing, so callers should compare case-insensitively. .PARAMETER ManagerName The raw manager display name to clean. .EXAMPLE Get-CleanManagerName -ManagerName 'Tara McGuinness (On Leave)' Returns: 'Tara McGuinness' .EXAMPLE Get-CleanManagerName -ManagerName 'ROBERTA KANCIAUSKAITE' Returns: 'ROBERTA KANCIAUSKAITE' .OUTPUTS System.String The cleaned manager name, or $null when the value is empty. #> [CmdletBinding()] param( [Parameter(Mandatory = $false, ValueFromPipeline = $true)] [AllowEmptyString()] [AllowNull()] [string]$ManagerName ) process { if ([string]::IsNullOrWhiteSpace($ManagerName)) { return $null } # Remove one or more trailing parenthetical groups, e.g. 'Name (On Leave)' or 'Name (A) (B)' $clean = [regex]::Replace($ManagerName, '(\s*\([^)]*\))+\s*$', '') $clean = $clean.Trim() if ([string]::IsNullOrWhiteSpace($clean)) { return $null } return $clean } } function Get-UserAttributeDiff { <# .SYNOPSIS Returns the subset of desired attributes that differ from the current values. .DESCRIPTION Compares a hashtable of current attribute values against a hashtable of desired attribute values and returns a hashtable containing only the keys whose desired value differs from the current value. This lets callers write only the attributes that actually changed. Desired values that are $null or empty/whitespace are skipped entirely so that existing directory attributes are never blanked out. Comparisons are case-insensitive and ignore leading/trailing whitespace. .PARAMETER Current Hashtable of the current attribute values, keyed by logical attribute name. .PARAMETER Desired Hashtable of the desired attribute values, keyed by the same logical attribute names. .EXAMPLE $current = @{ DisplayName = 'Jane Doe'; Department = 'Sales' } $desired = @{ DisplayName = 'Jane Doe'; Department = 'Marketing'; Company = 'Uniphar' } Get-UserAttributeDiff -Current $current -Desired $desired Returns: @{ Department = 'Marketing'; Company = 'Uniphar' } .OUTPUTS System.Collections.Hashtable A hashtable of the changed attributes with their desired values. Empty when nothing changed. #> [CmdletBinding()] param( [Parameter(Mandatory = $false)] [hashtable]$Current, [Parameter(Mandatory = $true)] [hashtable]$Desired ) $diff = @{} if ($null -eq $Desired) { return $diff } foreach ($key in $Desired.Keys) { $desiredValue = $Desired[$key] # Never blank an existing attribute from a missing/empty source value if ($null -eq $desiredValue -or [string]::IsNullOrWhiteSpace([string]$desiredValue)) { continue } $currentValue = $null if ($null -ne $Current -and $Current.ContainsKey($key)) { $currentValue = $Current[$key] } $currentString = if ($null -ne $currentValue) { ([string]$currentValue).Trim() } else { '' } $desiredString = ([string]$desiredValue).Trim() if (-not [string]::Equals($currentString, $desiredString, [System.StringComparison]::OrdinalIgnoreCase)) { $diff[$key] = $desiredValue } } return $diff } function Get-OrphanedEmployeeId { <# .SYNOPSIS Returns the employee IDs configured in the directory that are no longer present in the source of truth. .DESCRIPTION Compares the set of employee IDs currently configured on directory accounts against the set of valid employee IDs from the authoritative source (the Workday export). Any current employee ID that is not in the valid set is considered orphaned and returned. Comparisons are case-insensitive and ignore surrounding whitespace, and the returned list is de-duplicated. .PARAMETER CurrentEmployeeIds The employee IDs currently configured on directory accounts. .PARAMETER ValidEmployeeIds The authoritative set of employee IDs that should remain configured. .EXAMPLE Get-OrphanedEmployeeId -CurrentEmployeeIds @('100', '200', '300') -ValidEmployeeIds @('100', '300') Returns: @('200') .OUTPUTS System.String[] The distinct orphaned employee IDs. Empty array when there are none. #> [CmdletBinding()] param( [Parameter(Mandatory = $false)] [AllowNull()] [string[]]$CurrentEmployeeIds, [Parameter(Mandatory = $false)] [AllowNull()] [string[]]$ValidEmployeeIds ) $validSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($id in @($ValidEmployeeIds)) { if (-not [string]::IsNullOrWhiteSpace($id)) { [void]$validSet.Add($id.Trim()) } } $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $orphaned = [System.Collections.Generic.List[string]]::new() foreach ($id in @($CurrentEmployeeIds)) { if ([string]::IsNullOrWhiteSpace($id)) { continue } $trimmed = $id.Trim() if (-not $validSet.Contains($trimmed) -and $seen.Add($trimmed)) { $orphaned.Add($trimmed) } } return $orphaned.ToArray() } # Export module members Export-ModuleMember -Function @( 'New-RandomPassword', 'Protect-LdapFilterValue', 'Invoke-WithRetry', 'Connect-AzureContext', 'Connect-GraphContext', 'Connect-ExchangeOnlineContext', 'Get-KeyVaultSecretPlain', 'Get-ServicePrincipalCredentialFromKeyVault', 'Get-OnPremAdCredential', 'Test-OnPremAD', 'Send-EmailViaSendGrid', 'ConvertTo-SendGridAttachment', 'ConvertTo-EmailRecipientArray', 'ConvertTo-E164PhoneNumber', 'Get-CleanManagerName', 'Get-UserAttributeDiff', 'Get-OrphanedEmployeeId' ) |