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

    # Draw an unbiased index in [0, Max) using a cryptographically secure RNG
    $getSecureIndex = { param([int]$Max) [System.Security.Cryptography.RandomNumberGenerator]::GetInt32($Max) }

    # Ensure password has at least one character from each set
    $password = [System.Collections.Generic.List[char]]::new()
    $password.Add($upperCase[(& $getSecureIndex $upperCase.Length)])
    $password.Add($lowerCase[(& $getSecureIndex $lowerCase.Length)])
    $password.Add($numbers[(& $getSecureIndex $numbers.Length)])
    $password.Add($specialChars[(& $getSecureIndex $specialChars.Length)])

    # Fill the rest with random characters from all sets
    for ($i = 4; $i -lt $Length; $i++) {
        $password.Add($allChars[(& $getSecureIndex $allChars.Length)])
    }

    # Fisher-Yates shuffle using the same CSPRNG so guaranteed characters are placed uniformly
    for ($i = $password.Count - 1; $i -gt 0; $i--) {
        $j = & $getSecureIndex ($i + 1)
        $temp = $password[$i]
        $password[$i] = $password[$j]
        $password[$j] = $temp
    }

    return (-join $password)
}

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
            }
            $shouldRetry = $false
            $delay = [math]::Pow(2, $attempt - 1) * $BaseDelaySeconds

            # Prefer the HTTP status code from the response over regex over the message
            $statusCode = $null
            $response = $null
            try { $response = $ex.Response } catch { $response = $null }
            if ($response) {
                try { $statusCode = [int]$response.StatusCode } catch { $statusCode = $null }
            }

            if ($statusCode -in 429, 502, 503, 504) {
                $shouldRetry = $true
                # Read Retry-After from the response header when present
                try {
                    $retryHeader = $response.Headers.RetryAfter
                    if ($retryHeader) {
                        if ($retryHeader.Delta) {
                            $delay = [int]$retryHeader.Delta.TotalSeconds
                        } elseif ($retryHeader.Date) {
                            $seconds = ([datetimeoffset]$retryHeader.Date - [datetimeoffset]::UtcNow).TotalSeconds
                            if ($seconds -gt 0) { $delay = [int]$seconds }
                        }
                    }
                } catch { }
            } elseif ($null -eq $statusCode -and (
                    $ex.Message -match 'throttl|rate limit|too many requests' -or
                    $ex.Message -match 'temporary|transient|timeout')) {
                # Last-resort fallback only when no status code could be read
                $shouldRetry = $true
            }

            if (-not $shouldRetry) {
                throw
            }

            # Add jitter so parallel callers do not retry in lockstep
            $jitter = [System.Security.Cryptography.RandomNumberGenerator]::GetInt32(0, 1000) / 1000.0
            $delay = [double]$delay + $jitter
            Write-Warning "Attempt $attempt/$MaxAttempts failed: $($ex.Message). Retrying in $([math]::Round($delay, 2)) 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 {
        # SendGrid returns the actual rejection reason in the response body, exposed via ErrorDetails.Message.
        $detail = $null
        if ($_.ErrorDetails -and $_.ErrorDetails.Message) { $detail = $_.ErrorDetails.Message }
        $reason = if ($detail) { "$($_.Exception.Message) | SendGrid response: $detail" } else { $_.Exception.Message }
        Write-Warning "Failed to send email via SendGrid: $reason"
        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, and the country code
        MUST be separated from the national number by a space. Without that space the country-code boundary
        is ambiguous (e.g. '+15555551234' could be +1, +15 or +155), so such values return $null rather than
        being split with a guess. If the value is empty, is not in international format, has no space after the
        country code, 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'
 
    .EXAMPLE
        ConvertTo-E164PhoneNumber -PhoneNumber '+15555551234'
        Returns: $null — no space after the country code, so the boundary is ambiguous.
 
    .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
        if ($raw -notmatch '^\s*\+\d') {
            return $null
        }

        # The Graph format is '+{country code} {number}'. When a space separates the country
        # code from the national number, trust it rather than greedily grabbing three digits
        # (which would mis-split an unspaced value like '+15555551234' into '+155 5555551234').
        $spacedMatch = [regex]::Match($raw, '^\s*\+(\d{1,4})\s+(\S.*)$')
        if ($spacedMatch.Success) {
            $countryCode = $spacedMatch.Groups[1].Value
            $remainder = $spacedMatch.Groups[2].Value
        } else {
            # No delimiter: the country-code boundary is ambiguous and cannot be split reliably.
            return $null
        }

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

