Modules/Private/30-Credentials.ps1

function Test-RangerKeyVaultUri {
    param(
        [AllowNull()]
        $Value
    )

    $Value -is [string] -and $Value.StartsWith('keyvault://')
}

function ConvertFrom-RangerKeyVaultUri {
    param(
        [Parameter(Mandatory = $true)]
        [string]$Uri
    )

    if (-not (Test-RangerKeyVaultUri -Value $Uri)) {
        throw "Invalid Key Vault URI: $Uri"
    }

    $parts = $Uri.Substring(11).Split('/')
    if ($parts.Count -lt 2) {
        throw "Invalid Key Vault URI format: $Uri"
    }

    [ordered]@{
        VaultName  = $parts[0]
        SecretName = $parts[1]
        Version    = if ($parts.Count -gt 2) { $parts[2] } else { $null }
    }
}

function ConvertTo-RangerSecureString {
    param(
        [AllowNull()]
        [string]$Value
    )

    if ($null -eq $Value) {
        return $null
    }

    $secureValue = [System.Security.SecureString]::new()
    foreach ($character in $Value.ToCharArray()) {
        $secureValue.AppendChar($character)
    }
    $secureValue.MakeReadOnly()
    return $secureValue
}

function Get-RangerSecretFromUri {
    param(
        [Parameter(Mandatory = $true)]
        [string]$Uri,

        [switch]$AsPlainText
    )

    $parsed = ConvertFrom-RangerKeyVaultUri -Uri $Uri
    $providerFailures = [System.Collections.Generic.List[string]]::new()

    if (Test-RangerCommandAvailable -Name 'Get-AzKeyVaultSecret') {
        try {
            $secretParams = @{
                VaultName   = $parsed.VaultName
                Name        = $parsed.SecretName
                ErrorAction = 'Stop'
            }

            if ($parsed.Version) {
                $secretParams.Version = $parsed.Version
            }

            $secret = Get-AzKeyVaultSecret @secretParams
            if ($AsPlainText) {
                return ConvertTo-RangerPlainText -Value $secret.SecretValue
            }

            return $secret.SecretValue
        }
        catch {
            [void]$providerFailures.Add("Az.KeyVault failed: $($_.Exception.Message)")
        }
    }

    if (Test-RangerCommandAvailable -Name 'az') {
        try {
            $arguments = @('keyvault', 'secret', 'show', '--vault-name', $parsed.VaultName, '--name', $parsed.SecretName, '--query', 'value', '-o', 'tsv')
            if ($parsed.Version) {
                $arguments += @('--version', $parsed.Version)
            }

            $value = & az @arguments
            if ($LASTEXITCODE -ne 0) {
                throw "Azure CLI exited with code $LASTEXITCODE."
            }

            if ($AsPlainText) {
                return $value
            }

            return (ConvertTo-RangerSecureString -Value $value)
        }
        catch {
            [void]$providerFailures.Add("Azure CLI failed: $($_.Exception.Message)")
        }
    }

    if ($providerFailures.Count -gt 0) {
        $joined = $providerFailures -join ' '
        $dnsPatterns = @('getaddrinfo failed', 'No such host is known', 'could not be resolved', 'name or service not known')
        $isDnsFailure = $false
        foreach ($pattern in $dnsPatterns) {
            if ($joined -match [regex]::Escape($pattern)) { $isDnsFailure = $true; break }
        }

        if ($isDnsFailure) {
            $kvHost = "$($parsed.VaultName).vault.azure.net"
            $message = @(
                "Key Vault hostname '$kvHost' could not be resolved.",
                "Likely causes: (1) VPN or private endpoint network not connected, (2) Key Vault name '$($parsed.VaultName)' is incorrect in your config, (3) DNS resolver cannot reach the private zone.",
                "Action: verify the Key Vault name, confirm you are on the required network, or enable 'behavior.promptForMissingCredentials' in your config to be prompted interactively when KV is unreachable.",
                "Provider detail: $joined"
            ) -join ' '
            $ex = [System.Management.Automation.RuntimeException]::new($message)
            $ex.Data['RangerKeyVaultDnsFailure'] = $true
            $ex.Data['RangerKeyVaultHost'] = $kvHost
            $ex.Data['RangerKeyVaultUri'] = $Uri
            throw $ex
        }

        throw "Could not resolve Key Vault secret '$Uri'. $joined"
    }

    throw 'Neither Az.KeyVault nor the Azure CLI is available for Key Vault secret resolution.'
}

function Resolve-RangerPasswordValue {
    param(
        $CredentialBlock
    )

    if (-not $CredentialBlock) {
        return $null
    }

    if ($CredentialBlock.passwordSecureString -is [securestring]) {
        return $CredentialBlock.passwordSecureString
    }

    if ($CredentialBlock.password) {
        return (ConvertTo-RangerSecureString -Value ([string]$CredentialBlock.password))
    }

    if ($CredentialBlock.passwordRef) {
        return Get-RangerSecretFromUri -Uri $CredentialBlock.passwordRef
    }

    return $null
}

