functions/Get-EasyPIMConfiguration.ps1

#Requires -Version 5.1

# PSScriptAnalyzer suppressions for this orchestration configuration function
# Write-Host calls are intentional for user interaction and configuration display

function Get-EasyPIMConfiguration {
    <#
    .SYNOPSIS
        Loads EasyPIM configuration from a file or Azure Key Vault.
 
    .DESCRIPTION
        This function loads configuration data for EasyPIM operations from either a JSON configuration file
        or from Azure Key Vault. The configuration is returned as a hashtable to ensure compatibility
        with ContainsKey() method calls.
 
    .PARAMETER ConfigFilePath
        Path to a JSON configuration file.
 
    .PARAMETER KeyVaultName
        Name of the Azure Key Vault containing the configuration.
 
    .PARAMETER SecretName
        Name of the secret in Azure Key Vault containing the configuration.
 
    .PARAMETER Version
        Optional specific version of the Key Vault secret to retrieve.
        Use this parameter if you encounter corrupted secrets due to Azure Key Vault API/Portal sync issues.
 
    .EXAMPLE
        $config = Get-EasyPIMConfiguration -ConfigFilePath "config.json"
    Supports // and /* */ comments when Remove-JsonComments helper is available.
 
    .EXAMPLE
        $config = Get-EasyPIMConfiguration -KeyVaultName "MyVault" -SecretName "PIMConfig"
    Requires Az.KeyVault module and read access to the specified secret.
 
    .EXAMPLE
        $config = Get-EasyPIMConfiguration -KeyVaultName "MyVault" -SecretName "PIMConfig" -Version "abc123def456"
    Retrieves a specific version of the secret, useful for recovery from corrupted current versions.
 
    .EXAMPLE
        $config = Get-EasyPIMConfiguration -KeyVaultName "MyVault" -SecretName "PIMConfig" -Verbose
    Uses verbose output to see automatic recovery attempts if the current version is corrupted.
 
    .NOTES
        Azure Key Vault Troubleshooting:
        - If you encounter truncated or corrupted secrets, this function will automatically attempt recovery
        - The function checks recent versions and attempts to use a valid one
        - Use the -Version parameter to manually specify a known good version
        - Portal and API can sometimes show different "current" versions due to sync delays
        - For comprehensive diagnostics, see: EasyPIM/Documentation/KeyVault-Troubleshooting.md
    #>

    [CmdletBinding(DefaultParameterSetName = 'FilePath')]
    param (
        [Parameter(Mandatory = $true, ParameterSetName = 'FilePath')]
        [string]$ConfigFilePath,

        [Parameter(Mandatory = $true, ParameterSetName = 'KeyVault')]
        [string]$KeyVaultName,

        [Parameter(Mandatory = $true, ParameterSetName = 'KeyVault')]
        [string]$SecretName,

        [Parameter(Mandatory = $false, ParameterSetName = 'KeyVault')]
        [string]$Version
    )

    try {
        if ($PSCmdlet.ParameterSetName -eq 'KeyVault') {
            if ($Version) {
                Write-Host "Reading configuration from Key Vault '$KeyVaultName', secret '$SecretName', version '$Version'" -ForegroundColor Gray
            } else {
                Write-Host "Reading configuration from Key Vault '$KeyVaultName', secret '$SecretName'" -ForegroundColor Gray
            }

            # Import Az.KeyVault module if not already loaded
            if (-not (Get-Module -Name Az.KeyVault)) {
                Write-Verbose "Importing Az.KeyVault module"
                Import-Module Az.KeyVault -Force
            }

            # Check Az.KeyVault version for debugging
            $azKeyVaultVersion = (Get-Module Az.KeyVault).Version
            Write-Verbose "Using Az.KeyVault version: $azKeyVaultVersion"

            # Get secret from Key Vault with validation and recovery (with retry logic)
            $maxRetries = 3
            $retryDelay = 2
            $secret = $null

            for ($attempt = 1; $attempt -le $maxRetries; $attempt++) {
                try {
                    Write-Verbose "Key Vault retrieval attempt $attempt of $maxRetries"

                    if ($Version) {
                        $secret = Get-AzKeyVaultSecret -VaultName $KeyVaultName -Name $SecretName -Version $Version
                        Write-Verbose "Using specific version: $Version"
                    } else {
                        $secret = Get-AzKeyVaultSecret -VaultName $KeyVaultName -Name $SecretName
                    }

                    if ($secret) {
                        Write-Verbose "Successfully retrieved secret on attempt $attempt"
                        break
                    }
                } catch {
                    Write-Verbose "Key Vault retrieval attempt $attempt failed: $($_.Exception.Message)"
                    if ($attempt -eq $maxRetries) {
                        throw "Failed to retrieve secret after $maxRetries attempts: $($_.Exception.Message)"
                    }
                    Start-Sleep -Seconds $retryDelay
                }
            }

            if (-not $secret) {
                throw "Secret '$SecretName' not found in Key Vault '$KeyVaultName' after $maxRetries attempts"
            }

            Write-Verbose "Retrieved secret version: $($secret.Version)"
            Write-Verbose "Secret created: $($secret.Created)"

            # Handle both old and new Az.KeyVault module versions with robust compatibility
            $jsonString = $null
            $extractionRetries = 2

            for ($extractAttempt = 1; $extractAttempt -le $extractionRetries; $extractAttempt++) {
                try {
                    Write-Verbose "Secret value extraction attempt $extractAttempt of $extractionRetries"

                    # Method 1: Try SecretValueText (older Az.KeyVault versions)
                    if ($secret.SecretValueText) {
                        $jsonString = $secret.SecretValueText
                        Write-Verbose "Retrieved secret using SecretValueText (older Az.KeyVault)"
                    }
                    # Method 2: Try ConvertFrom-SecureString -AsPlainText (newer PowerShell versions)
                    elseif ($secret.SecretValue) {
                        try {
                            $jsonString = $secret.SecretValue | ConvertFrom-SecureString -AsPlainText
                            Write-Verbose "Retrieved secret using ConvertFrom-SecureString -AsPlainText"

                            # Validate the result is not empty
                            if ([string]::IsNullOrWhiteSpace($jsonString)) {
                                throw "ConvertFrom-SecureString returned empty result"
                            }
                        } catch {
                            Write-Verbose "ConvertFrom-SecureString failed: $($_.Exception.Message), trying Marshal method"
                            # Method 3: Fallback to Marshal method for compatibility
                            try {
                                $jsonString = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto(
                                    [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secret.SecretValue)
                                )
                                Write-Verbose "Retrieved secret using Marshal method"
                            } catch {
                                throw "Failed to retrieve secret using Marshal method: $($_.Exception.Message)"
                            }
                        }
                    } else {
                        throw "Unable to retrieve secret value from Key Vault response - no SecretValue or SecretValueText property found"
                    }

                    # Validate extraction was successful
                    if (-not [string]::IsNullOrWhiteSpace($jsonString) -and $jsonString.Length -gt 10) {
                        break
                    } else {
                        throw "Secret value is empty, null, or too short after extraction (length: $($jsonString.Length))"
                    }

                } catch {
                    Write-Verbose "Secret extraction attempt $extractAttempt failed: $($_.Exception.Message)"
                    if ($extractAttempt -eq $extractionRetries) {
                        # Final attempt failed, throw the error
                        throw
                    }
                    # Brief delay before retry
                    Start-Sleep -Seconds 1
                }
            }

            # Final validation
            if ([string]::IsNullOrWhiteSpace($jsonString)) {
                throw "Secret value is empty or null after $extractionRetries extraction attempts"
            }

            Write-Verbose "Secret retrieved successfully, length: $($jsonString.Length) characters"

            # Output secret version information for troubleshooting
            Write-Host "✅ Using Key Vault secret version: $($secret.Version) (created: $($secret.Created.ToString('MM/dd/yyyy HH:mm')))" -ForegroundColor Green

            # Enhanced validation for Azure Key Vault corruption issues
            # Check for common Key Vault corruption patterns
            if ($jsonString.Length -le 10 -and $jsonString.Trim() -in @('{', '}', '"{', '"}', '{{', '}}')) {
                Write-Warning "Detected potentially corrupted Key Vault secret (length: $($jsonString.Length), content: '$jsonString')"
                Write-Warning "This may be due to Azure Key Vault version synchronization issues between Portal and API"

                # Attempt recovery by checking recent versions
                Write-Verbose "Attempting to recover from Key Vault version history..."
                try {
                    $allVersions = Get-AzKeyVaultSecret -VaultName $KeyVaultName -Name $SecretName -IncludeVersions
                    $recentVersions = $allVersions | Sort-Object Created -Descending | Select-Object -First 5

                    foreach ($version in $recentVersions) {
                        if ($version.Version -eq $secret.Version) {
                            continue # Skip the current corrupted version
                        }

                        Write-Verbose "Trying version $($version.Version) from $($version.Created)..."
                        $testSecret = Get-AzKeyVaultSecret -VaultName $KeyVaultName -Name $SecretName -Version $version.Version
                        $testContent = $null

                        # Use the same retrieval methods
                        if ($testSecret.SecretValueText) {
                            $testContent = $testSecret.SecretValueText
                        } elseif ($testSecret.SecretValue) {
                            try {
                                $testContent = $testSecret.SecretValue | ConvertFrom-SecureString -AsPlainText
                            } catch {
                                try {
                                    $testContent = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto(
                                        [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($testSecret.SecretValue)
                                    )
                                } catch {
                                    continue # Skip this version
                                }
                            }
                        }

                        # Check if this version has valid content
                        if ($testContent -and $testContent.Length -gt 50 -and $testContent.Trim().StartsWith('{') -and $testContent.Trim().EndsWith('}')) {
                            Write-Host "🔧 Recovered from Key Vault version $($version.Version) (created: $($version.Created))" -ForegroundColor Yellow
                            Write-Host " Original version $($secret.Version) appears corrupted, using recovered content" -ForegroundColor Yellow
                            $jsonString = $testContent
                            break
                        }
                    }
                } catch {
                    Write-Verbose "Version recovery failed: $($_.Exception.Message)"
                }

                # If still corrupted after recovery attempt
                if ($jsonString.Length -le 10) {
                    $errorMsg = @"
Key Vault secret appears to be corrupted or truncated.
- Current version: $($secret.Version)
- Content length: $($jsonString.Length) characters
- Content: '$jsonString'
 
This is often caused by Azure Key Vault API/Portal synchronization issues.
 
Troubleshooting steps:
1. Check the Azure Portal to verify the secret content
2. Try specifying a specific version: Get-EasyPIMConfiguration -KeyVaultName '$KeyVaultName' -SecretName '$SecretName' -Version 'specific-version-id'
3. Re-upload the secret if corruption persists
4. Consider using a local configuration file as backup
 
For more information, see: https://docs.microsoft.com/en-us/azure/key-vault/general/about-keys-secrets-certificates
"@

                    throw $errorMsg
                }
            }
        } else {
            Write-Host "Reading from file '$ConfigFilePath'" -ForegroundColor Gray

            if (-not (Test-Path $ConfigFilePath)) {
                throw "Configuration file not found: $ConfigFilePath"
            }

            $jsonString = Get-Content -Path $ConfigFilePath -Raw
        }

        # Normalize: strip supported // and /* */ comments if helper exists
        if (Get-Command -Name Remove-JsonComments -ErrorAction SilentlyContinue) {
            $jsonString = $jsonString | Remove-JsonComments
        }

        # Enhanced JSON parsing with better error diagnostics
        try {
            # Pre-validate JSON content before parsing
            $trimmedJson = $jsonString.Trim()

            # Debug information for troubleshooting
            Write-Verbose "JSON content length: $($jsonString.Length) characters"
            Write-Verbose "Trimmed JSON length: $($trimmedJson.Length) characters"
            Write-Verbose "JSON starts with: '$($trimmedJson.Substring(0, [Math]::Min(50, $trimmedJson.Length)))...'"
            Write-Verbose "JSON ends with: '...$($trimmedJson.Substring([Math]::Max(0, $trimmedJson.Length - 50)))"

            # Basic JSON structure validation
            if (-not $trimmedJson.StartsWith('{') -or -not $trimmedJson.EndsWith('}')) {
                throw "Invalid JSON structure: content does not start with '{' and end with '}'"
            }

            if ($trimmedJson.Length -lt 10) {
                throw "JSON content appears to be too short ($($trimmedJson.Length) characters): '$trimmedJson'"
            }

            # Convert JSON to PSCustomObject and return it directly
            # The newer orchestrator functions work with PSCustomObjects
            $result = $trimmedJson | ConvertFrom-Json

            Write-Verbose "Configuration loaded successfully"
            return $result

        } catch {
            # Enhanced error reporting for JSON parsing failures
            $errorDetails = @"
JSON Parsing Error Details:
- Error: $($_.Exception.Message)
- JSON Length: $($jsonString.Length) characters
- Source: $($PSCmdlet.ParameterSetName)
- Content Preview (first 200 chars): '$($jsonString.Substring(0, [Math]::Min(200, $jsonString.Length)))'
- Content Preview (last 200 chars): '$($jsonString.Substring([Math]::Max(0, $jsonString.Length - 200)))'
- PowerShell Version: $($PSVersionTable.PSVersion)
- OS: $($PSVersionTable.OS)
 
This error often occurs when:
1. Key Vault secret is truncated or corrupted (common in CI/CD environments)
2. Character encoding differences between environments
3. Network issues during secret retrieval
4. Az.KeyVault module version differences
 
Troubleshooting:
- Try running locally to confirm the secret content is valid
- Check if the same secret works in different environments
- Consider using a specific secret version: -Version 'version-id'
- Verify Az.KeyVault module is up to date
"@

            Write-Error $errorDetails
            throw
        }

    } catch {
        Write-Error "Failed to load configuration: $($_.Exception.Message)"
        throw
    }
}