# ---------------------------------------------------------------------------
# Thinkst Canary phishing auto-response helpers
# Consumed by the Invoke-CanaryPhishingResponse GitHub Actions runbook.
# ---------------------------------------------------------------------------

function Confirm-CanaryConnectivity {
    <#
    .SYNOPSIS
        Verifies connectivity and authentication against the Thinkst Canary Console API.
 
    .DESCRIPTION
        Calls the /api/v1/ping endpoint. Returns $true when the console responds with
        result 'success', otherwise $false.
 
    .PARAMETER ConsoleDomain
        The Canary console hostname, e.g. 'abc123.canary.tools'.
 
    .PARAMETER AuthToken
        The Canary Console API auth_token.
 
    .EXAMPLE
        Confirm-CanaryConnectivity -ConsoleDomain 'abc123.canary.tools' -AuthToken $token
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$ConsoleDomain,

        [Parameter(Mandatory = $true)]
        [string]$AuthToken
    )

    $uri = "https://$ConsoleDomain/api/v1/ping"
    # Send the token in a header so it does not leak into proxy logs or the Referer chain
    $headers = @{ 'X-Canary-Auth-Token' = $AuthToken }
    $response = Invoke-RestMethod -Method Get -Uri $uri -Headers $headers -ErrorAction Stop
    return ($response.result -eq 'success')
}

function Get-CanaryIncident {
    <#
    .SYNOPSIS
        Retrieves unacknowledged incidents from the Thinkst Canary Console API.
 
    .DESCRIPTION
        Calls /api/v1/incidents/unacknowledged and returns the raw incident objects. When a
        CanaryTokenId is supplied it is passed to the API to pre-filter server side; the caller
        should still hard-filter with ConvertFrom-CanaryIncident.
 
    .PARAMETER ConsoleDomain
        The Canary console hostname, e.g. 'abc123.canary.tools'.
 
    .PARAMETER AuthToken
        The Canary Console API auth_token.
 
    .PARAMETER CanaryTokenId
        Optional canarytoken ID to pre-filter incidents to a single token.
 
    .EXAMPLE
        Get-CanaryIncident -ConsoleDomain 'abc123.canary.tools' -AuthToken $token -CanaryTokenId $id
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$ConsoleDomain,

        [Parameter(Mandatory = $true)]
        [string]$AuthToken,

        [Parameter(Mandatory = $false)]
        [string]$CanaryTokenId
    )

    $uri = "https://$ConsoleDomain/api/v1/incidents/unacknowledged"
    # Send the token in a header so it does not leak into proxy logs or the Referer chain
    $headers = @{ 'X-Canary-Auth-Token' = $AuthToken }
    $body = @{}
    if (-not [string]::IsNullOrWhiteSpace($CanaryTokenId)) { $body['canarytoken'] = $CanaryTokenId }

    $response = Invoke-RestMethod -Method Get -Uri $uri -Headers $headers -Body $body -ErrorAction Stop
    return @($response.incidents)
}

