diff --git a/.gitea/workflows/publish-psgallery.yml b/.gitea/workflows/publish-psgallery.yml new file mode 100644 index 0000000..4b21bff --- /dev/null +++ b/.gitea/workflows/publish-psgallery.yml @@ -0,0 +1,296 @@ +name: Publish to PowerShell Gallery + +on: + pull_request: + types: [closed] + branches: [main] + +jobs: + build: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Install PowerShell 7 (if not present) + shell: bash + run: | + set -euo pipefail + if command -v pwsh >/dev/null 2>&1; then + echo "pwsh already installed: $(pwsh --version)" + exit 0 + fi + + sudo apt-get update + sudo apt-get install -y --no-install-recommends wget ca-certificates apt-transport-https gnupg + + source /etc/os-release + wget -q "https://packages.microsoft.com/config/ubuntu/${VERSION_ID}/packages-microsoft-prod.deb" -O /tmp/ms-prod.deb + sudo dpkg -i /tmp/ms-prod.deb + rm -f /tmp/ms-prod.deb + + sudo apt-get update + sudo apt-get install -y powershell + pwsh --version + + - name: Build and test module + shell: pwsh + run: ./build.ps1 -RunTests + + - name: Validate module manifest + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $manifestPath = Join-Path $PWD 'Module/PSInfisicalAPI/PSInfisicalAPI.psd1' + $manifest = Test-ModuleManifest -Path $manifestPath + Write-Host "Manifest OK: $($manifest.Name) $($manifest.Version)" + + - name: Upload module artifact + uses: actions/upload-artifact@v4 + with: + name: PSInfisicalAPI-module + path: Module/PSInfisicalAPI + if-no-files-found: error + retention-days: 7 + + release: + needs: build + if: ${{ success() && github.event.pull_request.merged == true }} + runs-on: ubuntu-latest + outputs: + version: ${{ steps.meta.outputs.version }} + tag: ${{ steps.meta.outputs.tag }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install PowerShell 7 (if not present) + shell: bash + run: | + set -euo pipefail + if command -v pwsh >/dev/null 2>&1; then + echo "pwsh already installed: $(pwsh --version)" + exit 0 + fi + + sudo apt-get update + sudo apt-get install -y --no-install-recommends wget ca-certificates apt-transport-https gnupg + + source /etc/os-release + wget -q "https://packages.microsoft.com/config/ubuntu/${VERSION_ID}/packages-microsoft-prod.deb" -O /tmp/ms-prod.deb + sudo dpkg -i /tmp/ms-prod.deb + rm -f /tmp/ms-prod.deb + + sudo apt-get update + sudo apt-get install -y powershell + pwsh --version + + - name: Download module artifact + uses: actions/download-artifact@v4 + with: + name: PSInfisicalAPI-module + path: Module/PSInfisicalAPI + + - name: Resolve module version and tag + id: meta + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $manifestPath = Join-Path $PWD 'Module/PSInfisicalAPI/PSInfisicalAPI.psd1' + $manifest = Test-ModuleManifest -Path $manifestPath + $version = $manifest.Version.ToString() + $tag = $version + Write-Host "Module version: $version" + Write-Host "Release tag: $tag" + "version=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + "tag=$tag" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + + - name: Package module as release asset + shell: pwsh + env: + VERSION: ${{ steps.meta.outputs.version }} + run: | + $ErrorActionPreference = 'Stop' + $zipPath = Join-Path $PWD "PSInfisicalAPI-$($env:VERSION).zip" + if (Test-Path $zipPath) { Remove-Item $zipPath -Force } + Compress-Archive -Path 'Module/PSInfisicalAPI/*' -DestinationPath $zipPath -Force + Write-Host "Created: $zipPath ($([math]::Round((Get-Item $zipPath).Length / 1KB, 1)) KB)" + + - name: Create Gitea release + shell: pwsh + env: + GITEA_TOKEN: ${{ github.token }} + API_URL: ${{ github.api_url }} + REPO: ${{ github.repository }} + TAG: ${{ steps.meta.outputs.tag }} + VERSION: ${{ steps.meta.outputs.version }} + COMMIT_SHA: ${{ github.sha }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + SERVER_URL: ${{ github.server_url }} + RUN_ID: ${{ github.run_id }} + run: | + $ErrorActionPreference = 'Stop' + if ([string]::IsNullOrWhiteSpace($env:GITEA_TOKEN)) { + throw "github.token is not available; cannot call the Gitea release API." + } + + $shortSha = $env:COMMIT_SHA.Substring(0, [Math]::Min(12, $env:COMMIT_SHA.Length)) + $buildUtc = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + $runUrl = "$($env:SERVER_URL)/$($env:REPO)/actions/runs/$($env:RUN_ID)" + $prUrl = "$($env:SERVER_URL)/$($env:REPO)/pulls/$($env:PR_NUMBER)" + + $changelogSection = '' + if (Test-Path 'CHANGELOG.md') { + $lines = [System.IO.File]::ReadAllLines('CHANGELOG.md') + $start = -1; $end = $lines.Length + for ($i = 0; $i -lt $lines.Length; $i++) { + if ($lines[$i] -match "^##\s+$([regex]::Escape($env:VERSION))\s*$") { $start = $i + 1; continue } + if ($start -ge 0 -and $lines[$i] -match '^##\s+') { $end = $i; break } + } + if ($start -ge 0) { + $changelogSection = ($lines[$start..($end - 1)] -join "`n").Trim() + } + } + + $body = @" + **PSInfisicalAPI $($env:VERSION)** + + | Field | Value | + | --- | --- | + | Version | ``$($env:VERSION)`` | + | Tag | ``$($env:TAG)`` | + | Commit | [``$shortSha``]($($env:SERVER_URL)/$($env:REPO)/commit/$($env:COMMIT_SHA)) | + | Built (UTC) | $buildUtc | + | Merged PR | [#$($env:PR_NUMBER) $($env:PR_TITLE)]($prUrl) by @$($env:PR_AUTHOR) | + | Workflow run | [$($env:RUN_ID)]($runUrl) | + + ## Changes + $(if ($changelogSection) { $changelogSection } else { '_No CHANGELOG section found for this version._' }) + + ## Install + ``````powershell + Install-Module -Name PSInfisicalAPI -RequiredVersion $($env:VERSION) -Scope CurrentUser + `````` + "@ + + $headers = @{ + Authorization = "token $($env:GITEA_TOKEN)" + Accept = 'application/json' + } + $createUri = "$($env:API_URL)/repos/$($env:REPO)/releases" + + $existing = $null + try { + $existing = Invoke-RestMethod -Method Get -Headers $headers ` + -Uri "$createUri/tags/$($env:TAG)" -ErrorAction Stop + } catch { + if ($_.Exception.Response.StatusCode.value__ -ne 404) { throw } + } + if ($existing) { + Write-Host "Release tag '$($env:TAG)' already exists (id=$($existing.id)); skipping release creation." + return + } + + $payload = @{ + tag_name = $env:TAG + target_commitish = $env:COMMIT_SHA + name = "PSInfisicalAPI $($env:VERSION)" + body = $body + draft = $false + prerelease = $false + } | ConvertTo-Json -Depth 4 + + $release = Invoke-RestMethod -Method Post -Uri $createUri -Headers $headers ` + -ContentType 'application/json' -Body $payload + Write-Host "Created release id=$($release.id) at $($release.html_url)" + + $assetPath = Join-Path $PWD "PSInfisicalAPI-$($env:VERSION).zip" + $uploadUri = "$createUri/$($release.id)/assets?name=PSInfisicalAPI-$($env:VERSION).zip" + $fileBytes = [System.IO.File]::ReadAllBytes($assetPath) + Invoke-RestMethod -Method Post -Uri $uploadUri -Headers $headers ` + -ContentType 'application/zip' -Body $fileBytes | Out-Null + Write-Host "Uploaded asset: PSInfisicalAPI-$($env:VERSION).zip" + + publish: + needs: release + if: ${{ success() && github.event.pull_request.merged == true }} + runs-on: ubuntu-latest + steps: + - name: Install PowerShell 7 (if not present) + shell: bash + run: | + set -euo pipefail + if command -v pwsh >/dev/null 2>&1; then + echo "pwsh already installed: $(pwsh --version)" + exit 0 + fi + + sudo apt-get update + sudo apt-get install -y --no-install-recommends wget ca-certificates apt-transport-https gnupg + + source /etc/os-release + wget -q "https://packages.microsoft.com/config/ubuntu/${VERSION_ID}/packages-microsoft-prod.deb" -O /tmp/ms-prod.deb + sudo dpkg -i /tmp/ms-prod.deb + rm -f /tmp/ms-prod.deb + + sudo apt-get update + sudo apt-get install -y powershell + pwsh --version + + - name: Download module artifact + uses: actions/download-artifact@v4 + with: + name: PSInfisicalAPI-module + path: Module/PSInfisicalAPI + + - name: Bootstrap PowerShellGet / NuGet provider + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + Set-PSRepository -Name PSGallery -InstallationPolicy Trusted + if (-not (Get-PackageProvider -Name NuGet -ListAvailable -ErrorAction SilentlyContinue)) { + Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope CurrentUser | Out-Null + } + Get-PackageProvider -Name NuGet | Format-Table Name,Version + + - name: Verify PowerShell Gallery API key is configured + shell: pwsh + env: + PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} + run: | + if ([string]::IsNullOrWhiteSpace($env:PSGALLERY_API_KEY)) { + throw "Repository secret 'PSGALLERY_API_KEY' is not configured." + } + + - name: Re-validate downloaded module manifest + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $manifestPath = Join-Path $PWD 'Module/PSInfisicalAPI/PSInfisicalAPI.psd1' + $manifest = Test-ModuleManifest -Path $manifestPath + Write-Host "Manifest OK: $($manifest.Name) $($manifest.Version)" + + - name: Publish to PowerShell Gallery + shell: pwsh + env: + PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} + run: | + $ErrorActionPreference = 'Stop' + $moduleDir = Join-Path $PWD 'Module/PSInfisicalAPI' + Write-Host "Publishing module from: $moduleDir" + Publish-Module ` + -Path $moduleDir ` + -NuGetApiKey $env:PSGALLERY_API_KEY ` + -Verbose diff --git a/.gitignore b/.gitignore index ed32bf6..707e68d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,9 @@ obj/ Artifacts/ Releases/ -## Module bin output is generated by build.ps1 -Module/PSInfisicalAPI/bin/ +## Module bin output is generated by build.ps1, but tracked so the module is consumable from source +!Module/PSInfisicalAPI/bin/ +!Module/PSInfisicalAPI/bin/** ## VS / Rider / VSCode .vs/ @@ -28,3 +29,6 @@ Thumbs.db TestResults/ *.trx *.coverage + +## Local helper scripts (not part of the module) +scripts/ diff --git a/CHANGELOG.md b/CHANGELOG.md index beecad1..7182481 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,36 +1,832 @@ -# Changelog - -All notable changes to this project will be documented in this file. - -The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) loosely, but version numbers use the build timestamp format `yyyy.MM.dd.HHmm`. - +# Changelog + +All notable changes to this project will be documented in this file. + +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) loosely, but version numbers use the build timestamp format `yyyy.MM.dd.HHmm`. + ## Unreleased +## 2026.06.03.0131 + +- Build produced from commit 7be0b7b42008. +- **Behavior change**: `Get-InfisicalSecrets` and `Get-InfisicalSecret` now default `-ViewSecretValue` to `$true`. Real secret values are returned by default. To request the redacted/hidden response, pass `-ViewSecretValue:$false`. +- `InfisicalSecretMapper` now treats the server-side `` placeholder as a hidden marker rather than a value: when `secretValueHidden=true` (or the placeholder string is detected) `SecretValue` is set to `null` instead of stuffing the literal into a `SecureString`. This prevents downstream consumers (auth, exports, dictionary conversion) from silently using `` as if it were a real secret. + +## Unreleased (carried forward) + +## 2026.06.03.0113 + +- Build produced from commit 09c577ebd0fd. +- Added `InfisicalSecret.GetPlainTextValue()` for direct plain-text access to secret material from PowerShell without needing `Marshal.SecureStringToBSTR`. +- Added `-AsPlainText` switch to `ConvertTo-InfisicalSecretDictionary`; when present the cmdlet emits `Dictionary` instead of the default `Dictionary`. + +## Unreleased (carried forward) + +## 2026.06.03.0057 + +- Build produced from commit 7e5209190ac2. + +## Unreleased (carried forward) + +## 2026.06.03.0056 + +- Build produced from commit 7e5209190ac2. + +## Unreleased (carried forward) + +## 2026.06.03.0055 + +- Build produced from commit 7e5209190ac2. + +## Unreleased (carried forward) + +## 2026.06.03.0047 + +- Build produced from commit 7e5209190ac2. + +## Unreleased (carried forward) + +## 2026.06.03.0046 + +- Build produced from commit 7e5209190ac2. + +## Unreleased (carried forward) + +## 2026.06.03.0032 + +- Build produced from commit c86676010532. + +## Unreleased (carried forward) + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) + +## 2026.06.02.1902 + +- Build produced from commit fa65c18bc171. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) (carried forward) + +## 2026.06.02.1737 + +- Build produced from commit fa65c18bc171. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) + +## 2026.06.02.1902 + +- Build produced from commit fa65c18bc171. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) (carried forward) (carried forward) + +## 2026.06.02.1724 + +- Build produced from commit 5801b4774af5. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) + +## 2026.06.02.1902 + +- Build produced from commit fa65c18bc171. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) (carried forward) + +## 2026.06.02.1737 + +- Build produced from commit fa65c18bc171. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) + +## 2026.06.02.1902 + +- Build produced from commit fa65c18bc171. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) (carried forward) (carried forward) (carried forward) + +## 2026.06.02.1648 + +- Build produced from commit 430e3a00c921. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) + +## 2026.06.02.1902 + +- Build produced from commit fa65c18bc171. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) (carried forward) + +## 2026.06.02.1737 + +- Build produced from commit fa65c18bc171. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) + +## 2026.06.02.1902 + +- Build produced from commit fa65c18bc171. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) (carried forward) (carried forward) + +## 2026.06.02.1724 + +- Build produced from commit 5801b4774af5. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) + +## 2026.06.02.1902 + +- Build produced from commit fa65c18bc171. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) (carried forward) + +## 2026.06.02.1737 + +- Build produced from commit fa65c18bc171. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) + +## 2026.06.02.1902 + +- Build produced from commit fa65c18bc171. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) (carried forward) (carried forward) (carried forward) (carried forward) + ## 2026.06.02.1638 - Build produced from commit 3c47d6ff30ec. +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + ## Unreleased (carried forward) +## 2026.06.02.1902 + +- Build produced from commit fa65c18bc171. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) (carried forward) + +## 2026.06.02.1737 + +- Build produced from commit fa65c18bc171. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) + +## 2026.06.02.1902 + +- Build produced from commit fa65c18bc171. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) (carried forward) (carried forward) + +## 2026.06.02.1724 + +- Build produced from commit 5801b4774af5. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) + +## 2026.06.02.1902 + +- Build produced from commit fa65c18bc171. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) (carried forward) + +## 2026.06.02.1737 + +- Build produced from commit fa65c18bc171. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) + +## 2026.06.02.1902 + +- Build produced from commit fa65c18bc171. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) (carried forward) (carried forward) (carried forward) + +## 2026.06.02.1648 + +- Build produced from commit 430e3a00c921. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) + +## 2026.06.02.1902 + +- Build produced from commit fa65c18bc171. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) (carried forward) + +## 2026.06.02.1737 + +- Build produced from commit fa65c18bc171. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) + +## 2026.06.02.1902 + +- Build produced from commit fa65c18bc171. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) (carried forward) (carried forward) + +## 2026.06.02.1724 + +- Build produced from commit 5801b4774af5. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) + +## 2026.06.02.1902 + +- Build produced from commit fa65c18bc171. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) (carried forward) + +## 2026.06.02.1737 + +- Build produced from commit fa65c18bc171. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) + +## 2026.06.02.1902 + +- Build produced from commit fa65c18bc171. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) (carried forward) (carried forward) (carried forward) (carried forward) (carried forward) + ## 2026.06.02.1611 - Build produced from commit 3c47d6ff30ec. ## Unreleased +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) + +## 2026.06.02.1902 + +- Build produced from commit fa65c18bc171. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) (carried forward) + +## 2026.06.02.1737 + +- Build produced from commit fa65c18bc171. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) + +## 2026.06.02.1902 + +- Build produced from commit fa65c18bc171. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) (carried forward) (carried forward) + +## 2026.06.02.1724 + +- Build produced from commit 5801b4774af5. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) + +## 2026.06.02.1902 + +- Build produced from commit fa65c18bc171. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) (carried forward) + +## 2026.06.02.1737 + +- Build produced from commit fa65c18bc171. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) + +## 2026.06.02.1902 + +- Build produced from commit fa65c18bc171. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) (carried forward) (carried forward) (carried forward) + +## 2026.06.02.1648 + +- Build produced from commit 430e3a00c921. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) + +## 2026.06.02.1902 + +- Build produced from commit fa65c18bc171. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) (carried forward) + +## 2026.06.02.1737 + +- Build produced from commit fa65c18bc171. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) + +## 2026.06.02.1902 + +- Build produced from commit fa65c18bc171. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) (carried forward) (carried forward) + +## 2026.06.02.1724 + +- Build produced from commit 5801b4774af5. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) + +## 2026.06.02.1902 + +- Build produced from commit fa65c18bc171. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) (carried forward) + +## 2026.06.02.1737 + +- Build produced from commit fa65c18bc171. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) + +## 2026.06.02.1902 + +- Build produced from commit fa65c18bc171. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) (carried forward) (carried forward) (carried forward) (carried forward) + ## 2026.06.02.1638 - Build produced from commit 3c47d6ff30ec. -## Unreleased (carried forward) (carried forward) - -### Added - -- Initial repository skeleton, C# `netstandard2.0` project, and PowerShell module layout. -- Centralized logging (`InfisicalLogger`), error types/handler, sanitizer, path utility, and `SecureString` utility. -- Endpoint registry covering `UniversalAuthLogin`, `ListSecrets`, and `RetrieveSecret`, and a `System.Uri`-based URI builder. -- Synchronous HTTP client, JSON/YAML/XML/ENV serializers, and DTO/mapper for secrets. -- Connection model, process-level session manager, Universal Auth and Token Auth providers. -- Cmdlets: `Connect-Infisical`, `Disconnect-Infisical`, `Get-InfisicalSecrets`, `Get-InfisicalSecret`, `ConvertTo-InfisicalSecretDictionary`, `Export-InfisicalSecrets`. -- Build script (`build.ps1`) generating manifest, copying binaries, creating release folders, and supporting unit/integration tests. -- xUnit test project with unit tests and opt-in integration tests. +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) + +## 2026.06.02.1902 + +- Build produced from commit fa65c18bc171. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) (carried forward) + +## 2026.06.02.1737 + +- Build produced from commit fa65c18bc171. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) + +## 2026.06.02.1902 + +- Build produced from commit fa65c18bc171. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) (carried forward) (carried forward) + +## 2026.06.02.1724 + +- Build produced from commit 5801b4774af5. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) + +## 2026.06.02.1902 + +- Build produced from commit fa65c18bc171. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) (carried forward) + +## 2026.06.02.1737 + +- Build produced from commit fa65c18bc171. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) + +## 2026.06.02.1902 + +- Build produced from commit fa65c18bc171. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) (carried forward) (carried forward) (carried forward) + +## 2026.06.02.1648 + +- Build produced from commit 430e3a00c921. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) + +## 2026.06.02.1902 + +- Build produced from commit fa65c18bc171. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) (carried forward) + +## 2026.06.02.1737 + +- Build produced from commit fa65c18bc171. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) + +## 2026.06.02.1902 + +- Build produced from commit fa65c18bc171. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) (carried forward) (carried forward) + +## 2026.06.02.1724 + +- Build produced from commit 5801b4774af5. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) + +## 2026.06.02.1902 + +- Build produced from commit fa65c18bc171. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) (carried forward) + +## 2026.06.02.1737 + +- Build produced from commit fa65c18bc171. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) + +## 2026.06.02.1902 + +- Build produced from commit fa65c18bc171. + +## Unreleased + +## 2026.06.02.1907 + +- Build produced from commit fa65c18bc171. + +## Unreleased (carried forward) (carried forward) (carried forward) (carried forward) (carried forward) (carried forward) (carried forward) + +### Added + +- Initial repository skeleton, C# `netstandard2.0` project, and PowerShell module layout. +- Centralized logging (`InfisicalLogger`), error types/handler, sanitizer, path utility, and `SecureString` utility. +- Endpoint registry covering `UniversalAuthLogin`, `ListSecrets`, and `RetrieveSecret`, and a `System.Uri`-based URI builder. +- Synchronous HTTP client, JSON/YAML/XML/ENV serializers, and DTO/mapper for secrets. +- Connection model, process-level session manager, Universal Auth and Token Auth providers. +- Cmdlets: `Connect-Infisical`, `Disconnect-Infisical`, `Get-InfisicalSecrets`, `Get-InfisicalSecret`, `ConvertTo-InfisicalSecretDictionary`, `Export-InfisicalSecrets`. +- Build script (`build.ps1`) generating manifest, copying binaries, creating release folders, and supporting unit/integration tests. +- xUnit test project with unit tests and opt-in integration tests. diff --git a/Module/PSInfisicalAPI/PSInfisicalAPI.psd1 b/Module/PSInfisicalAPI/PSInfisicalAPI.psd1 index 1b52c29..cd3acdd 100644 --- a/Module/PSInfisicalAPI/PSInfisicalAPI.psd1 +++ b/Module/PSInfisicalAPI/PSInfisicalAPI.psd1 @@ -1,11 +1,11 @@ @{ RootModule = 'PSInfisicalAPI.psm1' - ModuleVersion = '2026.06.02.1638' + ModuleVersion = '2026.06.03.0131' GUID = 'b8a2f3d4-7c51-4d2f-9e6a-1f0c8b3d4e51' - Author = 'Alphaeus Mote' - CompanyName = '' - Copyright = '(c) Alphaeus Mote. All rights reserved.' - Description = 'PSInfisicalAPI is a C# binary PowerShell module for the Infisical REST API.' + Author = 'Grace Solutions' + CompanyName = 'Grace Solutions' + Copyright = '(c) Grace Solutions. All rights reserved.' + Description = 'PSInfisicalAPI is a C# binary PowerShell module for the Infisical REST API, providing cmdlets for authentication, secret retrieval, and export with automatic environment-variable discovery across Process, User, and Machine scopes.' PowerShellVersion = '5.1' CompatiblePSEditions = @('Desktop','Core') FunctionsToExport = @() @@ -23,10 +23,11 @@ TypesToProcess = @('PSInfisicalAPI.Types.ps1xml') PrivateData = @{ PSData = @{ - Tags = @('Infisical','Secrets','API','SecureString') - ProjectUri = '' - ReleaseNotes = '' - CommitHash = '3c47d6ff30ec' + Tags = @('Infisical','Secrets','API','SecureString','Vault','Authentication') + LicenseUri = 'https://www.gnu.org/licenses/agpl-3.0.html' + ProjectUri = 'https://prod.git.gracesolution.info/gsadmin/PSInfisicalAPI' + ReleaseNotes = 'See CHANGELOG.md in the project repository for release history.' + CommitHash = '7be0b7b42008' } } } \ No newline at end of file diff --git a/Module/PSInfisicalAPI/bin/Newtonsoft.Json.dll b/Module/PSInfisicalAPI/bin/Newtonsoft.Json.dll new file mode 100644 index 0000000..3af21d5 Binary files /dev/null and b/Module/PSInfisicalAPI/bin/Newtonsoft.Json.dll differ diff --git a/Module/PSInfisicalAPI/bin/PSInfisicalAPI.dll b/Module/PSInfisicalAPI/bin/PSInfisicalAPI.dll new file mode 100644 index 0000000..0ba2ef8 Binary files /dev/null and b/Module/PSInfisicalAPI/bin/PSInfisicalAPI.dll differ diff --git a/Module/PSInfisicalAPI/bin/YamlDotNet.dll b/Module/PSInfisicalAPI/bin/YamlDotNet.dll new file mode 100644 index 0000000..f9abf6c Binary files /dev/null and b/Module/PSInfisicalAPI/bin/YamlDotNet.dll differ diff --git a/Module/PSInfisicalAPI/en-US/about_PSInfisicalAPI.help.txt b/Module/PSInfisicalAPI/en-US/about_PSInfisicalAPI.help.txt new file mode 100644 index 0000000..ac9ffea --- /dev/null +++ b/Module/PSInfisicalAPI/en-US/about_PSInfisicalAPI.help.txt @@ -0,0 +1,97 @@ +TOPIC + about_PSInfisicalAPI + +SHORT DESCRIPTION + PSInfisicalAPI is a C# binary PowerShell module that exposes cmdlets for + authenticating against and retrieving secrets from the Infisical REST API, + with automatic environment-variable discovery for connection parameters. + +LONG DESCRIPTION + The module provides the following cmdlets: + + Connect-Infisical Establish a session. + Disconnect-Infisical Clear the current session. + Get-InfisicalSecrets List secrets at a path. + Get-InfisicalSecret Retrieve a single secret by name. + ConvertTo-InfisicalSecretDictionary Convert secret objects to a hashtable. + Export-InfisicalSecrets Export secrets to JSON, YAML, XML, or .env. + + Use Get-Help -Full for parameter details. + +AUTHENTICATION + Connect-Infisical supports two parameter sets: + + UniversalAuth -ClientId / -ClientSecret (SecureString) + Token -AccessToken (SecureString) + + Common parameters apply to both sets: + -BaseUri, -OrganizationId, -ProjectId, -Environment, + -SecretPath (default '/'), -ApiVersion (default 'v4'), -PassThru. + +ENVIRONMENT VARIABLE DISCOVERY + When any of the Connect-Infisical parameters are omitted, null, or contain + only whitespace, the cmdlet scans environment variables in three scopes, + in order: + + 1. Process + 2. User + 3. Machine + + The first matching variable with a non-blank value is used. Explicitly + supplied parameter values always win. + + Patterns are case-insensitive and match Infisical's CLI defaults plus + common variants such as CLOUDINIT_INFISICAL_* and custom-prefixed names + (for example "myapp_infisical_client_id"). + + Parameter Example variable names + ---------------- ---------------------------------------------------------- + BaseUri INFISICAL_API_URL, INFISICAL_BASE_URL, INFISICAL_HOST + OrganizationId INFISICAL_ORG_ID, INFISICAL_ORGANIZATION_ID + ProjectId INFISICAL_PROJECT_ID, INFISICAL_WORKSPACE_ID + Environment INFISICAL_ENVIRONMENT, INFISICAL_ENV, INFISICAL_ENV_SLUG + ClientId INFISICAL_CLIENT_ID, INFISICAL_UNIVERSAL_AUTH_CLIENT_ID + ClientSecret INFISICAL_CLIENT_SECRET, INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET + AccessToken INFISICAL_TOKEN, INFISICAL_ACCESS_TOKEN, INFISICAL_AUTH_TOKEN + SecretPath INFISICAL_SECRET_PATH, INFISICAL_DEFAULT_SECRET_PATH + ApiVersion INFISICAL_API_VERSION + + Sensitive values (ClientSecret, AccessToken) are read directly into a + read-only SecureString. Discovered values are never written to logs; + only the variable name and the scope it was found in are recorded. + + Use -Verbose to see the scan announcement and any discovered variable + names. + +EXAMPLES + Example 1: Zero-configuration connect (all values from environment). + + Connect-Infisical + Get-InfisicalSecrets + + Example 2: Explicit parameters override discovery. + + $secret = Read-Host -AsSecureString 'Client Secret' + Connect-Infisical -Environment prod -ClientSecret $secret + + Example 3: Token-based authentication. + + $token = Read-Host -AsSecureString 'Access Token' + Connect-Infisical -AccessToken $token + + Example 4: Export to a .env file. + + Get-InfisicalSecrets | + Export-InfisicalSecrets -Path .\secrets.env -Format Env + +SECURITY NOTES + - SecureString is used for ClientSecret, AccessToken, and any secret + payloads returned by the API. + - Sanitization is applied before any value reaches the logging pipeline. + - Sessions are stored in process-local state only and never persisted. + +SEE ALSO + Get-Help Connect-Infisical -Full + Get-Help Get-InfisicalSecrets -Full + Get-Help Export-InfisicalSecrets -Full + https://infisical.com/docs/api-reference/overview/introduction diff --git a/README.md b/README.md index c37544b..835c2b5 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,128 @@ # PSInfisicalAPI +A C# binary PowerShell module for interacting with the [Infisical](https://infisical.com/) REST API. It provides cmdlets for authentication, secret retrieval, structured export, and includes automatic environment-variable discovery so connections can be established with little or no inline configuration. + +- License: AGPL-3.0 +- Author: Grace Solutions +- Target framework: .NET Standard 2.0 (compatible with Windows PowerShell 5.1 and PowerShell 7+) + +## Installation + +### From the PowerShell Gallery + +```powershell +Install-Module -Name PSInfisicalAPI -Scope CurrentUser +Import-Module -Name PSInfisicalAPI +``` + +### From source + +```powershell +git clone https://prod.git.gracesolution.info/gsadmin/PSInfisicalAPI.git +cd PSInfisicalAPI +pwsh -NoProfile -ExecutionPolicy Bypass -File .\build.ps1 -RunTests +Import-Module -Name .\Module\PSInfisicalAPI +``` + +## Cmdlets + +| Cmdlet | Purpose | +| ------------------------------------- | -------------------------------------------------------------------------- | +| `Connect-Infisical` | Establish a session using Universal Auth or a pre-issued access token. | +| `Disconnect-Infisical` | Clear the current session. | +| `Get-InfisicalSecrets` | List secrets at a given path / environment. | +| `Get-InfisicalSecret` | Retrieve a single secret by name. | +| `ConvertTo-InfisicalSecretDictionary` | Convert secret objects into a `Hashtable` keyed by `SecretKey`. | +| `Export-InfisicalSecrets` | Export secrets to JSON, YAML, XML, or `.env` format. | + +Use `Get-Help -Full` for parameter details and `Get-Help about_PSInfisicalAPI` for the module overview. + +## Quick start + +```powershell +$secureSecret = Read-Host -AsSecureString 'Client Secret' + +$connection = Connect-Infisical ` + -BaseUri 'https://app.infisical.com' ` + -OrganizationId '00000000-0000-0000-0000-000000000000' ` + -ProjectId '11111111-1111-1111-1111-111111111111' ` + -Environment 'dev' ` + -ClientId 'machine-identity-client-id' ` + -ClientSecret $secureSecret ` + -PassThru + +Get-InfisicalSecrets -SecretPath '/' +Disconnect-Infisical +``` + +## Automatic environment-variable discovery + +When `Connect-Infisical` is invoked with one or more parameters missing (or set to whitespace/empty), the cmdlet searches environment variables and uses the first value it finds. This makes invocation as simple as `Connect-Infisical` when variables are set up in advance. + +### Scope precedence + +Scopes are searched in order; the first matching variable with a non-blank value wins: + +1. `Process` +2. `User` +3. `Machine` + +### Patterns + +The resolver matches case-insensitively against patterns aligned with Infisical's CLI defaults plus common variants such as `CLOUDINIT_INFISICAL_*` and custom-prefixed names (e.g., `myapp_infisical_client_id`). + +| Parameter | Example variable names matched | +| ----------------- | ------------------------------------------------------------------------------------ | +| `BaseUri` | `INFISICAL_API_URL`, `INFISICAL_BASE_URL`, `INFISICAL_HOST` | +| `OrganizationId` | `INFISICAL_ORG_ID`, `INFISICAL_ORGANIZATION_ID` | +| `ProjectId` | `INFISICAL_PROJECT_ID`, `INFISICAL_WORKSPACE_ID` | +| `Environment` | `INFISICAL_ENVIRONMENT`, `INFISICAL_ENV`, `INFISICAL_ENV_SLUG` | +| `ClientId` | `INFISICAL_CLIENT_ID`, `INFISICAL_UNIVERSAL_AUTH_CLIENT_ID` | +| `ClientSecret` | `INFISICAL_CLIENT_SECRET`, `INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET` | +| `AccessToken` | `INFISICAL_TOKEN`, `INFISICAL_ACCESS_TOKEN`, `INFISICAL_AUTH_TOKEN` | +| `SecretPath` | `INFISICAL_SECRET_PATH`, `INFISICAL_DEFAULT_SECRET_PATH` | +| `ApiVersion` | `INFISICAL_API_VERSION` | + +Sensitive values (`ClientSecret`, `AccessToken`) are read directly into a read-only `SecureString` and never logged. + +### Zero-configuration example + +```powershell +[Environment]::SetEnvironmentVariable('INFISICAL_API_URL', 'https://app.infisical.com', 'User') +[Environment]::SetEnvironmentVariable('INFISICAL_ORG_ID', '00000000-0000-0000-0000-000000000000', 'User') +[Environment]::SetEnvironmentVariable('INFISICAL_PROJECT_ID', '11111111-1111-1111-1111-111111111111', 'User') +[Environment]::SetEnvironmentVariable('INFISICAL_ENVIRONMENT', 'dev', 'User') +[Environment]::SetEnvironmentVariable('INFISICAL_CLIENT_ID', 'machine-identity-client-id', 'User') +[Environment]::SetEnvironmentVariable('INFISICAL_CLIENT_SECRET', 'super-secret-value', 'User') + +Connect-Infisical +Get-InfisicalSecrets +``` + +### Mixed example (explicit values override discovery) + +Explicit parameters always win over discovered values; blank/whitespace explicit values trigger discovery. + +```powershell +Connect-Infisical -Environment 'prod' # everything else discovered from environment +``` + +### Logging + +The resolver emits a single verbose line announcing the scan and one informational line per discovered variable (variable name and scope; values are never logged). Use `-Verbose` to see the scan announcement. + +## Building + +```powershell +pwsh -NoProfile -ExecutionPolicy Bypass -File .\build.ps1 -RunTests +``` + +The script builds the binary, runs unit tests, publishes binaries into `Module/PSInfisicalAPI/bin/`, regenerates the manifest, and validates that the module imports. + +## Continuous integration + +`.gitea/workflows/publish-psgallery.yml` publishes the module to the PowerShell Gallery whenever a pull request is merged into `main`. The workflow expects a repository secret named `PSGALLERY_API_KEY` containing a valid Gallery API key. + +## License + +Distributed under the GNU Affero General Public License v3.0. See [LICENSE](LICENSE). diff --git a/build.ps1 b/build.ps1 index 1bc4ae9..ef7a856 100644 --- a/build.ps1 +++ b/build.ps1 @@ -90,10 +90,10 @@ function Write-Manifest { RootModule = 'PSInfisicalAPI.psm1' ModuleVersion = '$ModuleVersion' GUID = '$ModuleGuid' - Author = 'Alphaeus Mote' - CompanyName = '' - Copyright = '(c) Alphaeus Mote. All rights reserved.' - Description = 'PSInfisicalAPI is a C# binary PowerShell module for the Infisical REST API.' + Author = 'Grace Solutions' + CompanyName = 'Grace Solutions' + Copyright = '(c) Grace Solutions. All rights reserved.' + Description = 'PSInfisicalAPI is a C# binary PowerShell module for the Infisical REST API, providing cmdlets for authentication, secret retrieval, and export with automatic environment-variable discovery across Process, User, and Machine scopes.' PowerShellVersion = '5.1' CompatiblePSEditions = @('Desktop','Core') FunctionsToExport = @() @@ -111,10 +111,11 @@ function Write-Manifest { TypesToProcess = @('PSInfisicalAPI.Types.ps1xml') PrivateData = @{ PSData = @{ - Tags = @('Infisical','Secrets','API','SecureString') - ProjectUri = '' - ReleaseNotes = '' - CommitHash = '$CommitHash' + Tags = @('Infisical','Secrets','API','SecureString','Vault','Authentication') + LicenseUri = 'https://www.gnu.org/licenses/agpl-3.0.html' + ProjectUri = 'https://prod.git.gracesolution.info/gsadmin/PSInfisicalAPI' + ReleaseNotes = 'See CHANGELOG.md in the project repository for release history.' + CommitHash = '$CommitHash' } } } @@ -132,7 +133,9 @@ function Update-Changelog { if ($existing -match [Regex]::Escape($marker)) { return } $insertion = "## $Version`r`n`r`n- Build produced from commit $CommitHash.`r`n`r`n" - $updated = $existing -replace '## Unreleased', "## Unreleased`r`n`r`n$insertion## Unreleased (carried forward)" + $unreleasedRegex = [regex]::new('(?m)^## Unreleased\r?$') + if (-not $unreleasedRegex.IsMatch($existing)) { return } + $updated = $unreleasedRegex.Replace($existing, "## Unreleased`r`n`r`n$insertion## Unreleased (carried forward)", 1) [System.IO.File]::WriteAllText($ChangelogFile.FullName, $updated, [System.Text.UTF8Encoding]::new($false)) } @@ -148,15 +151,33 @@ function Invoke-DotNet { function Test-ModuleImports { param([System.IO.DirectoryInfo]$ModuleDirectory) - Write-Step "Validating module import" + Write-Step "Validating module import, manifest, and help" + $manifestPath = [System.IO.Path]::Combine($ModuleDirectory.FullName, 'PSInfisicalAPI.psd1') $script = @" `$ErrorActionPreference = 'Stop' + +`$manifest = Test-ModuleManifest -Path '$manifestPath' +if (`$null -eq `$manifest) { + throw "Test-ModuleManifest returned no result for '$manifestPath'." +} + Import-Module -Name '$($ModuleDirectory.FullName)' -Force + `$cmds = @('Connect-Infisical','Disconnect-Infisical','Get-InfisicalSecrets','Get-InfisicalSecret','ConvertTo-InfisicalSecretDictionary','Export-InfisicalSecrets') foreach (`$c in `$cmds) { if (-not (Get-Command -Name `$c -Module PSInfisicalAPI -ErrorAction SilentlyContinue)) { throw "Cmdlet not found: `$c" } + + `$help = Get-Help -Name `$c -ErrorAction SilentlyContinue + if (`$null -eq `$help) { + throw "Get-Help returned nothing for cmdlet: `$c" + } +} + +`$about = Get-Help -Name 'about_PSInfisicalAPI' -ErrorAction SilentlyContinue +if (`$null -eq `$about -or [string]::IsNullOrWhiteSpace((`$about | Out-String))) { + throw "Get-Help 'about_PSInfisicalAPI' returned no content. Ensure en-US/about_PSInfisicalAPI.help.txt is present." } "@ @@ -251,11 +272,7 @@ Write-Manifest -Path $manifestPath -ModuleVersion $buildVersion -CommitHash $com Update-Changelog -Version $buildVersion -CommitHash $commitHash -try { - Test-ModuleImports -ModuleDirectory $ModuleRoot -} catch { - Write-Warning "Module import validation reported: $($_.Exception.Message)" -} +Test-ModuleImports -ModuleDirectory $ModuleRoot if ($CreateRelease.IsPresent) { $releaseDir = [System.IO.DirectoryInfo][System.IO.Path]::Combine($ReleasesDir.FullName, $buildVersion) diff --git a/docs/DesignSpec.md b/docs/DesignSpec.md index c7fa7af..030f81d 100644 --- a/docs/DesignSpec.md +++ b/docs/DesignSpec.md @@ -187,7 +187,7 @@ Example shape: RootModule = 'PSInfisicalAPI.psm1' ModuleVersion = 'yyyy.MM.dd.HHmm' GUID = '' - Author = 'Alphaeus Mote' + Author = 'Grace Solutions' CompanyName = '' Copyright = '' PowerShellVersion = '5.1' @@ -954,10 +954,10 @@ Get-InfisicalSecrets ` [-Environment ] ` [-SecretPath ] ` [-Recursive] ` - [-IncludeImports ] ` + [-IncludeImports] ` [-IncludePersonalOverrides] ` - [-ExpandSecretReferences ] ` - [-ViewSecretValue ] ` + [-ExpandSecretReferences] ` + [-ViewSecretValue] ` [-MetadataFilter ] ` [-TagSlugs ] ``` @@ -969,8 +969,8 @@ ProjectId: Current connection ProjectId Environment: Current connection Environment SecretPath: Current connection DefaultSecretPath or / Recursive: false -IncludeImports: true -ExpandSecretReferences: true +IncludeImports: false +ExpandSecretReferences: false ViewSecretValue: true ``` @@ -1020,9 +1020,9 @@ Get-InfisicalSecret ` [-SecretPath ] ` [-Version ] ` [-Type ] ` - [-ViewSecretValue ] ` - [-ExpandSecretReferences ] ` - [-IncludeImports ] + [-ViewSecretValue] ` + [-ExpandSecretReferences] ` + [-IncludeImports] ``` ## Parameter Attributes @@ -1041,8 +1041,8 @@ Environment: Current connection Environment SecretPath: Current connection DefaultSecretPath or / Type: Shared ViewSecretValue: true -ExpandSecretReferences: true -IncludeImports: true +ExpandSecretReferences: false +IncludeImports: false ``` ## Behavior diff --git a/src/PSInfisicalAPI/Cmdlets/ConnectInfisicalCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/ConnectInfisicalCmdlet.cs index 478ee7b..f5e6bdd 100644 --- a/src/PSInfisicalAPI/Cmdlets/ConnectInfisicalCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/ConnectInfisicalCmdlet.cs @@ -89,10 +89,13 @@ namespace PSInfisicalAPI.Cmdlets throw new InfisicalAuthenticationException("Authentication did not produce an access token."); } + bool apiVersionExplicitlyBound = MyInvocation.BoundParameters.ContainsKey("ApiVersion"); + InfisicalConnection connection = new InfisicalConnection { BaseUri = BaseUri, ApiVersion = ApiVersion, + PinnedApiVersion = apiVersionExplicitlyBound ? ApiVersion : null, AuthType = authType, OrganizationId = OrganizationId, ProjectId = ProjectId, diff --git a/src/PSInfisicalAPI/Cmdlets/ConvertToInfisicalSecretDictionaryCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/ConvertToInfisicalSecretDictionaryCmdlet.cs index 0b660ca..25925f4 100644 --- a/src/PSInfisicalAPI/Cmdlets/ConvertToInfisicalSecretDictionaryCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/ConvertToInfisicalSecretDictionaryCmdlet.cs @@ -9,6 +9,7 @@ namespace PSInfisicalAPI.Cmdlets { [Cmdlet(VerbsData.ConvertTo, "InfisicalSecretDictionary")] [OutputType(typeof(Dictionary))] + [OutputType(typeof(Dictionary))] public sealed class ConvertToInfisicalSecretDictionaryCmdlet : InfisicalCmdletBase { [Parameter(Mandatory = true, ValueFromPipeline = true)] @@ -17,6 +18,9 @@ namespace PSInfisicalAPI.Cmdlets [Parameter] public InfisicalDuplicateKeyBehavior DuplicateKeyBehavior { get; set; } = InfisicalDuplicateKeyBehavior.Error; + [Parameter] + public SwitchParameter AsPlainText { get; set; } + private readonly List _buffer = new List(); protected override void ProcessRecord() @@ -36,36 +40,50 @@ namespace PSInfisicalAPI.Cmdlets { try { - Dictionary dictionary = new Dictionary(StringComparer.OrdinalIgnoreCase); - - foreach (InfisicalSecret secret in _buffer) + if (AsPlainText.IsPresent) { - string key = secret.SecretName ?? string.Empty; - - if (dictionary.ContainsKey(key)) - { - if (DuplicateKeyBehavior == InfisicalDuplicateKeyBehavior.Error) - { - throw new InfisicalConfigurationException(string.Concat("Duplicate secret name encountered: ", key)); - } - - if (DuplicateKeyBehavior == InfisicalDuplicateKeyBehavior.LastWins) - { - dictionary[key] = secret.SecretValue; - } - - continue; - } - - dictionary[key] = secret.SecretValue; + Dictionary plain = BuildDictionary(secret => secret.GetPlainTextValue()); + WriteObject(plain); + } + else + { + Dictionary secure = BuildDictionary(secret => secret.SecretValue); + WriteObject(secure); } - - WriteObject(dictionary); } catch (Exception exception) { ThrowTerminatingForException("ConvertToInfisicalSecretDictionaryCmdlet", "ConvertToDictionary", exception); } } + + private Dictionary BuildDictionary(Func valueSelector) + { + Dictionary dictionary = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (InfisicalSecret secret in _buffer) + { + string key = secret.SecretName ?? string.Empty; + + if (dictionary.ContainsKey(key)) + { + if (DuplicateKeyBehavior == InfisicalDuplicateKeyBehavior.Error) + { + throw new InfisicalConfigurationException(string.Concat("Duplicate secret name encountered: ", key)); + } + + if (DuplicateKeyBehavior == InfisicalDuplicateKeyBehavior.LastWins) + { + dictionary[key] = valueSelector(secret); + } + + continue; + } + + dictionary[key] = valueSelector(secret); + } + + return dictionary; + } } } diff --git a/src/PSInfisicalAPI/Cmdlets/ExportInfisicalSecretsCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/ExportInfisicalSecretsCmdlet.cs index 9caae56..6a3f19f 100644 --- a/src/PSInfisicalAPI/Cmdlets/ExportInfisicalSecretsCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/ExportInfisicalSecretsCmdlet.cs @@ -89,6 +89,7 @@ namespace PSInfisicalAPI.Cmdlets { switch (encoding) { + case InfisicalExportEncoding.UTF8: return new UTF8Encoding(false); case InfisicalExportEncoding.UTF8Bom: return new UTF8Encoding(true); case InfisicalExportEncoding.Unicode: return new UnicodeEncoding(); default: return new UTF8Encoding(false); diff --git a/src/PSInfisicalAPI/Cmdlets/GetInfisicalSecretCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/GetInfisicalSecretCmdlet.cs index d15f6cc..eae5ea5 100644 --- a/src/PSInfisicalAPI/Cmdlets/GetInfisicalSecretCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/GetInfisicalSecretCmdlet.cs @@ -16,11 +16,12 @@ namespace PSInfisicalAPI.Cmdlets [Parameter] public string ProjectId { get; set; } [Parameter] public string Environment { get; set; } [Parameter] public string SecretPath { get; set; } + [Parameter] public string ApiVersion { get; set; } [Parameter] public int? Version { get; set; } [Parameter] public InfisicalSecretType Type { get; set; } = InfisicalSecretType.Shared; - [Parameter] public bool ViewSecretValue { get; set; } = true; - [Parameter] public bool ExpandSecretReferences { get; set; } = true; - [Parameter] public bool IncludeImports { get; set; } = true; + [Parameter] public SwitchParameter ViewSecretValue { get; set; } = SwitchParameter.Present; + [Parameter] public SwitchParameter ExpandSecretReferences { get; set; } + [Parameter] public SwitchParameter IncludeImports { get; set; } protected override void ProcessRecord() { @@ -34,11 +35,12 @@ namespace PSInfisicalAPI.Cmdlets ProjectId = ProjectId, Environment = Environment, SecretPath = SecretPath, + ApiVersion = ApiVersion, Version = Version, Type = Type.ToString(), - ViewSecretValue = ViewSecretValue, - ExpandSecretReferences = ExpandSecretReferences, - IncludeImports = IncludeImports + ViewSecretValue = ViewSecretValue.IsPresent, + ExpandSecretReferences = ExpandSecretReferences.IsPresent, + IncludeImports = IncludeImports.IsPresent }; InfisicalSecretsClient client = new InfisicalSecretsClient(HttpClient, Logger); diff --git a/src/PSInfisicalAPI/Cmdlets/GetInfisicalSecretsCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/GetInfisicalSecretsCmdlet.cs index eebd02f..e3e60bb 100644 --- a/src/PSInfisicalAPI/Cmdlets/GetInfisicalSecretsCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/GetInfisicalSecretsCmdlet.cs @@ -15,11 +15,12 @@ namespace PSInfisicalAPI.Cmdlets [Parameter] public string ProjectId { get; set; } [Parameter] public string Environment { get; set; } [Parameter] public string SecretPath { get; set; } + [Parameter] public string ApiVersion { get; set; } [Parameter] public SwitchParameter Recursive { get; set; } - [Parameter] public bool IncludeImports { get; set; } = true; + [Parameter] public SwitchParameter IncludeImports { get; set; } [Parameter] public SwitchParameter IncludePersonalOverrides { get; set; } - [Parameter] public bool ExpandSecretReferences { get; set; } = true; - [Parameter] public bool ViewSecretValue { get; set; } = true; + [Parameter] public SwitchParameter ExpandSecretReferences { get; set; } + [Parameter] public SwitchParameter ViewSecretValue { get; set; } = SwitchParameter.Present; [Parameter] public Hashtable MetadataFilter { get; set; } [Parameter] public string[] TagSlugs { get; set; } @@ -34,11 +35,12 @@ namespace PSInfisicalAPI.Cmdlets ProjectId = ProjectId, Environment = Environment, SecretPath = SecretPath, + ApiVersion = ApiVersion, Recursive = Recursive.IsPresent, - IncludeImports = IncludeImports, + IncludeImports = IncludeImports.IsPresent, IncludePersonalOverrides = IncludePersonalOverrides.IsPresent, - ExpandSecretReferences = ExpandSecretReferences, - ViewSecretValue = ViewSecretValue, + ExpandSecretReferences = ExpandSecretReferences.IsPresent, + ViewSecretValue = ViewSecretValue.IsPresent, MetadataFilter = ToStringDictionary(MetadataFilter), TagSlugs = TagSlugs }; diff --git a/src/PSInfisicalAPI/Connections/InfisicalConnection.cs b/src/PSInfisicalAPI/Connections/InfisicalConnection.cs index 7fcfad8..ef4d04c 100644 --- a/src/PSInfisicalAPI/Connections/InfisicalConnection.cs +++ b/src/PSInfisicalAPI/Connections/InfisicalConnection.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Security; using PSInfisicalAPI.Models; @@ -8,6 +9,7 @@ namespace PSInfisicalAPI.Connections { public Uri BaseUri { get; set; } public string ApiVersion { get; set; } + public string PinnedApiVersion { get; set; } public InfisicalAuthType AuthType { get; set; } public string OrganizationId { get; set; } public string ProjectId { get; set; } @@ -17,6 +19,8 @@ namespace PSInfisicalAPI.Connections public DateTimeOffset? ExpiresAtUtc { get; set; } public bool IsConnected { get; set; } + public Dictionary ResolvedEndpointVersions { get; } = new Dictionary(StringComparer.Ordinal); + internal SecureString AccessToken { get; set; } public override string ToString() diff --git a/src/PSInfisicalAPI/Endpoints/InfisicalEndpointRegistry.cs b/src/PSInfisicalAPI/Endpoints/InfisicalEndpointRegistry.cs index 9e3dfe7..997d14f 100644 --- a/src/PSInfisicalAPI/Endpoints/InfisicalEndpointRegistry.cs +++ b/src/PSInfisicalAPI/Endpoints/InfisicalEndpointRegistry.cs @@ -5,67 +5,88 @@ namespace PSInfisicalAPI.Endpoints { public static class InfisicalEndpointRegistry { - private static readonly Dictionary Definitions = - new Dictionary + private static readonly Dictionary> Candidates = + new Dictionary> { { InfisicalEndpointNames.UniversalAuthLogin, - new InfisicalEndpointDefinition + new List { - Name = InfisicalEndpointNames.UniversalAuthLogin, - Resource = "Authentication", - Version = "v1", - Method = "POST", - Template = "/api/v1/auth/universal-auth/login", - RequiresAuthorization = false, - ContainsSecretMaterialInRequest = true, - ContainsSecretMaterialInResponse = true + new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.UniversalAuthLogin, + Resource = "Authentication", + Version = "v1", + Method = "POST", + Template = "/api/v1/auth/universal-auth/login", + RequiresAuthorization = false, + ContainsSecretMaterialInRequest = true, + ContainsSecretMaterialInResponse = true + } } }, { InfisicalEndpointNames.ListSecrets, - new InfisicalEndpointDefinition + new List { - Name = InfisicalEndpointNames.ListSecrets, - Resource = "Secrets", - Version = "v4", - Method = "GET", - Template = "/api/v4/secrets", - RequiresAuthorization = true, - ContainsSecretMaterialInRequest = false, - ContainsSecretMaterialInResponse = true + new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.ListSecrets, + Resource = "Secrets", + Version = "v4", + Method = "GET", + Template = "/api/v4/secrets", + RequiresAuthorization = true, + ContainsSecretMaterialInRequest = false, + ContainsSecretMaterialInResponse = true + }, + new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.ListSecrets, + Resource = "Secrets", + Version = "v3", + Method = "GET", + Template = "/api/v3/secrets/raw", + RequiresAuthorization = true, + ContainsSecretMaterialInRequest = false, + ContainsSecretMaterialInResponse = true + } } }, { InfisicalEndpointNames.RetrieveSecret, - new InfisicalEndpointDefinition + new List { - Name = InfisicalEndpointNames.RetrieveSecret, - Resource = "Secrets", - Version = "v4", - Method = "GET", - Template = "/api/v4/secrets/{secretName}", - RequiresAuthorization = true, - ContainsSecretMaterialInRequest = false, - ContainsSecretMaterialInResponse = true + new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.RetrieveSecret, + Resource = "Secrets", + Version = "v4", + Method = "GET", + Template = "/api/v4/secrets/{secretName}", + RequiresAuthorization = true, + ContainsSecretMaterialInRequest = false, + ContainsSecretMaterialInResponse = true + }, + new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.RetrieveSecret, + Resource = "Secrets", + Version = "v3", + Method = "GET", + Template = "/api/v3/secrets/raw/{secretName}", + RequiresAuthorization = true, + ContainsSecretMaterialInRequest = false, + ContainsSecretMaterialInResponse = true + } } } }; public static InfisicalEndpointDefinition Get(string name) { - if (string.IsNullOrEmpty(name)) - { - throw new InfisicalConfigurationException("Endpoint name must be provided."); - } - - InfisicalEndpointDefinition definition; - if (!Definitions.TryGetValue(name, out definition)) - { - throw new InfisicalConfigurationException(string.Concat("Unknown endpoint name: ", name)); - } - - return definition; + List list = GetCandidatesInternal(name); + return list[0]; } public static bool TryGet(string name, out InfisicalEndpointDefinition definition) @@ -76,12 +97,50 @@ namespace PSInfisicalAPI.Endpoints return false; } - return Definitions.TryGetValue(name, out definition); + List list; + if (!Candidates.TryGetValue(name, out list) || list == null || list.Count == 0) + { + definition = null; + return false; + } + + definition = list[0]; + return true; + } + + public static IReadOnlyList GetCandidates(string name) + { + return GetCandidatesInternal(name); } public static IEnumerable All() { - return Definitions.Values; + List result = new List(); + foreach (List list in Candidates.Values) + { + foreach (InfisicalEndpointDefinition definition in list) + { + result.Add(definition); + } + } + + return result; + } + + private static List GetCandidatesInternal(string name) + { + if (string.IsNullOrEmpty(name)) + { + throw new InfisicalConfigurationException("Endpoint name must be provided."); + } + + List list; + if (!Candidates.TryGetValue(name, out list) || list == null || list.Count == 0) + { + throw new InfisicalConfigurationException(string.Concat("Unknown endpoint name: ", name)); + } + + return list; } } } diff --git a/src/PSInfisicalAPI/Http/InfisicalHttpClient.cs b/src/PSInfisicalAPI/Http/InfisicalHttpClient.cs index 1c417d0..55fe767 100644 --- a/src/PSInfisicalAPI/Http/InfisicalHttpClient.cs +++ b/src/PSInfisicalAPI/Http/InfisicalHttpClient.cs @@ -42,6 +42,14 @@ namespace PSInfisicalAPI.Http webRequest.UserAgent = "PSInfisicalAPI"; webRequest.Timeout = _timeoutSeconds * 1000; webRequest.ReadWriteTimeout = _timeoutSeconds * 1000; + webRequest.UseDefaultCredentials = true; + + IWebProxy systemProxy = WebRequest.GetSystemWebProxy(); + if (systemProxy != null) + { + systemProxy.Credentials = CredentialCache.DefaultNetworkCredentials; + webRequest.Proxy = systemProxy; + } ApplyHeaders(webRequest, request.Headers); diff --git a/src/PSInfisicalAPI/Logging/PSCmdletLogger.cs b/src/PSInfisicalAPI/Logging/PSCmdletLogger.cs index 69b6d30..178d189 100644 --- a/src/PSInfisicalAPI/Logging/PSCmdletLogger.cs +++ b/src/PSInfisicalAPI/Logging/PSCmdletLogger.cs @@ -39,13 +39,7 @@ namespace PSInfisicalAPI.Logging public void Error(string component, string message) { string line = InfisicalLogFormatter.FormatNow(InfisicalLogLevel.Error, component, message); - ErrorRecord record = new ErrorRecord( - new InvalidOperationException(message ?? string.Empty), - "PSInfisicalAPI.Error", - ErrorCategory.NotSpecified, - component); - record.ErrorDetails = new ErrorDetails(line); - _cmdlet.WriteError(record); + _cmdlet.WriteWarning(line); } } } diff --git a/src/PSInfisicalAPI/Models/InfisicalSecret.cs b/src/PSInfisicalAPI/Models/InfisicalSecret.cs index 0fee725..0d2abf5 100644 --- a/src/PSInfisicalAPI/Models/InfisicalSecret.cs +++ b/src/PSInfisicalAPI/Models/InfisicalSecret.cs @@ -34,6 +34,12 @@ namespace PSInfisicalAPI.Models SecureStringUtility.UsePlainText(SecretValue, action); } + public string GetPlainTextValue() + { + if (SecretValue == null) { return null; } + return SecureStringUtility.UsePlainText(SecretValue, plainText => plainText); + } + public override string ToString() { return SecretName; diff --git a/src/PSInfisicalAPI/PSInfisicalAPI.csproj b/src/PSInfisicalAPI/PSInfisicalAPI.csproj index 896fcdf..8c659ac 100644 --- a/src/PSInfisicalAPI/PSInfisicalAPI.csproj +++ b/src/PSInfisicalAPI/PSInfisicalAPI.csproj @@ -12,8 +12,8 @@ $(BuildAssemblyVersion) $(BuildAssemblyVersion) $(BuildVersion) - - Alphaeus Mote + Grace Solutions + Grace Solutions PSInfisicalAPI true false diff --git a/src/PSInfisicalAPI/Secrets/InfisicalSecretMapper.cs b/src/PSInfisicalAPI/Secrets/InfisicalSecretMapper.cs index b61ce69..944c346 100644 --- a/src/PSInfisicalAPI/Secrets/InfisicalSecretMapper.cs +++ b/src/PSInfisicalAPI/Secrets/InfisicalSecretMapper.cs @@ -15,6 +15,8 @@ namespace PSInfisicalAPI.Secrets return null; } + bool hidden = dto.SecretValueHidden || IsHiddenPlaceholder(dto.SecretValue); + InfisicalSecret secret = new InfisicalSecret { Id = dto.Id, @@ -24,8 +26,8 @@ namespace PSInfisicalAPI.Secrets Version = dto.Version, Type = ParseType(dto.Type), SecretName = dto.SecretKey, - SecretValue = SecureStringUtility.ToReadOnlySecureString(dto.SecretValue), - SecretValueHidden = dto.SecretValueHidden, + SecretValue = hidden ? null : SecureStringUtility.ToReadOnlySecureString(dto.SecretValue), + SecretValueHidden = hidden, SecretPath = dto.SecretPath, SecretComment = dto.SecretComment, CreatedAtUtc = ParseTimestamp(dto.CreatedAt), @@ -41,6 +43,11 @@ namespace PSInfisicalAPI.Secrets return secret; } + private static bool IsHiddenPlaceholder(string value) + { + return string.Equals(value, "", StringComparison.Ordinal); + } + public static InfisicalSecret[] MapMany(IEnumerable items) { if (items == null) diff --git a/src/PSInfisicalAPI/Secrets/InfisicalSecretQuery.cs b/src/PSInfisicalAPI/Secrets/InfisicalSecretQuery.cs index fd3561a..8ede056 100644 --- a/src/PSInfisicalAPI/Secrets/InfisicalSecretQuery.cs +++ b/src/PSInfisicalAPI/Secrets/InfisicalSecretQuery.cs @@ -7,6 +7,7 @@ namespace PSInfisicalAPI.Secrets public string ProjectId { get; set; } public string Environment { get; set; } public string SecretPath { get; set; } + public string ApiVersion { get; set; } public bool Recursive { get; set; } public bool? IncludeImports { get; set; } public bool IncludePersonalOverrides { get; set; } @@ -22,6 +23,7 @@ namespace PSInfisicalAPI.Secrets public string ProjectId { get; set; } public string Environment { get; set; } public string SecretPath { get; set; } + public string ApiVersion { get; set; } public int? Version { get; set; } public string Type { get; set; } public bool? ViewSecretValue { get; set; } diff --git a/src/PSInfisicalAPI/Secrets/InfisicalSecretsClient.cs b/src/PSInfisicalAPI/Secrets/InfisicalSecretsClient.cs index 3a2468b..aa9d7a7 100644 --- a/src/PSInfisicalAPI/Secrets/InfisicalSecretsClient.cs +++ b/src/PSInfisicalAPI/Secrets/InfisicalSecretsClient.cs @@ -32,14 +32,15 @@ namespace PSInfisicalAPI.Secrets if (connection == null) { throw new ArgumentNullException(nameof(connection)); } if (query == null) { throw new ArgumentNullException(nameof(query)); } - InfisicalEndpointDefinition definition = InfisicalEndpointRegistry.Get(InfisicalEndpointNames.ListSecrets); + string resolvedProjectId = FirstNonEmpty(query.ProjectId, connection.ProjectId); List> queryParameters = new List>(); - AddIfNotNull(queryParameters, "workspaceId", FirstNonEmpty(query.ProjectId, connection.ProjectId)); + AddIfNotNull(queryParameters, "workspaceId", resolvedProjectId); + AddIfNotNull(queryParameters, "projectId", resolvedProjectId); AddIfNotNull(queryParameters, "environment", FirstNonEmpty(query.Environment, connection.Environment)); AddIfNotNull(queryParameters, "secretPath", FirstNonEmpty(query.SecretPath, connection.DefaultSecretPath, "/")); queryParameters.Add(new KeyValuePair("recursive", query.Recursive ? "true" : "false")); - if (query.IncludeImports.HasValue) { queryParameters.Add(new KeyValuePair("include_imports", query.IncludeImports.Value ? "true" : "false")); } + if (query.IncludeImports.HasValue) { queryParameters.Add(new KeyValuePair("includeImports", query.IncludeImports.Value ? "true" : "false")); } if (query.IncludePersonalOverrides) { queryParameters.Add(new KeyValuePair("includePersonalOverrides", "true")); } if (query.ExpandSecretReferences.HasValue) { queryParameters.Add(new KeyValuePair("expandSecretReferences", query.ExpandSecretReferences.Value ? "true" : "false")); } if (query.ViewSecretValue.HasValue) { queryParameters.Add(new KeyValuePair("viewSecretValue", query.ViewSecretValue.Value ? "true" : "false")); } @@ -60,13 +61,18 @@ namespace PSInfisicalAPI.Secrets } } - Uri uri = InfisicalUriBuilder.Build(connection.BaseUri, definition, null, queryParameters); - InfisicalHttpResponse response = ExecuteAuthorized(connection, definition, "RetrieveSecrets", uri, null); - try { _logger.Information(Component, "Attempting to retrieve Infisical secrets. Please Wait..."); - EnsureSuccess(response, definition); + + InfisicalHttpResponse response = SendWithVersionFallback( + connection, + InfisicalEndpointNames.ListSecrets, + query.ApiVersion, + "RetrieveSecrets", + null, + queryParameters, + null); InfisicalSecretListResponseDto dto = _serializer.Deserialize(response.Body); response.Clear(); @@ -88,27 +94,33 @@ namespace PSInfisicalAPI.Secrets if (query == null) { throw new ArgumentNullException(nameof(query)); } if (string.IsNullOrEmpty(query.SecretName)) { throw new InfisicalConfigurationException("SecretName is required."); } - InfisicalEndpointDefinition definition = InfisicalEndpointRegistry.Get(InfisicalEndpointNames.RetrieveSecret); - Dictionary pathParameters = new Dictionary { { "secretName", query.SecretName } }; + string resolvedProjectId = FirstNonEmpty(query.ProjectId, connection.ProjectId); + List> queryParameters = new List>(); - AddIfNotNull(queryParameters, "workspaceId", FirstNonEmpty(query.ProjectId, connection.ProjectId)); + AddIfNotNull(queryParameters, "workspaceId", resolvedProjectId); + AddIfNotNull(queryParameters, "projectId", resolvedProjectId); AddIfNotNull(queryParameters, "environment", FirstNonEmpty(query.Environment, connection.Environment)); AddIfNotNull(queryParameters, "secretPath", FirstNonEmpty(query.SecretPath, connection.DefaultSecretPath, "/")); AddIfNotNull(queryParameters, "type", string.IsNullOrEmpty(query.Type) ? "shared" : query.Type.ToLowerInvariant()); if (query.Version.HasValue) { queryParameters.Add(new KeyValuePair("version", query.Version.Value.ToString(CultureInfo.InvariantCulture))); } if (query.ViewSecretValue.HasValue) { queryParameters.Add(new KeyValuePair("viewSecretValue", query.ViewSecretValue.Value ? "true" : "false")); } if (query.ExpandSecretReferences.HasValue) { queryParameters.Add(new KeyValuePair("expandSecretReferences", query.ExpandSecretReferences.Value ? "true" : "false")); } - if (query.IncludeImports.HasValue) { queryParameters.Add(new KeyValuePair("include_imports", query.IncludeImports.Value ? "true" : "false")); } - - Uri uri = InfisicalUriBuilder.Build(connection.BaseUri, definition, pathParameters, queryParameters); - InfisicalHttpResponse response = ExecuteAuthorized(connection, definition, "RetrieveSecret", uri, null); + if (query.IncludeImports.HasValue) { queryParameters.Add(new KeyValuePair("includeImports", query.IncludeImports.Value ? "true" : "false")); } try { _logger.Information(Component, string.Concat("Attempting to retrieve Infisical secret '", query.SecretName, "'. Please Wait...")); - EnsureSuccess(response, definition); + + InfisicalHttpResponse response = SendWithVersionFallback( + connection, + InfisicalEndpointNames.RetrieveSecret, + query.ApiVersion, + "RetrieveSecret", + pathParameters, + queryParameters, + null); InfisicalSecretSingleResponseDto dto = _serializer.Deserialize(response.Body); response.Clear(); @@ -124,6 +136,160 @@ namespace PSInfisicalAPI.Secrets } } + private InfisicalHttpResponse SendWithVersionFallback( + InfisicalConnection connection, + string endpointName, + string perCallApiVersion, + string operationName, + Dictionary pathParameters, + List> queryParameters, + string body) + { + IReadOnlyList allCandidates = InfisicalEndpointRegistry.GetCandidates(endpointName); + + string pinned = FirstNonEmpty(perCallApiVersion, connection.PinnedApiVersion); + string cached; + connection.ResolvedEndpointVersions.TryGetValue(endpointName, out cached); + + List candidates = OrderCandidates(allCandidates, pinned, cached); + + if (candidates.Count == 0) + { + throw new InfisicalConfigurationException(string.Concat( + "No matching endpoint candidate for '", endpointName, + "' with ApiVersion='", pinned ?? string.Empty, "'.")); + } + + InfisicalApiException lastException = null; + + for (int index = 0; index < candidates.Count; index++) + { + InfisicalEndpointDefinition definition = candidates[index]; + Uri uri = InfisicalUriBuilder.Build(connection.BaseUri, definition, pathParameters, queryParameters); + InfisicalHttpResponse response = ExecuteAuthorized(connection, definition, operationName, uri, body); + + if (response.StatusCode >= 200 && response.StatusCode < 300) + { + connection.ResolvedEndpointVersions[endpointName] = definition.Version; + return response; + } + + InfisicalApiException exception = BuildApiException(response, definition); + response.Clear(); + + bool hasMoreCandidates = (index + 1) < candidates.Count; + bool pinnedHere = !string.IsNullOrEmpty(pinned); + + if (!pinnedHere && hasMoreCandidates && IsVersionMismatch(exception)) + { + _logger.Warning(Component, string.Concat( + "Endpoint '", endpointName, "' version '", definition.Version, + "' rejected by server (", exception.StatusCode.ToString(CultureInfo.InvariantCulture), + "); falling back to next candidate.")); + lastException = exception; + continue; + } + + throw exception; + } + + throw lastException ?? new InfisicalApiException(string.Concat( + "All API version candidates exhausted for '", endpointName, "'.")); + } + + private static List OrderCandidates( + IReadOnlyList allCandidates, + string pinned, + string cached) + { + List ordered = new List(); + + if (!string.IsNullOrEmpty(pinned)) + { + foreach (InfisicalEndpointDefinition candidate in allCandidates) + { + if (string.Equals(candidate.Version, pinned, StringComparison.OrdinalIgnoreCase)) + { + ordered.Add(candidate); + } + } + + return ordered; + } + + if (!string.IsNullOrEmpty(cached)) + { + foreach (InfisicalEndpointDefinition candidate in allCandidates) + { + if (string.Equals(candidate.Version, cached, StringComparison.OrdinalIgnoreCase)) + { + ordered.Add(candidate); + break; + } + } + + foreach (InfisicalEndpointDefinition candidate in allCandidates) + { + if (!string.Equals(candidate.Version, cached, StringComparison.OrdinalIgnoreCase)) + { + ordered.Add(candidate); + } + } + + return ordered; + } + + foreach (InfisicalEndpointDefinition candidate in allCandidates) + { + ordered.Add(candidate); + } + + return ordered; + } + + private static bool IsVersionMismatch(InfisicalApiException exception) + { + string body = exception.SanitizedBody; + bool hasInfisicalErrorEnvelope = !string.IsNullOrEmpty(body) + && body.IndexOf("\"reqId\"", StringComparison.OrdinalIgnoreCase) >= 0 + && body.IndexOf("\"error\"", StringComparison.OrdinalIgnoreCase) >= 0; + + if (exception.StatusCode == 405) + { + return true; + } + + if (exception.StatusCode == 404 && !hasInfisicalErrorEnvelope) + { + return true; + } + + if (exception.StatusCode == 400 && !string.IsNullOrEmpty(body)) + { + if (body.IndexOf("projectSlug", StringComparison.OrdinalIgnoreCase) >= 0 || + body.IndexOf("workspaceId", StringComparison.OrdinalIgnoreCase) >= 0) + { + return true; + } + } + + return false; + } + + private static InfisicalApiException BuildApiException(InfisicalHttpResponse response, InfisicalEndpointDefinition definition) + { + InfisicalApiException exception = new InfisicalApiException(string.Concat( + "Infisical API returned ", + response.StatusCode.ToString(CultureInfo.InvariantCulture), + " (", response.ReasonPhrase ?? string.Empty, ").")); + exception.StatusCode = response.StatusCode; + exception.ReasonPhrase = response.ReasonPhrase; + exception.EndpointName = definition.Name; + exception.RequestMethod = definition.Method; + exception.SanitizedBody = response.Body; + return exception; + } + private InfisicalHttpResponse ExecuteAuthorized(InfisicalConnection connection, InfisicalEndpointDefinition definition, string operationName, Uri uri, string body) { Dictionary headers = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -157,23 +323,6 @@ namespace PSInfisicalAPI.Secrets return _httpClient.Send(request); } - private static void EnsureSuccess(InfisicalHttpResponse response, InfisicalEndpointDefinition definition) - { - if (response.StatusCode >= 200 && response.StatusCode < 300) - { - return; - } - - InfisicalApiException exception = new InfisicalApiException(string.Concat("Infisical API returned ", response.StatusCode.ToString(CultureInfo.InvariantCulture), " (", response.ReasonPhrase ?? string.Empty, ").")); - exception.StatusCode = response.StatusCode; - exception.ReasonPhrase = response.ReasonPhrase; - exception.EndpointName = definition.Name; - exception.RequestMethod = definition.Method; - exception.SanitizedBody = definition.ContainsSecretMaterialInResponse ? "[REDACTED]" : response.Body; - response.Clear(); - throw exception; - } - private static void AddIfNotNull(List> list, string key, string value) { if (!string.IsNullOrEmpty(value))