function Resolve-RangerCredentialDefinition {
    param(
        [string]$Name,
        $CredentialBlock,
        [PSCredential]$OverrideCredential,
        [bool]$AllowPrompt = $true
    )

    if ($OverrideCredential) {
        return $OverrideCredential
    }

    if ($CredentialBlock -is [PSCredential]) {
        return $CredentialBlock
    }

    if ($CredentialBlock -and $CredentialBlock.username) {
        try {
            $password = Resolve-RangerPasswordValue -CredentialBlock $CredentialBlock
            if ($password) {
                return [PSCredential]::new([string]$CredentialBlock.username, $password)
            }
        }
        catch {
            $isDnsFailure = $false
            if ($_.Exception.Data -and $_.Exception.Data.Contains('RangerKeyVaultDnsFailure')) {
                $isDnsFailure = [bool]$_.Exception.Data['RangerKeyVaultDnsFailure']
            }

            if ($isDnsFailure -and $AllowPrompt) {
                Write-Warning $_.Exception.Message
                Write-Warning "Falling back to interactive prompt for '$Name' credential because Key Vault is unreachable."
            }
            else {
                throw
            }
        }
    }

    if ($AllowPrompt -and $Name -ne 'azure') {
        try {
            return Get-Credential -Message "Enter the $Name credential for Azure Local Ranger"
        }
        catch {
            return $null
        }
    }

    return $null
}

function Resolve-RangerAzureCredentialSettings {
    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$Config,

        [switch]$SkipSecretResolution
    )

    $settings = if ($Config.credentials.azure) { ConvertTo-RangerHashtable -InputObject $Config.credentials.azure } else { [ordered]@{} }
    if (-not $settings.method) {
        $settings.method = 'existing-context'
    }

    if (-not $settings.Contains('tenantId') -and -not [string]::IsNullOrWhiteSpace($Config.targets.azure.tenantId)) {
        $settings.tenantId = $Config.targets.azure.tenantId
    }

    if (-not $settings.Contains('subscriptionId') -and -not [string]::IsNullOrWhiteSpace($Config.targets.azure.subscriptionId)) {
        $settings.subscriptionId = $Config.targets.azure.subscriptionId
    }

    if (-not $settings.Contains('useAzureCliFallback')) {
        $settings.useAzureCliFallback = $true
    }

    if (-not $SkipSecretResolution) {
        if ($settings.clientSecretRef) {
            $settings.clientSecretSecureString = Get-RangerSecretFromUri -Uri $settings.clientSecretRef
        }
        elseif ($settings.clientSecret) {
            $settings.clientSecretSecureString = ConvertTo-RangerSecureString -Value ([string]$settings.clientSecret)
        }
    }

    return $settings
}

function Resolve-RangerCredentialMap {
    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$Config,

        [hashtable]$Overrides
    )

    $allowPrompt = [bool]$Config.behavior.promptForMissingCredentials
    $overrides = if ($Overrides) { $Overrides } else { @{} }

    # v2.6.3 (#295): only prompt for BMC / switch / firewall credentials when
    # the relevant collector is in scope AND a target is configured. Previous
    # behavior always ran the prompt chain for every credential name, which
    # surfaced Get-Credential dialogs for BMC / device creds on runs where no
    # BMC / network device work was going to happen anyway.
    $selectedCollectors = Resolve-RangerSelectedCollectors -Config $Config

    $bmcInScope = ($selectedCollectors | Where-Object { 'bmc' -in @($_.RequiredTargets) }).Count -gt 0 -and `
                  (@($Config.targets.bmc.endpoints).Count -gt 0)
    $switchInScope   = @($Config.targets.switches).Count -gt 0
    $firewallInScope = @($Config.targets.firewalls).Count -gt 0

    # If the caller supplied an explicit credential override, honor it even
    # when the target list is empty — the operator asked for this credential
    # by name, presumably because they're about to populate the target list
    # interactively or they want to validate the credential itself.
    if ($overrides.bmc)      { $bmcInScope      = $true }
    if ($overrides.switch)   { $switchInScope   = $true }
    if ($overrides.firewall) { $firewallInScope = $true }

    [ordered]@{
        azure    = Resolve-RangerAzureCredentialSettings -Config $Config
        cluster  = Resolve-RangerCredentialDefinition -Name 'cluster' -CredentialBlock $Config.credentials.cluster -OverrideCredential $overrides.cluster -AllowPrompt $allowPrompt
        domain   = Resolve-RangerCredentialDefinition -Name 'domain' -CredentialBlock $Config.credentials.domain -OverrideCredential $overrides.domain -AllowPrompt $allowPrompt
        bmc      = if ($bmcInScope) { Resolve-RangerCredentialDefinition -Name 'bmc' -CredentialBlock $Config.credentials.bmc -OverrideCredential $overrides.bmc -AllowPrompt $allowPrompt } else { $null }
        firewall = if ($firewallInScope) { Resolve-RangerCredentialDefinition -Name 'firewall' -CredentialBlock $Config.credentials.firewall -OverrideCredential $overrides.firewall -AllowPrompt $allowPrompt } else { $null }
        switch   = if ($switchInScope) { Resolve-RangerCredentialDefinition -Name 'switch' -CredentialBlock $Config.credentials.switch -OverrideCredential $overrides.switch -AllowPrompt $allowPrompt } else { $null }
    }
}