function ConvertFrom-CanaryIncident {
    <#
    .SYNOPSIS
        Parses a raw Canary incident, hard-filtering to the Azure Entra ID Login token only.
 
    .DESCRIPTION
        Pure function (no external calls). Returns a normalised object with the source IP
        addresses and incident metadata ONLY when the incident belongs to the specified
        Entra ID Login canarytoken. Any other incident type or token is ignored (returns nothing).
 
    .PARAMETER Incident
        A single raw incident object from Get-CanaryIncident.
 
    .PARAMETER EntraTokenId
        The canarytoken ID of the Azure Entra ID Login token. Incidents for any other token
        are ignored.
 
    .EXAMPLE
        $incidents | ConvertFrom-CanaryIncident -EntraTokenId $tokenId
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [object]$Incident,

        [Parameter(Mandatory = $true)]
        [string]$EntraTokenId
    )

    process {
        $desc = $Incident.description
        if ($null -eq $desc) { return }

        # Hard filter: only act on the Azure Entra ID Login canarytoken.
        $tokenId = [string]$desc.canarytoken
        if ([string]::IsNullOrWhiteSpace($tokenId) -or $tokenId -ne $EntraTokenId) { return }

        # Collect source IP(s) from the incident summary and each rolled-up event.
        $ips = [System.Collections.Generic.List[string]]::new()
        if (-not [string]::IsNullOrWhiteSpace([string]$desc.src_host)) {
            $ips.Add(([string]$desc.src_host).Trim())
        }
        foreach ($ev in @($desc.events)) {
            if ($null -ne $ev -and -not [string]::IsNullOrWhiteSpace([string]$ev.src_host)) {
                $ips.Add(([string]$ev.src_host).Trim())
            }
        }

        $uniqueIps = @($ips | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Sort-Object -Unique)
        if ($uniqueIps.Count -eq 0) { return }

        $incidentId = if ($Incident.hash_id) { [string]$Incident.hash_id }
        elseif ($Incident.id) { [string]$Incident.id }
        else { [string]$desc.id }

        [PSCustomObject]@{
            IncidentId  = $incidentId
            CanaryToken = $tokenId
            FlockName   = [string]$desc.flock_name
            TokenName   = [string]$desc.description
            Created     = [string]$desc.created_std
            SourceIps   = $uniqueIps
        }
    }
}

function Set-CanaryIncidentAcknowledged {
    <#
    .SYNOPSIS
        Acknowledges a Canary incident so it is not re-processed.
 
    .DESCRIPTION
        Calls /api/v1/incident/acknowledge for the given incident ID.
 
    .PARAMETER ConsoleDomain
        The Canary console hostname, e.g. 'abc123.canary.tools'.
 
    .PARAMETER AuthToken
        The Canary Console API auth_token.
 
    .PARAMETER IncidentId
        The incident identifier (hash_id) returned by ConvertFrom-CanaryIncident.
 
    .EXAMPLE
        Set-CanaryIncidentAcknowledged -ConsoleDomain $domain -AuthToken $token -IncidentId $id
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory = $true)]
        [string]$ConsoleDomain,

        [Parameter(Mandatory = $true)]
        [string]$AuthToken,

        [Parameter(Mandatory = $true)]
        [string]$IncidentId
    )

    if ($PSCmdlet.ShouldProcess($IncidentId, 'Acknowledge Canary incident')) {
        $uri = "https://$ConsoleDomain/api/v1/incident/acknowledge"
        $body = @{ auth_token = $AuthToken; incident = $IncidentId }
        Invoke-RestMethod -Method Post -Uri $uri -Body $body -ErrorAction Stop | Out-Null
    }
}

function ConvertTo-CanaryCidr {
    <#
    .SYNOPSIS
        Normalises a bare IP address into CIDR notation.
 
    .DESCRIPTION
        Pure function. Returns the input unchanged when it already contains a prefix. A bare
        IPv4 address becomes /32 and a bare IPv6 address becomes /128. Returns $null for input
        that is not a valid IP address.
 
    .PARAMETER IpAddress
        The IP address or CIDR string to normalise.
 
    .EXAMPLE
        ConvertTo-CanaryCidr -IpAddress '203.0.113.5' # -> '203.0.113.5/32'
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$IpAddress
    )

    if ([string]::IsNullOrWhiteSpace($IpAddress)) { return $null }
    $value = $IpAddress.Trim()
    if ($value -match '/') { return $value }

    try {
        $parsed = [System.Net.IPAddress]::Parse($value)
        if ($parsed.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetworkV6) {
            return "$value/128"
        }
        return "$value/32"
    } catch {
        return $null
    }
}

function Test-IpInCidrRange {
    <#
    .SYNOPSIS
        Tests whether an IP address falls within a CIDR range.
 
    .DESCRIPTION
        Pure function supporting both IPv4 and IPv6. Returns $false (never throws) for malformed
        input or an address-family mismatch between the IP and the CIDR network.
 
    .PARAMETER IpAddress
        The IP address to test.
 
    .PARAMETER Cidr
        The CIDR range, e.g. '203.0.113.0/24'. A bare address is treated as a /32 or /128 host.
 
    .EXAMPLE
        Test-IpInCidrRange -IpAddress '203.0.113.5' -Cidr '203.0.113.0/24' # -> $true
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$IpAddress,

        [Parameter(Mandatory = $true)]
        [string]$Cidr
    )

    try {
        $ip = [System.Net.IPAddress]::Parse($IpAddress.Trim())
        $parts = $Cidr.Trim().Split('/')
        $network = [System.Net.IPAddress]::Parse($parts[0])

        if ($ip.AddressFamily -ne $network.AddressFamily) { return $false }

        $ipBytes = $ip.GetAddressBytes()
        $netBytes = $network.GetAddressBytes()
        $totalBits = $ipBytes.Length * 8

        $prefixLength = if ($parts.Count -gt 1 -and -not [string]::IsNullOrWhiteSpace($parts[1])) {
            [int]$parts[1]
        } else {
            $totalBits
        }
        if ($prefixLength -lt 0 -or $prefixLength -gt $totalBits) { return $false }

        $fullBytes = [math]::Floor($prefixLength / 8)
        for ($i = 0; $i -lt $fullBytes; $i++) {
            if ($ipBytes[$i] -ne $netBytes[$i]) { return $false }
        }

        $remainderBits = $prefixLength % 8
        if ($remainderBits -gt 0) {
            $mask = [byte]((0xFF -shl (8 - $remainderBits)) -band 0xFF)
            if (($ipBytes[$fullBytes] -band $mask) -ne ($netBytes[$fullBytes] -band $mask)) {
                return $false
            }
        }

        return $true
    } catch {
        return $false
    }
}

function Get-TrustedNamedLocationRange {
    <#
    .SYNOPSIS
        Returns the CIDR ranges of all Conditional Access Named Locations marked as trusted.
 
    .DESCRIPTION
        Reads every IP Named Location where isTrusted is true and returns the union of their
        CIDR ranges. These act as the known-good allow-list: an alert IP inside any of these
        ranges must NOT be blocked or have sessions revoked (self-DoS protection).
 
    .EXAMPLE
        $allowList = Get-TrustedNamedLocationRange
    #>

    [CmdletBinding()]
    param()

    $allLocations = Get-MgIdentityConditionalAccessNamedLocation -All -ErrorAction Stop
    $ranges = [System.Collections.Generic.List[string]]::new()

    foreach ($location in $allLocations) {
        $props = $location.AdditionalProperties
        if ($null -eq $props) { continue }
        if ($props['@odata.type'] -ne '#microsoft.graph.ipNamedLocation') { continue }
        if (-not [bool]$props['isTrusted']) { continue }

        foreach ($range in @($props['ipRanges'])) {
            $cidr = [string]$range['cidrAddress']
            if (-not [string]::IsNullOrWhiteSpace($cidr)) { $ranges.Add($cidr) }
        }
    }

    return $ranges.ToArray()
}

function Test-IpAllowListed {
    <#
    .SYNOPSIS
        Determines whether an IP address is inside any allow-list CIDR range.
 
    .DESCRIPTION
        Pure function. Returns $true when the IP falls within any of the supplied trusted CIDR
        ranges, otherwise $false.
 
    .PARAMETER IpAddress
        The IP address to test.
 
    .PARAMETER AllowListRange
        The trusted CIDR ranges (typically from Get-TrustedNamedLocationRange).
 
    .EXAMPLE
        Test-IpAllowListed -IpAddress '203.0.113.5' -AllowListRange $trustedRanges
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$IpAddress,

        [Parameter(Mandatory = $false)]
        [AllowNull()]
        [string[]]$AllowListRange
    )

    if ($null -eq $AllowListRange -or $AllowListRange.Count -eq 0) { return $false }

    foreach ($cidr in $AllowListRange) {
        if ([string]::IsNullOrWhiteSpace($cidr)) { continue }
        if (Test-IpInCidrRange -IpAddress $IpAddress -Cidr $cidr) { return $true }
    }
    return $false
}

function Add-IpToRestrictedNamedLocation {
    <#
    .SYNOPSIS
        Adds IP address(es) to an existing restricted IP Named Location.
 
    .DESCRIPTION
        Fetches the named location by display name, appends any new IP ranges (normalised to
        CIDR and de-duplicated against existing ranges) and updates it. The named location must
        already exist. Honours -WhatIf so the caller can run in dry-run mode. Returns the array
        of CIDR ranges that were newly added (empty when nothing changed).
 
    .PARAMETER NamedLocationDisplayName
        Exact display name of the restricted IP Named Location, e.g. 'Restriced IPs'.
 
    .PARAMETER IpAddress
        One or more IP addresses (or CIDR ranges) to add.
 
    .EXAMPLE
        Add-IpToRestrictedNamedLocation -NamedLocationDisplayName 'Restriced IPs' -IpAddress '203.0.113.5'
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory = $true)]
        [string]$NamedLocationDisplayName,

        [Parameter(Mandatory = $true)]
        [string[]]$IpAddress,

        # Entra allows ~2000 ranges per named location; refuse to grow past a safe ceiling.
        [Parameter(Mandatory = $false)]
        [int]$MaxRanges = 1800
    )

    $allLocations = Get-MgIdentityConditionalAccessNamedLocation -All -ErrorAction Stop
    $target = $allLocations | Where-Object { $_.DisplayName -eq $NamedLocationDisplayName } | Select-Object -First 1
    if (-not $target) {
        throw "Named Location '$NamedLocationDisplayName' not found. Pre-create it in Entra (Conditional Access -> Named locations) before running."
    }

    $props = $target.AdditionalProperties
    $existingRanges = @($props['ipRanges'])

    # Fail closed: never silently rewrite a Conditional Access trust flag we could not read.
    if (-not $props.ContainsKey('isTrusted')) {
        throw "Named Location '$NamedLocationDisplayName' returned no isTrusted value; refusing to update."
    }
    $isTrusted = [bool]$props['isTrusted']

    $seenCidrs = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    $rangeList = [System.Collections.Generic.List[object]]::new()
    foreach ($range in $existingRanges) {
        $rangeList.Add($range)
        $cidr = [string]$range['cidrAddress']
        if (-not [string]::IsNullOrWhiteSpace($cidr)) { [void]$seenCidrs.Add($cidr) }
    }

    $addedCidrs = [System.Collections.Generic.List[string]]::new()
    foreach ($ip in $IpAddress) {
        $cidr = ConvertTo-CanaryCidr -IpAddress $ip
        if ([string]::IsNullOrWhiteSpace($cidr)) {
            Write-Warning "Skipping invalid IP address '$ip'"
            continue
        }
        if ($seenCidrs.Contains($cidr)) { continue }

        $odataType = if ($cidr -match ':') { '#microsoft.graph.iPv6CidrRange' } else { '#microsoft.graph.iPv4CidrRange' }
        $rangeList.Add(@{ '@odata.type' = $odataType; cidrAddress = $cidr })
        [void]$seenCidrs.Add($cidr)
        $addedCidrs.Add($cidr)
    }

    if ($addedCidrs.Count -eq 0) { return @() }

    if ($rangeList.Count -gt $MaxRanges) {
        throw "Named Location '$NamedLocationDisplayName' would hold $($rangeList.Count) ranges, exceeding the $MaxRanges ceiling. Refusing to update — investigate a possible alert storm."
    }

    if ($PSCmdlet.ShouldProcess($NamedLocationDisplayName, "Add $($addedCidrs.Count) IP range(s): $($addedCidrs -join ', ')")) {
        $bodyParams = @{
            '@odata.type' = '#microsoft.graph.ipNamedLocation'
            displayName   = $target.DisplayName
            isTrusted     = $isTrusted
            ipRanges      = @($rangeList)
        }
        Update-MgIdentityConditionalAccessNamedLocation -NamedLocationId $target.Id -BodyParameter $bodyParams -ErrorAction Stop
    }

    return $addedCidrs.ToArray()
}

function Get-SignInUserByIp {
    <#
    .SYNOPSIS
        Returns the unique users who signed in from a given IP address within a lookback window.
 
    .DESCRIPTION
        Queries the Entra sign-in logs filtered by ipAddress and createdDateTime. Returns one
        object per unique user with UserId and UserPrincipalName.
 
    .PARAMETER IpAddress
        The source IP address to correlate.
 
    .PARAMETER LookbackHours
        How many hours back to search the sign-in logs. Default 2.
 
    .EXAMPLE
        Get-SignInUserByIp -IpAddress '203.0.113.5' -LookbackHours 2
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$IpAddress,

        [Parameter(Mandatory = $false)]
        [int]$LookbackHours = 2
    )

    $since = (Get-Date).ToUniversalTime().AddHours(-$LookbackHours).ToString('yyyy-MM-ddTHH:mm:ssZ')

    # Defence in depth: the caller must already have validated the IP, but never trust it.
    # Reject anything that is not a well-formed IP address so it cannot rewrite the OData query.
    [System.Net.IPAddress]$parsedIp = $null
    if (-not [System.Net.IPAddress]::TryParse($IpAddress, [ref]$parsedIp)) {
        throw "Get-SignInUserByIp: '$IpAddress' is not a valid IP address; refusing to build a sign-in filter from it."
    }

    # Escape single quotes for OData even though the value is already validated as an IP.
    $safeIp = $parsedIp.ToString().Replace("'", "''")
    $filter = "ipAddress eq '$safeIp' and createdDateTime ge $since"
    $signIns = Get-MgAuditLogSignIn -Filter $filter -All -ErrorAction Stop

    $users = @{}
    foreach ($signIn in $signIns) {
        $userId = [string]$signIn.UserId
        if ([string]::IsNullOrWhiteSpace($userId)) { continue }
        if (-not $users.ContainsKey($userId)) {
            $users[$userId] = [PSCustomObject]@{
                UserId            = $userId
                UserPrincipalName = [string]$signIn.UserPrincipalName
            }
        }
    }

    return @($users.Values)
}

function Revoke-UserAccess {
    <#
    .SYNOPSIS
        Revokes a user's active sign-in sessions and invalidates their refresh tokens.
 
    .DESCRIPTION
        Performs both mitigation actions from the Thinkst response pipeline: revokeSignInSessions
        and invalidateAllRefreshTokens. Honours -WhatIf so the caller can run in dry-run mode.
 
    .PARAMETER UserId
        The object ID (or userPrincipalName) of the user to lock out.
 
    .EXAMPLE
        Revoke-UserAccess -UserId '00000000-0000-0000-0000-000000000000'
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory = $true)]
        [string]$UserId
    )

    if ($PSCmdlet.ShouldProcess($UserId, 'Revoke sign-in sessions and invalidate refresh tokens')) {
        Revoke-MgUserSignInSession -UserId $UserId -ErrorAction Stop | Out-Null
        Invoke-MgGraphRequest -Method POST `
            -Uri "https://graph.microsoft.com/v1.0/users/$UserId/invalidateAllRefreshTokens" `
            -Body '{}' -ErrorAction Stop | Out-Null
    }
}

# 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',
    'Confirm-CanaryConnectivity',
    'Get-CanaryIncident',
    'ConvertFrom-CanaryIncident',
    'Set-CanaryIncidentAcknowledged',
    'ConvertTo-CanaryCidr',
    'Test-IpInCidrRange',
    'Get-TrustedNamedLocationRange',
    'Test-IpAllowListed',
    'Add-IpToRestrictedNamedLocation',
    'Get-SignInUserByIp',
    'Revoke-UserAccess'
)