Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 09af8939c7 | |||
| dadba2f4c8 | |||
| c114bd3b9c | |||
| 883322cadf | |||
| f65124fd99 | |||
| f56fd15b38 | |||
| 8b6823344f | |||
| f62b3e90b1 | |||
| 4c7ce00504 | |||
| 14c8c4f384 | |||
| 5e5145fdc7 | |||
| 6318d06362 | |||
| 98f5d7704e | |||
| 94bd15a8f8 | |||
| daf1cdce65 | |||
| 80871b73af | |||
| 9a2f81fc02 | |||
| 97193d46f2 | |||
| b5575222eb |
@@ -319,9 +319,65 @@ jobs:
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$moduleDir = Join-Path $PWD 'Module/PSInfisicalAPI'
|
||||
Write-Host "Publishing module from: $moduleDir"
|
||||
Publish-PSResource `
|
||||
-Path $moduleDir `
|
||||
-Repository PSGallery `
|
||||
-ApiKey $env:PSGALLERY_API_KEY `
|
||||
-Verbose
|
||||
$manifest = Test-ModuleManifest -Path (Join-Path $moduleDir 'PSInfisicalAPI.psd1')
|
||||
$version = $manifest.Version.ToString()
|
||||
|
||||
# NuGet strips leading zeros from each segment, so the manifest's 2026.07.30.2309 is listed on the
|
||||
# gallery as 2026.7.30.2309. Compare on the normalized form or every lookup misses.
|
||||
function Get-NormalizedVersion {
|
||||
param([string]$Value)
|
||||
$parts = $Value -split '\.'
|
||||
$normalized = foreach ($part in $parts) {
|
||||
$number = 0
|
||||
if ([int]::TryParse($part, [ref]$number)) { $number.ToString([System.Globalization.CultureInfo]::InvariantCulture) } else { $part }
|
||||
}
|
||||
return ($normalized -join '.')
|
||||
}
|
||||
|
||||
function Test-PublishedVersion {
|
||||
param([string]$Normalized)
|
||||
$uri = "https://www.powershellgallery.com/api/v2/FindPackagesById()?id='PSInfisicalAPI'&`$select=Version"
|
||||
try {
|
||||
$feed = Invoke-RestMethod -Uri $uri -TimeoutSec 120
|
||||
} catch {
|
||||
Write-Host "==> Could not query the gallery for existing versions: $($_.Exception.Message)"
|
||||
return $false
|
||||
}
|
||||
|
||||
foreach ($entry in @($feed)) {
|
||||
$candidate = $entry.properties.Version
|
||||
if ([string]::IsNullOrWhiteSpace($candidate)) { continue }
|
||||
if ((Get-NormalizedVersion -Value $candidate) -eq $Normalized) { return $true }
|
||||
}
|
||||
|
||||
return $false
|
||||
}
|
||||
|
||||
$normalizedVersion = Get-NormalizedVersion -Value $version
|
||||
Write-Host "==> Module version : $version (gallery form: $normalizedVersion)"
|
||||
Write-Host "==> Publishing from: $moduleDir"
|
||||
|
||||
if (Test-PublishedVersion -Normalized $normalizedVersion) {
|
||||
Write-Host "==> Version $normalizedVersion is already on the PowerShell Gallery; nothing to publish."
|
||||
exit 0
|
||||
}
|
||||
|
||||
try {
|
||||
Publish-PSResource `
|
||||
-Path $moduleDir `
|
||||
-Repository PSGallery `
|
||||
-ApiKey $env:PSGALLERY_API_KEY `
|
||||
-Verbose
|
||||
Write-Host "==> Published $normalizedVersion to the PowerShell Gallery."
|
||||
} catch {
|
||||
# A push can be accepted by the gallery and still surface as an error here; when that happens the
|
||||
# retry comes back as 409. Re-check before failing the run over an upload that actually landed.
|
||||
Write-Host "==> Publish-PSResource reported: $($_.Exception.Message)"
|
||||
Start-Sleep -Seconds 15
|
||||
if (Test-PublishedVersion -Normalized $normalizedVersion) {
|
||||
Write-Host "==> Version $normalizedVersion is present on the gallery; treating the publish as successful."
|
||||
exit 0
|
||||
}
|
||||
|
||||
throw
|
||||
}
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
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: Verify host prerequisites (pwsh, dotnet)
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$missing = @()
|
||||
if (-not (Get-Command pwsh -ErrorAction SilentlyContinue)) { $missing += 'pwsh' }
|
||||
if (-not (Get-Command dotnet -ErrorAction SilentlyContinue)) { $missing += 'dotnet' }
|
||||
if ($missing.Count -gt 0) {
|
||||
throw "Host runner is missing required tool(s): $($missing -join ', '). Provision them on the runner host."
|
||||
}
|
||||
Write-Host ("pwsh: " + (pwsh -NoProfile -Command '$PSVersionTable.PSVersion.ToString()'))
|
||||
Write-Host ("dotnet: " + (dotnet --version))
|
||||
Write-Host '--- dotnet --info ---'
|
||||
dotnet --info
|
||||
Write-Host '--- disk free ---'
|
||||
df -h .
|
||||
Write-Host '--- memory ---'
|
||||
free -m
|
||||
|
||||
- name: Restore NuGet packages
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Write-Host '==> dotnet restore src/PSInfisicalAPI/PSInfisicalAPI.csproj'
|
||||
dotnet restore src/PSInfisicalAPI/PSInfisicalAPI.csproj --verbosity normal
|
||||
if ($LASTEXITCODE -ne 0) { throw "Restore of PSInfisicalAPI.csproj failed with exit code $LASTEXITCODE" }
|
||||
Write-Host '==> dotnet restore src/PSInfisicalAPI.Tests/PSInfisicalAPI.Tests.csproj'
|
||||
dotnet restore src/PSInfisicalAPI.Tests/PSInfisicalAPI.Tests.csproj --verbosity normal
|
||||
if ($LASTEXITCODE -ne 0) { throw "Restore of PSInfisicalAPI.Tests.csproj failed with exit code $LASTEXITCODE" }
|
||||
|
||||
- name: Build module
|
||||
shell: pwsh
|
||||
run: ./build.ps1
|
||||
|
||||
- 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
|
||||
permissions:
|
||||
contents: write
|
||||
outputs:
|
||||
version: ${{ steps.meta.outputs.version }}
|
||||
tag: ${{ steps.meta.outputs.tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Verify host prerequisites (pwsh)
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if (-not (Get-Command pwsh -ErrorAction SilentlyContinue)) {
|
||||
throw "Host runner is missing required tool: pwsh. Provision it on the runner host."
|
||||
}
|
||||
Write-Host ("pwsh: " + (pwsh -NoProfile -Command '$PSVersionTable.PSVersion.ToString()'))
|
||||
|
||||
- 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 GitHub release
|
||||
shell: pwsh
|
||||
env:
|
||||
GITHUB_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'
|
||||
Set-StrictMode -Version Latest
|
||||
trap { Write-Host "==> RELEASE STEP FAILED: $($_ | Out-String)"; Write-Host ($_.ScriptStackTrace); exit 1 }
|
||||
|
||||
Write-Host "==> [1/8] Validating inputs"
|
||||
Write-Host " TAG=$($env:TAG)"
|
||||
Write-Host " VERSION=$($env:VERSION)"
|
||||
Write-Host " REPO=$($env:REPO)"
|
||||
Write-Host " API_URL=$($env:API_URL)"
|
||||
Write-Host " SERVER_URL=$($env:SERVER_URL)"
|
||||
Write-Host " PR_NUMBER=$($env:PR_NUMBER)"
|
||||
Write-Host " RUN_ID=$($env:RUN_ID)"
|
||||
if ([string]::IsNullOrWhiteSpace($env:GITHUB_TOKEN)) { throw "github.token is empty." }
|
||||
if ([string]::IsNullOrWhiteSpace($env:TAG)) { throw "TAG is empty." }
|
||||
if ([string]::IsNullOrWhiteSpace($env:VERSION)) { throw "VERSION is empty." }
|
||||
if ([string]::IsNullOrWhiteSpace($env:API_URL)) { throw "API_URL is empty." }
|
||||
if ([string]::IsNullOrWhiteSpace($env:REPO)) { throw "REPO is empty." }
|
||||
if ([string]::IsNullOrWhiteSpace($env:COMMIT_SHA)) { throw "COMMIT_SHA is empty." }
|
||||
|
||||
Write-Host "==> [2/8] Deriving metadata"
|
||||
$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)/pull/$($env:PR_NUMBER)"
|
||||
Write-Host " shortSha=$shortSha"
|
||||
|
||||
Write-Host "==> [3/8] Extracting CHANGELOG section"
|
||||
$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()
|
||||
}
|
||||
}
|
||||
Write-Host " CHANGELOG section length: $($changelogSection.Length) chars"
|
||||
|
||||
Write-Host "==> [4/8] Building release body"
|
||||
$changelogText = if ($changelogSection) { $changelogSection } else { '_No CHANGELOG section found for this version._' }
|
||||
$sb = New-Object System.Text.StringBuilder
|
||||
[void]$sb.AppendLine("**PSInfisicalAPI $($env:VERSION)**")
|
||||
[void]$sb.AppendLine('')
|
||||
[void]$sb.AppendLine('| Field | Value |')
|
||||
[void]$sb.AppendLine('| --- | --- |')
|
||||
[void]$sb.AppendLine("| Version | ``$($env:VERSION)`` |")
|
||||
[void]$sb.AppendLine("| Tag | ``$($env:TAG)`` |")
|
||||
[void]$sb.AppendLine("| Commit | [``$shortSha``]($($env:SERVER_URL)/$($env:REPO)/commit/$($env:COMMIT_SHA)) |")
|
||||
[void]$sb.AppendLine("| Built (UTC) | $buildUtc |")
|
||||
[void]$sb.AppendLine("| Merged PR | [#$($env:PR_NUMBER) $($env:PR_TITLE)]($prUrl) by @$($env:PR_AUTHOR) |")
|
||||
[void]$sb.AppendLine("| Workflow run | [$($env:RUN_ID)]($runUrl) |")
|
||||
[void]$sb.AppendLine('')
|
||||
[void]$sb.AppendLine('## Changes')
|
||||
[void]$sb.AppendLine($changelogText)
|
||||
[void]$sb.AppendLine('')
|
||||
[void]$sb.AppendLine('## Install')
|
||||
[void]$sb.AppendLine('```powershell')
|
||||
[void]$sb.AppendLine("Install-Module -Name PSInfisicalAPI -RequiredVersion $($env:VERSION) -Scope CurrentUser")
|
||||
[void]$sb.AppendLine('```')
|
||||
$body = $sb.ToString()
|
||||
Write-Host " body length: $($body.Length) chars"
|
||||
|
||||
$headers = @{
|
||||
Authorization = "Bearer $($env:GITHUB_TOKEN)"
|
||||
Accept = 'application/vnd.github+json'
|
||||
'X-GitHub-Api-Version' = '2022-11-28'
|
||||
}
|
||||
$createUri = "$($env:API_URL)/repos/$($env:REPO)/releases"
|
||||
|
||||
Write-Host "==> [5/8] Checking for existing release tag: $createUri/tags/$($env:TAG)"
|
||||
$existing = $null
|
||||
try {
|
||||
$existing = Invoke-RestMethod -Method Get -Headers $headers `
|
||||
-Uri "$createUri/tags/$($env:TAG)" -ErrorAction Stop
|
||||
} catch {
|
||||
$status = $null
|
||||
try { $status = $_.Exception.Response.StatusCode.value__ } catch { }
|
||||
if ($status -ne 404) {
|
||||
Write-Host " Lookup failed (status=$status): $($_.Exception.Message)"
|
||||
throw
|
||||
}
|
||||
Write-Host " No existing release (404)."
|
||||
}
|
||||
if ($existing) {
|
||||
Write-Host " Release tag '$($env:TAG)' already exists (id=$($existing.id)); skipping creation."
|
||||
return
|
||||
}
|
||||
|
||||
Write-Host "==> [6/8] Creating release"
|
||||
$payload = @{
|
||||
tag_name = $env:TAG
|
||||
target_commitish = $env:COMMIT_SHA
|
||||
name = "PSInfisicalAPI $($env:VERSION)"
|
||||
body = $body
|
||||
draft = $false
|
||||
prerelease = $false
|
||||
} | ConvertTo-Json -Depth 4
|
||||
Write-Host " payload bytes: $([System.Text.Encoding]::UTF8.GetByteCount($payload))"
|
||||
|
||||
$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)"
|
||||
|
||||
Write-Host "==> [7/8] Locating release asset"
|
||||
$assetPath = Join-Path $PWD "PSInfisicalAPI-$($env:VERSION).zip"
|
||||
if (-not (Test-Path $assetPath)) { throw "Release asset not found at: $assetPath" }
|
||||
$fileBytes = [System.IO.File]::ReadAllBytes($assetPath)
|
||||
Write-Host " Asset: $assetPath ($([math]::Round($fileBytes.Length / 1KB, 1)) KB)"
|
||||
|
||||
Write-Host "==> [8/8] Uploading asset"
|
||||
# GitHub returns a URI Template in upload_url (e.g. "https://uploads.github.com/.../assets{?name,label}").
|
||||
# Strip the template suffix and append the asset name query.
|
||||
$uploadBase = ($release.upload_url -replace '\{.*\}$', '')
|
||||
$uploadUri = "$uploadBase`?name=PSInfisicalAPI-$($env:VERSION).zip"
|
||||
Invoke-RestMethod -Method Post -Uri $uploadUri -Headers $headers `
|
||||
-ContentType 'application/zip' -Body $fileBytes | Out-Null
|
||||
Write-Host "==> Done: uploaded PSInfisicalAPI-$($env:VERSION).zip"
|
||||
|
||||
publish:
|
||||
needs: release
|
||||
if: ${{ success() && github.event.pull_request.merged == true }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Verify host prerequisites (pwsh)
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if (-not (Get-Command pwsh -ErrorAction SilentlyContinue)) {
|
||||
throw "Host runner is missing required tool: pwsh. Provision it on the runner host."
|
||||
}
|
||||
Write-Host ("pwsh: " + (pwsh -NoProfile -Command '$PSVersionTable.PSVersion.ToString()'))
|
||||
|
||||
- name: Download module artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: PSInfisicalAPI-module
|
||||
path: Module/PSInfisicalAPI
|
||||
|
||||
- name: Bootstrap Microsoft.PowerShell.PSResourceGet
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if (-not (Get-Module -ListAvailable -Name Microsoft.PowerShell.PSResourceGet)) {
|
||||
Write-Host "==> Installing Microsoft.PowerShell.PSResourceGet for CurrentUser"
|
||||
Install-Module -Name Microsoft.PowerShell.PSResourceGet -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop
|
||||
}
|
||||
Import-Module Microsoft.PowerShell.PSResourceGet -ErrorAction Stop
|
||||
|
||||
$existing = Get-PSResourceRepository -Name PSGallery -ErrorAction SilentlyContinue
|
||||
if (-not $existing) {
|
||||
Write-Host "==> Registering PSGallery repository"
|
||||
Register-PSResourceRepository -PSGallery -Trusted -ErrorAction Stop
|
||||
} else {
|
||||
Write-Host "==> PSGallery already registered; ensuring Trusted + ApiVersion v2"
|
||||
Set-PSResourceRepository -Name PSGallery -Trusted -ApiVersion v2 -ErrorAction Stop
|
||||
}
|
||||
Get-PSResourceRepository -Name PSGallery | Format-Table Name,Uri,Trusted,ApiVersion
|
||||
|
||||
- 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'
|
||||
$manifest = Test-ModuleManifest -Path (Join-Path $moduleDir 'PSInfisicalAPI.psd1')
|
||||
$version = $manifest.Version.ToString()
|
||||
|
||||
# NuGet strips leading zeros from each segment, so the manifest's 2026.07.30.2309 is listed on the
|
||||
# gallery as 2026.7.30.2309. Compare on the normalized form or every lookup misses.
|
||||
function Get-NormalizedVersion {
|
||||
param([string]$Value)
|
||||
$parts = $Value -split '\.'
|
||||
$normalized = foreach ($part in $parts) {
|
||||
$number = 0
|
||||
if ([int]::TryParse($part, [ref]$number)) { $number.ToString([System.Globalization.CultureInfo]::InvariantCulture) } else { $part }
|
||||
}
|
||||
return ($normalized -join '.')
|
||||
}
|
||||
|
||||
function Test-PublishedVersion {
|
||||
param([string]$Normalized)
|
||||
$uri = "https://www.powershellgallery.com/api/v2/FindPackagesById()?id='PSInfisicalAPI'&`$select=Version"
|
||||
try {
|
||||
$feed = Invoke-RestMethod -Uri $uri -TimeoutSec 120
|
||||
} catch {
|
||||
Write-Host "==> Could not query the gallery for existing versions: $($_.Exception.Message)"
|
||||
return $false
|
||||
}
|
||||
|
||||
foreach ($entry in @($feed)) {
|
||||
$candidate = $entry.properties.Version
|
||||
if ([string]::IsNullOrWhiteSpace($candidate)) { continue }
|
||||
if ((Get-NormalizedVersion -Value $candidate) -eq $Normalized) { return $true }
|
||||
}
|
||||
|
||||
return $false
|
||||
}
|
||||
|
||||
$normalizedVersion = Get-NormalizedVersion -Value $version
|
||||
Write-Host "==> Module version : $version (gallery form: $normalizedVersion)"
|
||||
Write-Host "==> Publishing from: $moduleDir"
|
||||
|
||||
if (Test-PublishedVersion -Normalized $normalizedVersion) {
|
||||
Write-Host "==> Version $normalizedVersion is already on the PowerShell Gallery; nothing to publish."
|
||||
exit 0
|
||||
}
|
||||
|
||||
try {
|
||||
Publish-PSResource `
|
||||
-Path $moduleDir `
|
||||
-Repository PSGallery `
|
||||
-ApiKey $env:PSGALLERY_API_KEY `
|
||||
-Verbose
|
||||
Write-Host "==> Published $normalizedVersion to the PowerShell Gallery."
|
||||
} catch {
|
||||
# A push can be accepted by the gallery and still surface as an error here; when that happens the
|
||||
# retry comes back as 409. Re-check before failing the run over an upload that actually landed.
|
||||
Write-Host "==> Publish-PSResource reported: $($_.Exception.Message)"
|
||||
Start-Sleep -Seconds 15
|
||||
if (Test-PublishedVersion -Normalized $normalizedVersion) {
|
||||
Write-Host "==> Version $normalizedVersion is present on the gallery; treating the publish as successful."
|
||||
exit 0
|
||||
}
|
||||
|
||||
throw
|
||||
}
|
||||
+140
-10
File diff suppressed because one or more lines are too long
@@ -38,11 +38,12 @@
|
||||
</ViewSelectedBy>
|
||||
<TableControl>
|
||||
<TableHeaders>
|
||||
<TableColumnHeader><Label>Name</Label><Width>28</Width></TableColumnHeader>
|
||||
<TableColumnHeader><Label>CommonName</Label><Width>32</Width></TableColumnHeader>
|
||||
<TableColumnHeader><Label>Type</Label><Width>10</Width></TableColumnHeader>
|
||||
<TableColumnHeader><Label>Status</Label><Width>10</Width></TableColumnHeader>
|
||||
<TableColumnHeader><Label>KeyAlgorithm</Label><Width>14</Width></TableColumnHeader>
|
||||
<TableColumnHeader><Label>Name</Label><Width>24</Width></TableColumnHeader>
|
||||
<TableColumnHeader><Label>CommonName</Label><Width>28</Width></TableColumnHeader>
|
||||
<TableColumnHeader><Label>Type</Label><Width>9</Width></TableColumnHeader>
|
||||
<TableColumnHeader><Label>Status</Label><Width>8</Width></TableColumnHeader>
|
||||
<TableColumnHeader><Label>DirectIssue</Label><Width>11</Width></TableColumnHeader>
|
||||
<TableColumnHeader><Label>KeyAlgorithm</Label><Width>13</Width></TableColumnHeader>
|
||||
<TableColumnHeader><Label>NotAfter</Label><Width>22</Width></TableColumnHeader>
|
||||
</TableHeaders>
|
||||
<TableRowEntries>
|
||||
@@ -52,6 +53,7 @@
|
||||
<TableColumnItem><PropertyName>CommonName</PropertyName></TableColumnItem>
|
||||
<TableColumnItem><PropertyName>Type</PropertyName></TableColumnItem>
|
||||
<TableColumnItem><PropertyName>Status</PropertyName></TableColumnItem>
|
||||
<TableColumnItem><PropertyName>EnableDirectIssuance</PropertyName></TableColumnItem>
|
||||
<TableColumnItem><PropertyName>KeyAlgorithm</PropertyName></TableColumnItem>
|
||||
<TableColumnItem><PropertyName>NotAfter</PropertyName></TableColumnItem>
|
||||
</TableColumnItems>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@{
|
||||
RootModule = 'PSInfisicalAPI.psm1'
|
||||
ModuleVersion = '2026.06.07.0017'
|
||||
ModuleVersion = '2026.07.30.2305'
|
||||
GUID = 'b8a2f3d4-7c51-4d2f-9e6a-1f0c8b3d4e51'
|
||||
Author = 'Grace Solutions'
|
||||
CompanyName = 'Grace Solutions'
|
||||
@@ -19,6 +19,7 @@
|
||||
'Copy-InfisicalSecret',
|
||||
'ConvertTo-InfisicalSecretDictionary',
|
||||
'Export-InfisicalSecrets',
|
||||
'Import-InfisicalSecret',
|
||||
'Get-InfisicalProject',
|
||||
'New-InfisicalProject',
|
||||
'Update-InfisicalProject',
|
||||
@@ -60,6 +61,7 @@
|
||||
'Export-InfisicalScepMdmProfile',
|
||||
'Write-InfisicalScepMdmProfileToWmi',
|
||||
'Start-InfisicalProcess',
|
||||
'Get-InfisicalEnvironmentVariable',
|
||||
'Get-InfisicalSANList'
|
||||
)
|
||||
AliasesToExport = @()
|
||||
@@ -72,7 +74,7 @@
|
||||
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 = '77cb03ec9845'
|
||||
CommitHash = 'f65124fd9911'
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -295,7 +295,7 @@ $CopyInfisicalSecretResult = Copy-InfisicalSecret @CopyInfisicalSecretParameters
|
||||
<command:noun>InfisicalSecretDictionary</command:noun>
|
||||
</command:details>
|
||||
<maml:description>
|
||||
<maml:para>Aggregates an incoming pipeline of InfisicalSecret objects into a case-insensitive Dictionary keyed by SecretName. By default values are SecureString; pass -AsPlainText to materialize string values. Duplicate keys are handled via the -DuplicateKeyBehavior parameter (Error, FirstWins, LastWins).</maml:para>
|
||||
<maml:para>Aggregates an incoming pipeline of InfisicalSecret objects into a case-insensitive Dictionary keyed by SecretName. By default values are SecureString; pass -AsPlainText to materialize string values. Duplicate keys are handled via the -DuplicateKeyBehavior parameter (Error, FirstWins, LastWins). -Prefix prepends a string to every dictionary key (e.g. SecretName 'API_KEY' with -Prefix 'MYAPP_' becomes key 'MYAPP_API_KEY'); the underlying InfisicalSecret objects are not mutated. A SecretName that already starts with -Prefix (case-insensitive) is left as-is to avoid double-prefixing; pass -ForcePrefix to always prepend.</maml:para>
|
||||
</maml:description>
|
||||
<maml:alertSet>
|
||||
<maml:title>Notes</maml:title>
|
||||
@@ -322,6 +322,11 @@ $ConvertToInfisicalSecretDictionaryParameters.Verbose = $True
|
||||
$ConvertToInfisicalSecretDictionaryResult = ConvertTo-InfisicalSecretDictionary @ConvertToInfisicalSecretDictionaryParameters</dev:code>
|
||||
<dev:remarks><maml:para>Aggregates recursive secret results into a plain-text dictionary, with the last value winning on key collisions.</maml:para></dev:remarks>
|
||||
</command:example>
|
||||
<command:example>
|
||||
<maml:title>EXAMPLE 3</maml:title>
|
||||
<dev:code>Get-InfisicalSecret -ProjectId $ProjectId -Environment 'dev' | ConvertTo-InfisicalSecretDictionary -Prefix 'MYAPP_' -AsPlainText</dev:code>
|
||||
<dev:remarks><maml:para>Builds a plain-text dictionary whose keys are namespaced with 'MYAPP_' (e.g. API_KEY becomes MYAPP_API_KEY); the source InfisicalSecret objects are unchanged.</maml:para></dev:remarks>
|
||||
</command:example>
|
||||
</command:examples>
|
||||
</command:command>
|
||||
|
||||
@@ -333,7 +338,7 @@ $ConvertToInfisicalSecretDictionaryResult = ConvertTo-InfisicalSecretDictionary
|
||||
<command:noun>InfisicalSecrets</command:noun>
|
||||
</command:details>
|
||||
<maml:description>
|
||||
<maml:para>Buffers an incoming pipeline of InfisicalSecret objects and writes them to a file in the requested format (DotEnv, Json, Yaml, EnvironmentVariables, etc.) or sets them as environment variables on the chosen scope (Process, User, Machine). -Encoding controls text encoding for file outputs. -Prefix prepends a string to every emitted variable name regardless of format.</maml:para>
|
||||
<maml:para>Buffers an incoming pipeline of InfisicalSecret objects and writes them to a file in the requested format (DotEnv, Json, Yaml, EnvironmentVariables, etc.) or sets them as environment variables on the chosen scope (Process, User, Machine). -Encoding controls text encoding for file outputs. -Prefix prepends a string to every emitted variable name regardless of format; names that already start with -Prefix (case-insensitive) are left as-is to avoid double-prefixing. Pass -ForcePrefix to always prepend.</maml:para>
|
||||
</maml:description>
|
||||
<maml:alertSet>
|
||||
<maml:title>Notes</maml:title>
|
||||
@@ -369,6 +374,44 @@ $ExportInfisicalSecretsResult = Export-InfisicalSecrets @ExportInfisicalSecretsP
|
||||
</command:examples>
|
||||
</command:command>
|
||||
|
||||
<command:command xmlns:maml="http://schemas.microsoft.com/maml/2004/10" xmlns:command="http://schemas.microsoft.com/maml/dev/command/2004/10" xmlns:dev="http://schemas.microsoft.com/maml/dev/2004/10">
|
||||
<command:details>
|
||||
<command:name>Import-InfisicalSecret</command:name>
|
||||
<maml:description><maml:para>Reads a previously exported secrets file (Json, Yaml, Env, or Xml) back into a name-keyed Dictionary.</maml:para></maml:description>
|
||||
<command:verb>Import</command:verb>
|
||||
<command:noun>InfisicalSecret</command:noun>
|
||||
</command:details>
|
||||
<maml:description>
|
||||
<maml:para>Loads the file at -Path (which must exist) using the parser matching -Format and returns a case-insensitive Dictionary keyed by SecretName. By default values are SecureString; pass -AsPlainText for a plain string dictionary. -Prefix prepends to every emitted key; keys already starting with -Prefix (case-insensitive) are left as-is to avoid double-prefixing, and -ForcePrefix overrides that gate. -DuplicateKeyBehavior controls collision handling (Error, FirstWins, LastWins). The EnvironmentVariables format is intentionally not supported here; use [Environment]::GetEnvironmentVariable or Get-InfisicalEnvironmentVariable for environment-backed values.</maml:para>
|
||||
</maml:description>
|
||||
<maml:alertSet>
|
||||
<maml:title>Notes</maml:title>
|
||||
<maml:alert>
|
||||
<maml:para>Importers expect the same schema that Export-InfisicalSecrets produces (Json/Yaml = array or 'Secrets' root list of {SecretName, SecretValue}; Xml = <Secrets><Secret><SecretName/><SecretValue/>; Env = KEY=VALUE per line, '#' comments allowed). JSON and YAML additionally accept a flat key/value object as a convenience.</maml:para>
|
||||
</maml:alert>
|
||||
</maml:alertSet>
|
||||
<command:examples>
|
||||
<command:example>
|
||||
<maml:title>EXAMPLE 1</maml:title>
|
||||
<dev:code>$Secrets = Import-InfisicalSecret -Path '.\secrets.json' -Format Json</dev:code>
|
||||
<dev:remarks><maml:para>Reads a JSON export back into a Dictionary<string, SecureString> keyed by the original SecretName values.</maml:para></dev:remarks>
|
||||
</command:example>
|
||||
<command:example>
|
||||
<maml:title>EXAMPLE 2</maml:title>
|
||||
<dev:code>$ImportInfisicalSecretParameters = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase)
|
||||
$ImportInfisicalSecretParameters.Path = [System.IO.FileInfo]'.\secrets.env'
|
||||
$ImportInfisicalSecretParameters.Format = 'Env'
|
||||
$ImportInfisicalSecretParameters.Prefix = 'MYAPP_'
|
||||
$ImportInfisicalSecretParameters.DuplicateKeyBehavior = 'LastWins'
|
||||
$ImportInfisicalSecretParameters.AsPlainText = $True
|
||||
|
||||
$ImportInfisicalSecretResult = Import-InfisicalSecret @ImportInfisicalSecretParameters</dev:code>
|
||||
<dev:remarks><maml:para>Loads a .env file into a plain-text dictionary, namespacing every key with 'MYAPP_' and letting the last occurrence win on duplicates.</maml:para></dev:remarks>
|
||||
</command:example>
|
||||
</command:examples>
|
||||
</command:command>
|
||||
|
||||
|
||||
<command:command xmlns:maml="http://schemas.microsoft.com/maml/2004/10" xmlns:command="http://schemas.microsoft.com/maml/dev/command/2004/10" xmlns:dev="http://schemas.microsoft.com/maml/dev/2004/10">
|
||||
<command:details>
|
||||
<command:name>Get-InfisicalProject</command:name>
|
||||
@@ -1023,7 +1066,7 @@ $RemoveInfisicalTagResult = Remove-InfisicalTag @RemoveInfisicalTagParameters</d
|
||||
<maml:alertSet>
|
||||
<maml:title>Notes</maml:title>
|
||||
<maml:alert>
|
||||
<maml:para>ByID retrieval currently always resolves against the internal CA endpoint. CA Ids returned here are the values to pass on -CertificateAuthorityId to Request-InfisicalCertificate. The Type property distinguishes 'internal' from 'acme' when -Kind Any is used.</maml:para>
|
||||
<maml:para>ByID retrieval currently always resolves against the internal CA endpoint. CA Ids returned here are the values to pass on -CertificateAuthorityId to Request-InfisicalCertificate. The Type property distinguishes 'internal' from 'acme' when -Kind Any is used. Only CAs whose EnableDirectIssuance property is True can sign a CSR through -CertificateAuthorityId; the others must issue through Request-InfisicalCertificate -CertificateProfileId, which bypasses that check. EnableDirectIssuance is fixed at CA creation and appears in no Infisical update schema, so it cannot be toggled afterwards; a CA migrated from the older requireTemplateForIssuance column reads False permanently.</maml:para>
|
||||
</maml:alert>
|
||||
</maml:alertSet>
|
||||
<command:examples>
|
||||
@@ -1238,6 +1281,14 @@ $GetInfisicalCertificatePolicyResult = Get-InfisicalCertificatePolicy @GetInfisi
|
||||
<maml:title>Notes</maml:title>
|
||||
<maml:alert>
|
||||
<maml:para>Default -PrivateKeyProtection is 'LocalOnly': the leaf is loaded into memory without persisting the private key and PrivateKeyPem is scrubbed from the emitted result unless -PrivateKeyPath or an explicit -KeyStorageFlags binding overrides it. The reuse path completes its chain from the Infisical bundle when local stores are incomplete; pass -LocalChainOnly to suppress that fetch entirely.</maml:para>
|
||||
<maml:para>Choose the parameter set by whether the common name varies per request. -CertificateProfileId and -CertificateAuthorityId both accept a per-request common name and suit fleet enrollment; -PkiSubscriberSlug does not, because Infisical rejects any CSR whose CN differs from the subscriber's ('Common name (CN) in the CSR does not match the subscriber's common name') and allowlists the subscriber's subjectAlternativeNames. A subscriber is a single named identity, so enrolling many machines through subscribers requires one subscriber per machine.</maml:para>
|
||||
<maml:para>-CertificateAuthorityId only works against a CA that permits direct issuance (Get-InfisicalCertificateAuthority reports this as EnableDirectIssuance). The cmdlet resolves the issuer and validates this before generating a keypair, naming the subscriber, CA, or profile it will use on the verbose stream and in the -WhatIf target. Profile issuance is the only path that ignores that flag, so -CertificateProfileId works against a CA whose EnableDirectIssuance is False. Note that enableDirectIssuance appears in no Infisical create or update schema, so it cannot be changed through the API or UI after the CA exists.</maml:para>
|
||||
<maml:para>There is no -CertificateTemplateId parameter because Infisical's REST API exposes no template-based issuance route; when the API asks for 'a certificate template or subscriber', supply -CertificateProfileId or -PkiSubscriberSlug, or use a CA that allows direct issuance.</maml:para>
|
||||
<maml:para>-CommonName takes the bare value ('web01.contoso.com'), not an RDN; a leading 'CN=' is stripped because the CSR builder adds the prefix itself. -DnsName accepts the mixed output of Get-InfisicalSANList: IP literals in that list are emitted as iPAddress SAN entries rather than dNSName entries.</maml:para>
|
||||
<maml:para>When -StoreLocation is not supplied it is chosen from the process elevation: an elevated session installs to LocalMachine, otherwise CurrentUser. The choice is reported on the verbose stream. Chain members are routed by type regardless of location - self-signed certificates to the Root store, others to CertificateAuthority. When the resolved location is LocalMachine and -KeyStorageFlags was not supplied, the private key is placed in the machine key store so the installed certificate has a usable key outside the calling user's profile.</maml:para>
|
||||
<maml:para>Installing a root into CurrentUser\Root makes Windows display a modal trust confirmation dialog, and the call blocks until it is answered; in a non-interactive session this looks like a hang. The cmdlet emits a warning before blocking. Run elevated or pass -StoreLocation LocalMachine to install machine-wide without a prompt.</maml:para>
|
||||
<maml:para>Only the leaf honours -StoreName (default My). Chain members are routed by what they are: a self-signed certificate is a root and goes to the trusted-root store, anything with an issuer above it is a subordinate CA and goes to the intermediate store, for a chain of any depth. Issuers are installed before the leaf, and the chain is then validated against the machine's stores; an incomplete chain is reported as a warning naming the missing issuer, which is the condition Windows shows as "The issuer of this certificate could not be found".</maml:para>
|
||||
<maml:para>The installed certificate's Windows friendly name defaults to the common name in upper case. -FriendlyName overrides it and is accepted on every parameter set; on the -CertificateAuthorityId path the same value is additionally sent to Infisical as the issued certificate's friendlyName.</maml:para>
|
||||
</maml:alert>
|
||||
</maml:alertSet>
|
||||
<command:examples>
|
||||
@@ -1662,7 +1713,7 @@ $WriteInfisicalScepMdmProfileToWmiResult = Write-InfisicalScepMdmProfileToWmi @W
|
||||
<command:noun>InfisicalProcess</command:noun>
|
||||
</command:details>
|
||||
<maml:description>
|
||||
<maml:para>Launches the executable specified by -FilePath, captures stdout/stderr, validates the exit code against -AcceptableExitCodeList, and optionally parses output with -ParsingExpression. InfisicalSecret objects supplied via -Secret (pipeline or by name) are decrypted into the ProcessStartInfo.Environment dictionary only, never written to the user or machine scope; -Prefix prepends a string to each injected variable name. -EnvironmentVariables adds additional non-secret values. -ExecutionTimeout, -NoWait, -CreateNoWindow, -WindowStyle, -Priority, -StandardInputObjectList, -SecureArgumentList, -LogOutput, and -ContinueOnError mirror the semantics of the upstream Start-ProcessWithOutput helper. Honors -WhatIf and -Confirm.</maml:para>
|
||||
<maml:para>Launches the executable specified by -FilePath, captures stdout/stderr, validates the exit code against -AcceptableExitCodeList, and optionally parses output with -ParsingExpression. InfisicalSecret objects supplied via -Secret (pipeline or by name) are decrypted into the ProcessStartInfo.Environment dictionary only, never written to the user or machine scope; -Prefix prepends a string to each injected variable name, skipping names that already start with -Prefix (case-insensitive) unless -ForcePrefix is supplied. -EnvironmentVariables adds additional non-secret values. -ExecutionTimeout, -NoWait, -CreateNoWindow, -WindowStyle, -Priority, -StandardInputObjectList, -SecureArgumentList, -LogOutput, and -ContinueOnError mirror the semantics of the upstream Start-ProcessWithOutput helper. Honors -WhatIf and -Confirm.</maml:para>
|
||||
</maml:description>
|
||||
<maml:alertSet>
|
||||
<maml:title>Notes</maml:title>
|
||||
@@ -1701,6 +1752,37 @@ $StartInfisicalProcessResult = Start-InfisicalProcess @StartInfisicalProcessPara
|
||||
</command:examples>
|
||||
</command:command>
|
||||
|
||||
<command:command xmlns:maml="http://schemas.microsoft.com/maml/2004/10" xmlns:command="http://schemas.microsoft.com/maml/dev/command/2004/10" xmlns:dev="http://schemas.microsoft.com/maml/dev/2004/10">
|
||||
<command:details>
|
||||
<command:name>Get-InfisicalEnvironmentVariable</command:name>
|
||||
<maml:description><maml:para>Reads an environment variable from the first scope that has a non-empty value (Process > User > Machine).</maml:para></maml:description>
|
||||
<command:verb>Get</command:verb>
|
||||
<command:noun>InfisicalEnvironmentVariable</command:noun>
|
||||
</command:details>
|
||||
<maml:description>
|
||||
<maml:para>Returns the value of -Name from the first scope that contains a non-empty value, checking Process, then User, then Machine in that order. Emits nothing when the variable is missing or blank in every scope, so an assignment yields $null without writing errors or warnings. Platform-unsupported scopes (User and Machine on non-Windows) are silently skipped.</maml:para>
|
||||
</maml:description>
|
||||
<maml:alertSet>
|
||||
<maml:title>Notes</maml:title>
|
||||
<maml:alert>
|
||||
<maml:para>Designed for the same discovery semantics the rest of PSInfisicalAPI uses when resolving connection inputs from the environment. Pipe-friendly: accepts -Name from the pipeline by value and by property name.</maml:para>
|
||||
</maml:alert>
|
||||
</maml:alertSet>
|
||||
<command:examples>
|
||||
<command:example>
|
||||
<maml:title>EXAMPLE 1</maml:title>
|
||||
<dev:code>$Value = Get-InfisicalEnvironmentVariable -Name 'INFISICAL_CLIENT_ID'</dev:code>
|
||||
<dev:remarks><maml:para>Returns the first non-empty value of INFISICAL_CLIENT_ID across Process, User, and Machine scopes; assigns $null when the variable is unset everywhere.</maml:para></dev:remarks>
|
||||
</command:example>
|
||||
<command:example>
|
||||
<maml:title>EXAMPLE 2</maml:title>
|
||||
<dev:code>@('INFISICAL_BASE_URI','INFISICAL_PROJECT_ID') | Get-InfisicalEnvironmentVariable</dev:code>
|
||||
<dev:remarks><maml:para>Pipes a list of variable names through the cmdlet and emits one value per name that is set in any scope.</maml:para></dev:remarks>
|
||||
</command:example>
|
||||
</command:examples>
|
||||
</command:command>
|
||||
|
||||
|
||||
<command:command xmlns:maml="http://schemas.microsoft.com/maml/2004/10" xmlns:command="http://schemas.microsoft.com/maml/dev/command/2004/10" xmlns:dev="http://schemas.microsoft.com/maml/dev/2004/10">
|
||||
<command:details>
|
||||
<command:name>Get-InfisicalOrganization</command:name>
|
||||
|
||||
@@ -295,7 +295,7 @@ $CopyInfisicalSecretResult = Copy-InfisicalSecret @CopyInfisicalSecretParameters
|
||||
<command:noun>InfisicalSecretDictionary</command:noun>
|
||||
</command:details>
|
||||
<maml:description>
|
||||
<maml:para>Aggregates an incoming pipeline of InfisicalSecret objects into a case-insensitive Dictionary keyed by SecretName. By default values are SecureString; pass -AsPlainText to materialize string values. Duplicate keys are handled via the -DuplicateKeyBehavior parameter (Error, FirstWins, LastWins).</maml:para>
|
||||
<maml:para>Aggregates an incoming pipeline of InfisicalSecret objects into a case-insensitive Dictionary keyed by SecretName. By default values are SecureString; pass -AsPlainText to materialize string values. Duplicate keys are handled via the -DuplicateKeyBehavior parameter (Error, FirstWins, LastWins). -Prefix prepends a string to every dictionary key (e.g. SecretName 'API_KEY' with -Prefix 'MYAPP_' becomes key 'MYAPP_API_KEY'); the underlying InfisicalSecret objects are not mutated. A SecretName that already starts with -Prefix (case-insensitive) is left as-is to avoid double-prefixing; pass -ForcePrefix to always prepend.</maml:para>
|
||||
</maml:description>
|
||||
<maml:alertSet>
|
||||
<maml:title>Notes</maml:title>
|
||||
@@ -322,6 +322,11 @@ $ConvertToInfisicalSecretDictionaryParameters.Verbose = $True
|
||||
$ConvertToInfisicalSecretDictionaryResult = ConvertTo-InfisicalSecretDictionary @ConvertToInfisicalSecretDictionaryParameters</dev:code>
|
||||
<dev:remarks><maml:para>Aggregates recursive secret results into a plain-text dictionary, with the last value winning on key collisions.</maml:para></dev:remarks>
|
||||
</command:example>
|
||||
<command:example>
|
||||
<maml:title>EXAMPLE 3</maml:title>
|
||||
<dev:code>Get-InfisicalSecret -ProjectId $ProjectId -Environment 'dev' | ConvertTo-InfisicalSecretDictionary -Prefix 'MYAPP_' -AsPlainText</dev:code>
|
||||
<dev:remarks><maml:para>Builds a plain-text dictionary whose keys are namespaced with 'MYAPP_' (e.g. API_KEY becomes MYAPP_API_KEY); the source InfisicalSecret objects are unchanged.</maml:para></dev:remarks>
|
||||
</command:example>
|
||||
</command:examples>
|
||||
</command:command>
|
||||
|
||||
@@ -333,7 +338,7 @@ $ConvertToInfisicalSecretDictionaryResult = ConvertTo-InfisicalSecretDictionary
|
||||
<command:noun>InfisicalSecrets</command:noun>
|
||||
</command:details>
|
||||
<maml:description>
|
||||
<maml:para>Buffers an incoming pipeline of InfisicalSecret objects and writes them to a file in the requested format (DotEnv, Json, Yaml, EnvironmentVariables, etc.) or sets them as environment variables on the chosen scope (Process, User, Machine). -Encoding controls text encoding for file outputs. -Prefix prepends a string to every emitted variable name regardless of format.</maml:para>
|
||||
<maml:para>Buffers an incoming pipeline of InfisicalSecret objects and writes them to a file in the requested format (DotEnv, Json, Yaml, EnvironmentVariables, etc.) or sets them as environment variables on the chosen scope (Process, User, Machine). -Encoding controls text encoding for file outputs. -Prefix prepends a string to every emitted variable name regardless of format; names that already start with -Prefix (case-insensitive) are left as-is to avoid double-prefixing. Pass -ForcePrefix to always prepend.</maml:para>
|
||||
</maml:description>
|
||||
<maml:alertSet>
|
||||
<maml:title>Notes</maml:title>
|
||||
@@ -369,6 +374,44 @@ $ExportInfisicalSecretsResult = Export-InfisicalSecrets @ExportInfisicalSecretsP
|
||||
</command:examples>
|
||||
</command:command>
|
||||
|
||||
<command:command xmlns:maml="http://schemas.microsoft.com/maml/2004/10" xmlns:command="http://schemas.microsoft.com/maml/dev/command/2004/10" xmlns:dev="http://schemas.microsoft.com/maml/dev/2004/10">
|
||||
<command:details>
|
||||
<command:name>Import-InfisicalSecret</command:name>
|
||||
<maml:description><maml:para>Reads a previously exported secrets file (Json, Yaml, Env, or Xml) back into a name-keyed Dictionary.</maml:para></maml:description>
|
||||
<command:verb>Import</command:verb>
|
||||
<command:noun>InfisicalSecret</command:noun>
|
||||
</command:details>
|
||||
<maml:description>
|
||||
<maml:para>Loads the file at -Path (which must exist) using the parser matching -Format and returns a case-insensitive Dictionary keyed by SecretName. By default values are SecureString; pass -AsPlainText for a plain string dictionary. -Prefix prepends to every emitted key; keys already starting with -Prefix (case-insensitive) are left as-is to avoid double-prefixing, and -ForcePrefix overrides that gate. -DuplicateKeyBehavior controls collision handling (Error, FirstWins, LastWins). The EnvironmentVariables format is intentionally not supported here; use [Environment]::GetEnvironmentVariable or Get-InfisicalEnvironmentVariable for environment-backed values.</maml:para>
|
||||
</maml:description>
|
||||
<maml:alertSet>
|
||||
<maml:title>Notes</maml:title>
|
||||
<maml:alert>
|
||||
<maml:para>Importers expect the same schema that Export-InfisicalSecrets produces (Json/Yaml = array or 'Secrets' root list of {SecretName, SecretValue}; Xml = <Secrets><Secret><SecretName/><SecretValue/>; Env = KEY=VALUE per line, '#' comments allowed). JSON and YAML additionally accept a flat key/value object as a convenience.</maml:para>
|
||||
</maml:alert>
|
||||
</maml:alertSet>
|
||||
<command:examples>
|
||||
<command:example>
|
||||
<maml:title>EXAMPLE 1</maml:title>
|
||||
<dev:code>$Secrets = Import-InfisicalSecret -Path '.\secrets.json' -Format Json</dev:code>
|
||||
<dev:remarks><maml:para>Reads a JSON export back into a Dictionary<string, SecureString> keyed by the original SecretName values.</maml:para></dev:remarks>
|
||||
</command:example>
|
||||
<command:example>
|
||||
<maml:title>EXAMPLE 2</maml:title>
|
||||
<dev:code>$ImportInfisicalSecretParameters = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase)
|
||||
$ImportInfisicalSecretParameters.Path = [System.IO.FileInfo]'.\secrets.env'
|
||||
$ImportInfisicalSecretParameters.Format = 'Env'
|
||||
$ImportInfisicalSecretParameters.Prefix = 'MYAPP_'
|
||||
$ImportInfisicalSecretParameters.DuplicateKeyBehavior = 'LastWins'
|
||||
$ImportInfisicalSecretParameters.AsPlainText = $True
|
||||
|
||||
$ImportInfisicalSecretResult = Import-InfisicalSecret @ImportInfisicalSecretParameters</dev:code>
|
||||
<dev:remarks><maml:para>Loads a .env file into a plain-text dictionary, namespacing every key with 'MYAPP_' and letting the last occurrence win on duplicates.</maml:para></dev:remarks>
|
||||
</command:example>
|
||||
</command:examples>
|
||||
</command:command>
|
||||
|
||||
|
||||
<command:command xmlns:maml="http://schemas.microsoft.com/maml/2004/10" xmlns:command="http://schemas.microsoft.com/maml/dev/command/2004/10" xmlns:dev="http://schemas.microsoft.com/maml/dev/2004/10">
|
||||
<command:details>
|
||||
<command:name>Get-InfisicalProject</command:name>
|
||||
@@ -1023,7 +1066,7 @@ $RemoveInfisicalTagResult = Remove-InfisicalTag @RemoveInfisicalTagParameters</d
|
||||
<maml:alertSet>
|
||||
<maml:title>Notes</maml:title>
|
||||
<maml:alert>
|
||||
<maml:para>ByID retrieval currently always resolves against the internal CA endpoint. CA Ids returned here are the values to pass on -CertificateAuthorityId to Request-InfisicalCertificate. The Type property distinguishes 'internal' from 'acme' when -Kind Any is used.</maml:para>
|
||||
<maml:para>ByID retrieval currently always resolves against the internal CA endpoint. CA Ids returned here are the values to pass on -CertificateAuthorityId to Request-InfisicalCertificate. The Type property distinguishes 'internal' from 'acme' when -Kind Any is used. Only CAs whose EnableDirectIssuance property is True can sign a CSR through -CertificateAuthorityId; the others must issue through Request-InfisicalCertificate -CertificateProfileId, which bypasses that check. EnableDirectIssuance is fixed at CA creation and appears in no Infisical update schema, so it cannot be toggled afterwards; a CA migrated from the older requireTemplateForIssuance column reads False permanently.</maml:para>
|
||||
</maml:alert>
|
||||
</maml:alertSet>
|
||||
<command:examples>
|
||||
@@ -1238,6 +1281,14 @@ $GetInfisicalCertificatePolicyResult = Get-InfisicalCertificatePolicy @GetInfisi
|
||||
<maml:title>Notes</maml:title>
|
||||
<maml:alert>
|
||||
<maml:para>Default -PrivateKeyProtection is 'LocalOnly': the leaf is loaded into memory without persisting the private key and PrivateKeyPem is scrubbed from the emitted result unless -PrivateKeyPath or an explicit -KeyStorageFlags binding overrides it. The reuse path completes its chain from the Infisical bundle when local stores are incomplete; pass -LocalChainOnly to suppress that fetch entirely.</maml:para>
|
||||
<maml:para>Choose the parameter set by whether the common name varies per request. -CertificateProfileId and -CertificateAuthorityId both accept a per-request common name and suit fleet enrollment; -PkiSubscriberSlug does not, because Infisical rejects any CSR whose CN differs from the subscriber's ('Common name (CN) in the CSR does not match the subscriber's common name') and allowlists the subscriber's subjectAlternativeNames. A subscriber is a single named identity, so enrolling many machines through subscribers requires one subscriber per machine.</maml:para>
|
||||
<maml:para>-CertificateAuthorityId only works against a CA that permits direct issuance (Get-InfisicalCertificateAuthority reports this as EnableDirectIssuance). The cmdlet resolves the issuer and validates this before generating a keypair, naming the subscriber, CA, or profile it will use on the verbose stream and in the -WhatIf target. Profile issuance is the only path that ignores that flag, so -CertificateProfileId works against a CA whose EnableDirectIssuance is False. Note that enableDirectIssuance appears in no Infisical create or update schema, so it cannot be changed through the API or UI after the CA exists.</maml:para>
|
||||
<maml:para>There is no -CertificateTemplateId parameter because Infisical's REST API exposes no template-based issuance route; when the API asks for 'a certificate template or subscriber', supply -CertificateProfileId or -PkiSubscriberSlug, or use a CA that allows direct issuance.</maml:para>
|
||||
<maml:para>-CommonName takes the bare value ('web01.contoso.com'), not an RDN; a leading 'CN=' is stripped because the CSR builder adds the prefix itself. -DnsName accepts the mixed output of Get-InfisicalSANList: IP literals in that list are emitted as iPAddress SAN entries rather than dNSName entries.</maml:para>
|
||||
<maml:para>When -StoreLocation is not supplied it is chosen from the process elevation: an elevated session installs to LocalMachine, otherwise CurrentUser. The choice is reported on the verbose stream. Chain members are routed by type regardless of location - self-signed certificates to the Root store, others to CertificateAuthority. When the resolved location is LocalMachine and -KeyStorageFlags was not supplied, the private key is placed in the machine key store so the installed certificate has a usable key outside the calling user's profile.</maml:para>
|
||||
<maml:para>Installing a root into CurrentUser\Root makes Windows display a modal trust confirmation dialog, and the call blocks until it is answered; in a non-interactive session this looks like a hang. The cmdlet emits a warning before blocking. Run elevated or pass -StoreLocation LocalMachine to install machine-wide without a prompt.</maml:para>
|
||||
<maml:para>Only the leaf honours -StoreName (default My). Chain members are routed by what they are: a self-signed certificate is a root and goes to the trusted-root store, anything with an issuer above it is a subordinate CA and goes to the intermediate store, for a chain of any depth. Issuers are installed before the leaf, and the chain is then validated against the machine's stores; an incomplete chain is reported as a warning naming the missing issuer, which is the condition Windows shows as "The issuer of this certificate could not be found".</maml:para>
|
||||
<maml:para>The installed certificate's Windows friendly name defaults to the common name in upper case. -FriendlyName overrides it and is accepted on every parameter set; on the -CertificateAuthorityId path the same value is additionally sent to Infisical as the issued certificate's friendlyName.</maml:para>
|
||||
</maml:alert>
|
||||
</maml:alertSet>
|
||||
<command:examples>
|
||||
@@ -1662,7 +1713,7 @@ $WriteInfisicalScepMdmProfileToWmiResult = Write-InfisicalScepMdmProfileToWmi @W
|
||||
<command:noun>InfisicalProcess</command:noun>
|
||||
</command:details>
|
||||
<maml:description>
|
||||
<maml:para>Launches the executable specified by -FilePath, captures stdout/stderr, validates the exit code against -AcceptableExitCodeList, and optionally parses output with -ParsingExpression. InfisicalSecret objects supplied via -Secret (pipeline or by name) are decrypted into the ProcessStartInfo.Environment dictionary only, never written to the user or machine scope; -Prefix prepends a string to each injected variable name. -EnvironmentVariables adds additional non-secret values. -ExecutionTimeout, -NoWait, -CreateNoWindow, -WindowStyle, -Priority, -StandardInputObjectList, -SecureArgumentList, -LogOutput, and -ContinueOnError mirror the semantics of the upstream Start-ProcessWithOutput helper. Honors -WhatIf and -Confirm.</maml:para>
|
||||
<maml:para>Launches the executable specified by -FilePath, captures stdout/stderr, validates the exit code against -AcceptableExitCodeList, and optionally parses output with -ParsingExpression. InfisicalSecret objects supplied via -Secret (pipeline or by name) are decrypted into the ProcessStartInfo.Environment dictionary only, never written to the user or machine scope; -Prefix prepends a string to each injected variable name, skipping names that already start with -Prefix (case-insensitive) unless -ForcePrefix is supplied. -EnvironmentVariables adds additional non-secret values. -ExecutionTimeout, -NoWait, -CreateNoWindow, -WindowStyle, -Priority, -StandardInputObjectList, -SecureArgumentList, -LogOutput, and -ContinueOnError mirror the semantics of the upstream Start-ProcessWithOutput helper. Honors -WhatIf and -Confirm.</maml:para>
|
||||
</maml:description>
|
||||
<maml:alertSet>
|
||||
<maml:title>Notes</maml:title>
|
||||
@@ -1701,6 +1752,37 @@ $StartInfisicalProcessResult = Start-InfisicalProcess @StartInfisicalProcessPara
|
||||
</command:examples>
|
||||
</command:command>
|
||||
|
||||
<command:command xmlns:maml="http://schemas.microsoft.com/maml/2004/10" xmlns:command="http://schemas.microsoft.com/maml/dev/command/2004/10" xmlns:dev="http://schemas.microsoft.com/maml/dev/2004/10">
|
||||
<command:details>
|
||||
<command:name>Get-InfisicalEnvironmentVariable</command:name>
|
||||
<maml:description><maml:para>Reads an environment variable from the first scope that has a non-empty value (Process > User > Machine).</maml:para></maml:description>
|
||||
<command:verb>Get</command:verb>
|
||||
<command:noun>InfisicalEnvironmentVariable</command:noun>
|
||||
</command:details>
|
||||
<maml:description>
|
||||
<maml:para>Returns the value of -Name from the first scope that contains a non-empty value, checking Process, then User, then Machine in that order. Emits nothing when the variable is missing or blank in every scope, so an assignment yields $null without writing errors or warnings. Platform-unsupported scopes (User and Machine on non-Windows) are silently skipped.</maml:para>
|
||||
</maml:description>
|
||||
<maml:alertSet>
|
||||
<maml:title>Notes</maml:title>
|
||||
<maml:alert>
|
||||
<maml:para>Designed for the same discovery semantics the rest of PSInfisicalAPI uses when resolving connection inputs from the environment. Pipe-friendly: accepts -Name from the pipeline by value and by property name.</maml:para>
|
||||
</maml:alert>
|
||||
</maml:alertSet>
|
||||
<command:examples>
|
||||
<command:example>
|
||||
<maml:title>EXAMPLE 1</maml:title>
|
||||
<dev:code>$Value = Get-InfisicalEnvironmentVariable -Name 'INFISICAL_CLIENT_ID'</dev:code>
|
||||
<dev:remarks><maml:para>Returns the first non-empty value of INFISICAL_CLIENT_ID across Process, User, and Machine scopes; assigns $null when the variable is unset everywhere.</maml:para></dev:remarks>
|
||||
</command:example>
|
||||
<command:example>
|
||||
<maml:title>EXAMPLE 2</maml:title>
|
||||
<dev:code>@('INFISICAL_BASE_URI','INFISICAL_PROJECT_ID') | Get-InfisicalEnvironmentVariable</dev:code>
|
||||
<dev:remarks><maml:para>Pipes a list of variable names through the cmdlet and emits one value per name that is set in any scope.</maml:para></dev:remarks>
|
||||
</command:example>
|
||||
</command:examples>
|
||||
</command:command>
|
||||
|
||||
|
||||
<command:command xmlns:maml="http://schemas.microsoft.com/maml/2004/10" xmlns:command="http://schemas.microsoft.com/maml/dev/command/2004/10" xmlns:dev="http://schemas.microsoft.com/maml/dev/2004/10">
|
||||
<command:details>
|
||||
<command:name>Get-InfisicalOrganization</command:name>
|
||||
|
||||
@@ -84,6 +84,39 @@ EXAMPLES
|
||||
Get-InfisicalSecrets |
|
||||
Export-InfisicalSecrets -Path .\secrets.env -Format Env
|
||||
|
||||
ERROR HANDLING AND STREAMS
|
||||
Every cmdlet derives from PSCmdlet, so the common parameters are bound:
|
||||
-Verbose, -Debug, -ErrorAction, -ErrorVariable, -WarningAction,
|
||||
-WarningVariable, -InformationAction, -InformationVariable, -OutVariable,
|
||||
and -PipelineVariable, plus -WhatIf/-Confirm where ShouldProcess applies.
|
||||
|
||||
Output is separated by stream so those parameters mean what they say:
|
||||
|
||||
Error The failure itself, once, as a non-terminating ErrorRecord.
|
||||
Warning Advisories that are not failures.
|
||||
Verbose Request/response trace and the trail leading up to a failure.
|
||||
Debug Low-level detail.
|
||||
|
||||
Operation failures are NON-TERMINATING, so -ErrorAction decides the
|
||||
outcome:
|
||||
|
||||
Continue (default) Error is written; a pipeline keeps processing.
|
||||
SilentlyContinue Nothing printed; still in $Error/-ErrorVariable.
|
||||
Ignore Nothing printed and nothing recorded.
|
||||
Stop Promoted to terminating; try/catch catches it.
|
||||
|
||||
To catch a failure you must ask for it:
|
||||
|
||||
try {
|
||||
Request-InfisicalCertificate @Parameters -ErrorAction Stop
|
||||
} catch [PSInfisicalAPI.Errors.InfisicalApiException] {
|
||||
"HTTP $($_.Exception.StatusCode): $($_.Exception.ApiErrorMessage)"
|
||||
}
|
||||
|
||||
The ErrorRecord carries the API detail, so log scraping is unnecessary:
|
||||
$Error[0].Exception exposes StatusCode, ApiErrorCode, ApiErrorMessage, and
|
||||
ApiRequestId on InfisicalApiException.
|
||||
|
||||
SECURITY NOTES
|
||||
- SecureString is used for ClientSecret, AccessToken, and any secret
|
||||
payloads returned by the API.
|
||||
|
||||
@@ -146,7 +146,9 @@ Disconnect-Infisical
|
||||
|
||||
## End-to-end: request and install a chained certificate
|
||||
|
||||
Connects, selects a project by name, sources SANs from `Get-InfisicalSANList`, picks the first available internal CA, requests a certificate, installs it (and its chain) into the current-user store, and disconnects. Each call uses a splatted `OrderedDictionary` constructed with `OrdinalIgnoreCase` so parameter names round-trip case-insensitively.
|
||||
Connects, selects a `cert-manager` project, sources SANs from `Get-InfisicalSANList`, requests a certificate through a certificate profile, installs it (and its chain) into the current-user store, and disconnects. Each call uses a splatted `OrderedDictionary` constructed with `OrdinalIgnoreCase` so parameter names round-trip case-insensitively.
|
||||
|
||||
This is the shape to use for **fleet enrollment**, where each machine needs its own common name. See [Choosing an issuance path](#choosing-an-issuance-path) — a PKI subscriber is *not* the right tool for this, because it pins one fixed common name.
|
||||
|
||||
```powershell
|
||||
$ConnectInfisicalParameters = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase)
|
||||
@@ -159,25 +161,315 @@ $ConnectInfisicalParameters = New-Object -TypeName 'System.Collections.Specializ
|
||||
|
||||
$Connection = Connect-Infisical @ConnectInfisicalParameters
|
||||
|
||||
$Project = Get-InfisicalProject | Where-Object {($_.Name -eq 'Platform')} | Select-Object -First 1
|
||||
$Ca = Get-InfisicalCertificateAuthority -ProjectId ($Project.Id) | Select-Object -First 1
|
||||
$Project = Get-InfisicalProject -Type cert-manager | Select-Object -First 1
|
||||
|
||||
$Project
|
||||
|
||||
#region Certificate authorities. Not required for profile issuance - the profile already binds its CA - but
|
||||
# useful for confirming the chain you expect to be installed.
|
||||
$CAList = Get-InfisicalCertificateAuthority -ProjectId ($Project.Id) -Kind Internal
|
||||
|
||||
$RootCA = $CAList | Where-Object {([String]::IsNullOrEmpty($_.ParentCaId) -eq $True)} | Select-Object -First 1
|
||||
|
||||
$RootCA
|
||||
|
||||
$IntermediateCA = $CAList | Where-Object {([String]::IsNullOrEmpty($_.ParentCaId) -eq $False)}
|
||||
|
||||
$IntermediateCA
|
||||
#endregion
|
||||
|
||||
$CertificateProfile = Get-InfisicalCertificateProfile -ProjectId ($Project.Id) -IncludeConfigs |
|
||||
Where-Object {($_.EnrollmentType -iin @('API')) -and ($_.Slug -imatch '.*Server.*')} |
|
||||
Select-Object -First 1
|
||||
|
||||
$CertificateProfile
|
||||
|
||||
$SanList = Get-InfisicalSANList
|
||||
|
||||
$SanList
|
||||
|
||||
$RequestInfisicalCertificateParameters = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase)
|
||||
$RequestInfisicalCertificateParameters.ProjectId = $Project.Id
|
||||
$RequestInfisicalCertificateParameters.CertificateAuthorityId = $Ca.Id
|
||||
$RequestInfisicalCertificateParameters.CommonName = "CN=$($Env:ComputerName.ToUpper())"
|
||||
$RequestInfisicalCertificateParameters.DnsName = New-Object -TypeName 'System.Collections.Generic.List[System.String]'
|
||||
$RequestInfisicalCertificateParameters.ProjectId = $Project.Id
|
||||
$RequestInfisicalCertificateParameters.CertificateProfileId = $CertificateProfile.Id
|
||||
$RequestInfisicalCertificateParameters.CommonName = $Env:ComputerName.ToUpper()
|
||||
$RequestInfisicalCertificateParameters.DnsName = New-Object -TypeName 'System.Collections.Generic.List[System.String]'
|
||||
$RequestInfisicalCertificateParameters.DnsName.AddRange($SanList)
|
||||
$RequestInfisicalCertificateParameters.DnsName.Add('myrecord.mydomain.com')
|
||||
$RequestInfisicalCertificateParameters.Ttl = '90d'
|
||||
$RequestInfisicalCertificateParameters.Install = $True
|
||||
$RequestInfisicalCertificateParameters.InstallChain = $True
|
||||
$Certificate = Request-InfisicalCertificate @RequestInfisicalCertificateParameters
|
||||
$RequestInfisicalCertificateParameters.DnsName.Add('app.contoso.com')
|
||||
$RequestInfisicalCertificateParameters.DnsName.Add('api.contoso.com')
|
||||
$RequestInfisicalCertificateParameters.DnsName.Add('boot.contoso.com')
|
||||
$RequestInfisicalCertificateParameters.Ttl = '90d'
|
||||
$RequestInfisicalCertificateParameters.Install = $True
|
||||
$RequestInfisicalCertificateParameters.InstallChain = $True
|
||||
$RequestInfisicalCertificateParameters.Verbose = $True
|
||||
|
||||
$Certificate = Request-InfisicalCertificate @RequestInfisicalCertificateParameters
|
||||
|
||||
$Null = Disconnect-Infisical -Verbose
|
||||
```
|
||||
|
||||
Note `$CertificateProfile` rather than `$Profile`: `$Profile` is an automatic variable in PowerShell (the path to the current profile script), and assigning to it works but shadows something the host relies on.
|
||||
|
||||
`-StoreName`/`-StoreLocation` are omitted deliberately — see [Where certificates get installed](#where-certificates-get-installed).
|
||||
|
||||
### Example output
|
||||
|
||||
```text
|
||||
Id : 00000000-0000-0000-0000-000000000000
|
||||
Name : Microsoft Endpoint Configuration Manager
|
||||
Slug : mecm
|
||||
Description :
|
||||
OrganizationId : 11111111-1111-1111-1111-111111111111
|
||||
Type : cert-manager
|
||||
AutoCapitalization : False
|
||||
EnvironmentSlugs : {dev, staging, prod}
|
||||
CreatedAtUtc : 3/12/2026 8:32:52 PM +00:00
|
||||
UpdatedAtUtc : 6/21/2026 7:00:28 PM +00:00
|
||||
|
||||
Name : root-ca
|
||||
CommonName : Contoso Root Certificate Authority
|
||||
Type : internal
|
||||
Status : active
|
||||
KeyAlgorithm : RSA_2048
|
||||
NotAfter : 03/25/2036 00:00:00
|
||||
Id : 22222222-2222-2222-2222-222222222222
|
||||
|
||||
Name : intermediate-ca
|
||||
CommonName : Contoso Intermediate Certificate Authority
|
||||
Type : internal
|
||||
Status : active
|
||||
KeyAlgorithm : RSA_2048
|
||||
NotAfter : 03/25/2031 00:00:00
|
||||
Id : 33333333-3333-3333-3333-333333333333
|
||||
|
||||
Id : 44444444-4444-4444-4444-444444444444
|
||||
ProjectId : 00000000-0000-0000-0000-000000000000
|
||||
CaId : 33333333-3333-3333-3333-333333333333
|
||||
CertificatePolicyId : 55555555-5555-5555-5555-555555555555
|
||||
Slug : serverauthentication
|
||||
Description :
|
||||
EnrollmentType : api
|
||||
IssuerType : ca
|
||||
EstConfigId :
|
||||
ApiConfigId : 66666666-6666-6666-6666-666666666666
|
||||
AcmeConfigId :
|
||||
ScepConfigId :
|
||||
CreatedAtUtc : 3/25/2026 4:53:53 PM +00:00
|
||||
UpdatedAtUtc : 3/25/2026 4:53:53 PM +00:00
|
||||
Defaults : PSInfisicalAPI.Models.InfisicalCertificateProfileDefaults
|
||||
CertificateAuthority : PSInfisicalAPI.Models.InfisicalCertificateAuthoritySummary
|
||||
CertificatePolicy :
|
||||
ApiConfig : PSInfisicalAPI.Models.InfisicalCertificateProfileApiConfig
|
||||
|
||||
WEB01
|
||||
10.20.30.40
|
||||
WEB01.contoso.com
|
||||
127.0.0.1
|
||||
::1
|
||||
|
||||
VERBOSE: [...] - [Information] - [PkiClient] - Attempting to search Infisical certificates. Please Wait...
|
||||
VERBOSE: [...] - [Verbose] - [HttpClient] - Attempting HTTP POST to https://infisical.contoso.com/api/v1/projects/00000000-0000-0000-0000-000000000000/certificates/search. Please Wait...
|
||||
VERBOSE: [...] - [Verbose] - [HttpClient] - HTTP POST completed with status 200.
|
||||
VERBOSE: [...] - [Information] - [PkiClient] - Infisical certificate search was successful.
|
||||
VERBOSE: [...] - [Information] - [RequestInfisicalCertificateCmdlet] - Process is elevated; defaulting -StoreLocation to LocalMachine. Pass -StoreLocation explicitly to override.
|
||||
VERBOSE: [...] - [Information] - [RequestInfisicalCertificateCmdlet] - Issuing via certificate profile '44444444-4444-4444-4444-444444444444' in project '00000000-0000-0000-0000-000000000000'.
|
||||
VERBOSE: Performing the operation "Request new certificate" on target "certificate profile '44444444-4444-4444-4444-444444444444' for CN=WEB01".
|
||||
VERBOSE: [...] - [Information] - [PkiClient] - Attempting to issue certificate via profile '44444444-4444-4444-4444-444444444444'. Please Wait...
|
||||
VERBOSE: [...] - [Verbose] - [HttpClient] - Attempting HTTP POST to https://infisical.contoso.com/api/v1/cert-manager/certificates. Please Wait...
|
||||
VERBOSE: [...] - [Verbose] - [HttpClient] - HTTP POST completed with status 200.
|
||||
VERBOSE: [...] - [Information] - [PkiClient] - Infisical certificate issuance (profile) was successful.
|
||||
VERBOSE: [...] - [Information] - [RequestInfisicalCertificateCmdlet] - Installed certificate to LocalMachine\My [F480A920DFB41EA8EE3E9178C1BC6A5EC7055B96].
|
||||
VERBOSE: [...] - [Information] - [RequestInfisicalCertificateCmdlet] - Installed certificate to LocalMachine\CertificateAuthority [89A486A532D94EFE4391BEF2EA7F5E7E2B654AB0].
|
||||
VERBOSE: [...] - [Information] - [RequestInfisicalCertificateCmdlet] - Installed certificate to LocalMachine\Root [1F3A77B0C2D45E6819AB3C7D0E5F2A9B4C81D6E7].
|
||||
```
|
||||
|
||||
### Where certificates get installed
|
||||
|
||||
When `-StoreLocation` is not supplied, the cmdlet picks it from the process's elevation, and says which it chose on the verbose stream:
|
||||
|
||||
| Session | Leaf | Intermediates | Roots |
|
||||
| ------------- | -------------------------- | -------------------------------------------- | ---------------------------- |
|
||||
| Elevated | `LocalMachine\My` | `LocalMachine\CertificateAuthority` | `LocalMachine\Root` |
|
||||
| Not elevated | `CurrentUser\My` | `CurrentUser\CertificateAuthority` | `CurrentUser\Root` |
|
||||
|
||||
The routing split is deliberate:
|
||||
|
||||
- **The leaf** honours `-StoreName` (default `My`) and lands in the resolved location.
|
||||
- **Chain members** ignore `-StoreName` and are routed by what they are — a self-signed certificate is a root and goes to the trusted-root store; anything with an issuer above it is a subordinate CA and goes to the intermediate store. A chain of any depth is walked, so an issuing subordinate installs alongside every CA above it up to the root.
|
||||
|
||||
`-StoreLocation` still applies to all of them, so an elevated run puts the whole chain machine-wide and a non-elevated run puts it all under the user.
|
||||
|
||||
Issuers are installed **before** the leaf, so the certificate is chainable the moment it appears in the store. Afterwards the chain is validated against the machine's own stores, and an incomplete result is called out by name rather than left to be discovered in `certmgr`:
|
||||
|
||||
```text
|
||||
WARNING: [...] Certificate chain is incomplete (PartialChain). The highest certificate installed is
|
||||
'CN=Contoso Intermediate Certificate Authority, OU=IT, O=Contoso, C=US', whose issuer
|
||||
'CN=Contoso Root Certificate Authority, OU=IT, O=Contoso, C=US' is not present in the trusted stores.
|
||||
Windows will report "The issuer of this certificate could not be found" until that issuer is installed.
|
||||
```
|
||||
|
||||
The installed certificate's Windows friendly name defaults to the common name in upper case (`WEB01`), which is what shows in `certmgr`. Pass `-FriendlyName` on any parameter set to override it; on the `-CertificateAuthorityId` path the same value is also forwarded to Infisical as the issued certificate's `friendlyName`.
|
||||
|
||||
When the resolved location is `LocalMachine` and `-KeyStorageFlags` was not supplied, the private key is written to the machine key store. Without that the key lands in the calling user's profile while the certificate sits in `LocalMachine\My`, which is the usual cause of an installed certificate that reports no usable private key to a service.
|
||||
|
||||
> **Non-elevated root installs prompt.** Adding a root to `CurrentUser\Root` makes Windows raise a modal trust dialog, and the call blocks until it is answered — if the dialog is hidden or the session is non-interactive (a scheduled task, an MECM task sequence), the cmdlet appears to hang indefinitely. It warns before blocking. Run elevated, or pass `-StoreLocation LocalMachine`, to install machine-wide with no prompt.
|
||||
|
||||
### Choosing an issuance path
|
||||
|
||||
`Request-InfisicalCertificate` has three mutually exclusive issuance parameter sets. The deciding question is **whether the common name varies per request**:
|
||||
|
||||
| Parameter | Common name | Use when |
|
||||
| -------------------------- | ------------------------------------ | ------------------------------------------------------------------------ |
|
||||
| `-CertificateProfileId` | **Per request**, constrained by policy | Fleet enrollment — many machines, each with its own CN. Works on any CA. |
|
||||
| `-CertificateAuthorityId` | **Per request**, unconstrained | Fleet enrollment where no policy is wanted. Needs direct issuance on the CA. |
|
||||
| `-PkiSubscriberSlug` | **Fixed** by the subscriber record | One named identity — a specific service or host, provisioned in advance. |
|
||||
|
||||
**A PKI subscriber is a per-identity object, not a fleet template.** `signSubscriberCert` rejects any CSR whose CN differs from the subscriber's:
|
||||
|
||||
```text
|
||||
Common name (CN) in the CSR does not match the subscriber's common name
|
||||
```
|
||||
|
||||
It also allowlists SANs — every `dNSName`/`email` SAN in the CSR must appear in the subscriber's `subjectAlternativeNames` — and requires CSR key usages to be a subset of the subscriber's. (IP SANs are not covered by that check.) Enrolling *N* machines through subscribers therefore means creating *N* subscribers. Prefer a profile.
|
||||
|
||||
There is no `-CertificateTemplateId` parameter. Infisical's REST API exposes no template-based issuance route — templates are consumed internally by EST and subscribers — so when the API says *"Certificate template or subscriber is required for issuance"*, the reachable answers are a profile, a subscriber, or direct issuance.
|
||||
|
||||
The cmdlet resolves and reports the issuer before generating a keypair, so `-Verbose` tells you exactly what will sign the request:
|
||||
|
||||
```text
|
||||
VERBOSE: [...] - [Information] - [RequestInfisicalCertificateCmdlet] - Issuing via certificate profile 'a1b2c3d4-...' in project '2122628e-...'.
|
||||
VERBOSE: [...] - [Information] - [RequestInfisicalCertificateCmdlet] - Issuing directly via certificate authority 'intermediate-ca' (bf661d78-...); direct issuance is enabled.
|
||||
```
|
||||
|
||||
`-WhatIf` names the same issuer without issuing anything:
|
||||
|
||||
```powershell
|
||||
Request-InfisicalCertificate @RequestInfisicalCertificateParameters -WhatIf
|
||||
# What if: Performing the operation "Request new certificate" on target
|
||||
# "certificate profile 'a1b2c3d4-...' for CN=WEB01".
|
||||
```
|
||||
|
||||
#### Discovering profiles (recommended)
|
||||
|
||||
A certificate profile binds a CA to a certificate policy. The policy constrains the subject, key usages, and extended key usages with `allowed`/`required`/`denied` lists, so the common name still varies per request while staying inside guardrails.
|
||||
|
||||
```powershell
|
||||
Get-InfisicalCertificateProfile -ProjectId ($Project.Id) | Format-Table Id, Name, CaId
|
||||
Get-InfisicalCertificatePolicy -ProjectId ($Project.Id) | Format-Table Id, Name
|
||||
```
|
||||
|
||||
Profile issuance is the only path that does **not** consult the CA's direct-issuance flag — the service short-circuits it:
|
||||
|
||||
```ts
|
||||
if (!isFromProfile && !ca.enableDirectIssuance && !certificateTemplate) { throw ... }
|
||||
```
|
||||
|
||||
So a profile issues successfully against a CA whose `EnableDirectIssuance` is `False`. If a project has no profiles, create a policy then a profile under **Certificate Management** in the Infisical UI.
|
||||
|
||||
#### Discovering subscribers
|
||||
|
||||
```powershell
|
||||
Get-InfisicalPkiSubscriber -ProjectId ($Project.Id) |
|
||||
Format-Table Name, CommonName, Status, Ttl, CaId
|
||||
```
|
||||
|
||||
Pass the subscriber's `Name` to `-PkiSubscriberSlug`, and set `-CommonName` to exactly that subscriber's `CommonName`. Because the subscriber owns the lifetime and usage policy, `-Ttl`, `-KeyUsage`, and `-ExtendedKeyUsage` are not accepted on this parameter set — set them on the subscriber in Infisical instead.
|
||||
|
||||
An empty result means the project has no subscribers; create one under **Certificate Management > Subscribers**. This module is read-only for subscribers, so creation is UI or raw API (`POST /api/v1/pki/subscribers`).
|
||||
|
||||
#### Direct issuance on a CA
|
||||
|
||||
Direct issuance lets a CA sign a bare CSR with no profile, subscriber, or template in front of it. When it is off, Infisical rejects the request with `400 Certificate template or subscriber is required for issuance`; this module catches that before building a CSR.
|
||||
|
||||
> **There is no UI toggle or API field for this.** `enableDirectIssuance` appears in no create or update schema — the generic CA schemas accept only `name` and `status`. It is set at CA creation (the column defaults to `true`) and is not editable afterwards through the public API.
|
||||
|
||||
A CA can therefore read `False` for a reason that is not obvious. Migration `20250521110635_add-external-ca-pki.ts` renamed the older `requireTemplateForIssuance` column to `enableDirectIssuance` and **inverted** every existing value:
|
||||
|
||||
```ts
|
||||
t.renameColumn("requireTemplateForIssuance", "enableDirectIssuance");
|
||||
...
|
||||
.update({ name: slugifiedName, enableDirectIssuance: !ca.enableDirectIssuance });
|
||||
```
|
||||
|
||||
Any CA created before that migration with "require template for issuance" enabled now reads `EnableDirectIssuance = False` permanently. The options are to **use a profile** (which ignores the flag), or to create a new CA — new CAs default to `true`.
|
||||
|
||||
```powershell
|
||||
Get-InfisicalCertificateAuthority -ProjectId ($Project.Id) -Kind Internal |
|
||||
Format-Table Name, CommonName, Status, EnableDirectIssuance
|
||||
|
||||
$Ca = Get-InfisicalCertificateAuthority -ProjectId ($Project.Id) -Kind Internal |
|
||||
Where-Object {($_.EnableDirectIssuance -eq $True)} |
|
||||
Select-Object -First 1
|
||||
|
||||
$RequestInfisicalCertificateParameters.CertificateAuthorityId = $Ca.Id
|
||||
$RequestInfisicalCertificateParameters.Ttl = '90d' # required by the CA path
|
||||
```
|
||||
|
||||
### Subject and SAN handling
|
||||
|
||||
- `-CommonName` takes the bare value (`WEB01.contoso.com`), not an RDN. `CN=WEB01` is accepted and normalized, since the CSR builder adds the `CN=` prefix itself.
|
||||
- `Get-InfisicalSANList` returns DNS names *and* IP addresses in one list. Passing the whole list to `-DnsName` is fine: IP literals are detected and emitted as `iPAddress` SAN entries rather than malformed `dNSName` entries.
|
||||
- `-Ttl` (or `-NotAfter`) applies to the `-CertificateAuthorityId` and `-CertificateProfileId` paths. Subscriber-issued certificates take their lifetime from the subscriber definition.
|
||||
|
||||
## Diagnostics and error handling
|
||||
|
||||
Every cmdlet derives from `PSCmdlet`, so the full set of common parameters is bound: `-Verbose`, `-Debug`, `-ErrorAction`, `-ErrorVariable`, `-WarningAction`, `-WarningVariable`, `-InformationAction`, `-InformationVariable`, `-OutVariable`, `-PipelineVariable`, and `-WhatIf`/`-Confirm` on the cmdlets that declare `SupportsShouldProcess`.
|
||||
|
||||
Output is routed by stream so those parameters mean what they say:
|
||||
|
||||
| Stream | Carries | Controlled by |
|
||||
| ----------- | ----------------------------------------------------------------------- | ------------------------------------ |
|
||||
| Error | The failure itself, once, as a non-terminating `ErrorRecord` | `-ErrorAction`, `-ErrorVariable`, `2>` |
|
||||
| Warning | Genuine advisories that are not failures (e.g. issuance returned no certificate) | `-WarningAction`, `-WarningVariable` |
|
||||
| Verbose | Request/response trace and the diagnostic trail leading up to a failure | `-Verbose` |
|
||||
| Debug | Low-level detail | `-Debug` |
|
||||
|
||||
A failed call surfaces exactly one error. The `[Error]`-tagged diagnostic lines that precede it are on the verbose stream, so they appear only under `-Verbose` and never compete with the `ErrorRecord`:
|
||||
|
||||
```powershell
|
||||
# One error, no warning noise.
|
||||
Request-InfisicalCertificate @Parameters -ErrorVariable Failure -ErrorAction SilentlyContinue
|
||||
|
||||
# The ErrorRecord carries the API detail; no log scraping required.
|
||||
$Failure[0].Exception.StatusCode # 400
|
||||
$Failure[0].Exception.ApiErrorCode # BadRequest
|
||||
$Failure[0].Exception.ApiErrorMessage # Certificate template or subscriber is required for issuance
|
||||
$Failure[0].Exception.ApiRequestId # req-SSPFN1gc2zHvkV
|
||||
```
|
||||
|
||||
### `-ErrorAction` decides the outcome
|
||||
|
||||
Operation failures are reported as **non-terminating** errors, so `-ErrorAction` (or `$ErrorActionPreference`) governs what happens, exactly as it does for built-in cmdlets:
|
||||
|
||||
| `-ErrorAction` | Behavior |
|
||||
| ------------------ | ------------------------------------------------------------------------- |
|
||||
| `Continue` (default) | Error is written; a pipeline keeps processing its remaining input |
|
||||
| `SilentlyContinue` | Nothing is printed; the error is still in `$Error` and `-ErrorVariable` |
|
||||
| `Ignore` | Nothing is printed and nothing is recorded in `$Error` |
|
||||
| `Stop` | Promoted to a terminating error that `try`/`catch` catches |
|
||||
| `Inquire` | Prompts |
|
||||
|
||||
A failing item does not abort the batch:
|
||||
|
||||
```powershell
|
||||
'web01', 'does-not-exist', 'web02' |
|
||||
ForEach-Object { Get-InfisicalPkiSubscriber -ProjectId $ProjectId -Name $_ -ErrorAction SilentlyContinue }
|
||||
# emits web01 and web02; the failure is available in $Error
|
||||
```
|
||||
|
||||
**To catch failures you must ask for it** with `-ErrorAction Stop` or `$ErrorActionPreference = 'Stop'`:
|
||||
|
||||
```powershell
|
||||
try {
|
||||
$Certificate = Request-InfisicalCertificate @Parameters -ErrorAction Stop
|
||||
} catch [PSInfisicalAPI.Errors.InfisicalApiException] {
|
||||
Write-Warning "Issuance failed with HTTP $($_.Exception.StatusCode): $($_.Exception.ApiErrorMessage)"
|
||||
}
|
||||
```
|
||||
|
||||
> **Breaking change.** Failures were previously terminating, so `try`/`catch` caught them without `-ErrorAction Stop`. Existing `try`/`catch` blocks need `-ErrorAction Stop` added (or `$ErrorActionPreference = 'Stop'` set) to keep catching.
|
||||
|
||||
Exception types are `InfisicalApiException`, `InfisicalAuthenticationException`, `InfisicalHttpException`, `InfisicalSerializationException`, `InfisicalConfigurationException`, `InfisicalExportException`, and `InfisicalImportException`, all deriving from `InfisicalException`.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -113,6 +113,7 @@ function Write-Manifest {
|
||||
'Copy-InfisicalSecret',
|
||||
'ConvertTo-InfisicalSecretDictionary',
|
||||
'Export-InfisicalSecrets',
|
||||
'Import-InfisicalSecret',
|
||||
'Get-InfisicalProject',
|
||||
'New-InfisicalProject',
|
||||
'Update-InfisicalProject',
|
||||
@@ -154,6 +155,7 @@ function Write-Manifest {
|
||||
'Export-InfisicalScepMdmProfile',
|
||||
'Write-InfisicalScepMdmProfileToWmi',
|
||||
'Start-InfisicalProcess',
|
||||
'Get-InfisicalEnvironmentVariable',
|
||||
'Get-InfisicalSANList'
|
||||
)
|
||||
AliasesToExport = @()
|
||||
@@ -219,7 +221,7 @@ if (`$cmds.Count -eq 0) {
|
||||
throw "No cmdlets were exported by the PSInfisicalAPI module."
|
||||
}
|
||||
|
||||
`$expectedCmds = @('Connect-Infisical','Disconnect-Infisical','Get-InfisicalSecret','New-InfisicalSecret','Update-InfisicalSecret','Remove-InfisicalSecret','Copy-InfisicalSecret','ConvertTo-InfisicalSecretDictionary','Export-InfisicalSecrets','Get-InfisicalProject','New-InfisicalProject','Update-InfisicalProject','Remove-InfisicalProject','Get-InfisicalEnvironment','New-InfisicalEnvironment','Update-InfisicalEnvironment','Remove-InfisicalEnvironment','Get-InfisicalFolder','New-InfisicalFolder','Update-InfisicalFolder','Remove-InfisicalFolder','Get-InfisicalTag','New-InfisicalTag','Update-InfisicalTag','Remove-InfisicalTag','Get-InfisicalOrganization','New-InfisicalOrganization','Update-InfisicalOrganization','Remove-InfisicalOrganization','Get-InfisicalSubOrganization','New-InfisicalSubOrganization','Update-InfisicalSubOrganization','Remove-InfisicalSubOrganization','Get-InfisicalCertificateAuthority','Get-InfisicalPkiSubscriber','Get-InfisicalCertificateProfile','Get-InfisicalCertificatePolicy','Get-InfisicalCertificate','Request-InfisicalCertificate','ConvertTo-InfisicalCertificate','Install-InfisicalCertificate','Uninstall-InfisicalCertificate','Export-InfisicalCertificate','Get-InfisicalCertificateApplication','Get-InfisicalCertificateApplicationEnrollment','New-InfisicalScepDynamicChallenge','Get-InfisicalScepMdmProfile','Export-InfisicalScepMdmProfile','Write-InfisicalScepMdmProfileToWmi','Start-InfisicalProcess','Get-InfisicalSANList')
|
||||
`$expectedCmds = @('Connect-Infisical','Disconnect-Infisical','Get-InfisicalSecret','New-InfisicalSecret','Update-InfisicalSecret','Remove-InfisicalSecret','Copy-InfisicalSecret','ConvertTo-InfisicalSecretDictionary','Export-InfisicalSecrets','Import-InfisicalSecret','Get-InfisicalProject','New-InfisicalProject','Update-InfisicalProject','Remove-InfisicalProject','Get-InfisicalEnvironment','New-InfisicalEnvironment','Update-InfisicalEnvironment','Remove-InfisicalEnvironment','Get-InfisicalFolder','New-InfisicalFolder','Update-InfisicalFolder','Remove-InfisicalFolder','Get-InfisicalTag','New-InfisicalTag','Update-InfisicalTag','Remove-InfisicalTag','Get-InfisicalOrganization','New-InfisicalOrganization','Update-InfisicalOrganization','Remove-InfisicalOrganization','Get-InfisicalSubOrganization','New-InfisicalSubOrganization','Update-InfisicalSubOrganization','Remove-InfisicalSubOrganization','Get-InfisicalCertificateAuthority','Get-InfisicalPkiSubscriber','Get-InfisicalCertificateProfile','Get-InfisicalCertificatePolicy','Get-InfisicalCertificate','Request-InfisicalCertificate','ConvertTo-InfisicalCertificate','Install-InfisicalCertificate','Uninstall-InfisicalCertificate','Export-InfisicalCertificate','Get-InfisicalCertificateApplication','Get-InfisicalCertificateApplicationEnrollment','New-InfisicalScepDynamicChallenge','Get-InfisicalScepMdmProfile','Export-InfisicalScepMdmProfile','Write-InfisicalScepMdmProfileToWmi','Start-InfisicalProcess','Get-InfisicalEnvironmentVariable','Get-InfisicalSANList')
|
||||
foreach (`$expected in `$expectedCmds) {
|
||||
if (-not (Get-Command -Name `$expected -Module PSInfisicalAPI -ErrorAction SilentlyContinue)) {
|
||||
throw "Cmdlet not found: `$expected"
|
||||
|
||||
@@ -1222,7 +1222,8 @@ Export-InfisicalSecrets `
|
||||
[-Scope <Process|User|Machine>] `
|
||||
[-Force] `
|
||||
[-Encoding <UTF8|UTF8Bom|Unicode>] `
|
||||
[-Prefix <string>]
|
||||
[-SecretsPrefix <string>] `
|
||||
[-ForceSecretsPrefix]
|
||||
```
|
||||
|
||||
## Parameter Rules
|
||||
@@ -1521,8 +1522,9 @@ Start-InfisicalProcess
|
||||
[-SecureArgumentList]
|
||||
[-LogOutput]
|
||||
[-ContinueOnError]
|
||||
[-Secret <InfisicalSecret[]>]
|
||||
[-Prefix <string>]
|
||||
[-Secrets <InfisicalSecret[]>]
|
||||
[-SecretsPrefix <string>]
|
||||
[-ForceSecretsPrefix]
|
||||
```
|
||||
|
||||
Behavior:
|
||||
@@ -1530,7 +1532,7 @@ Behavior:
|
||||
```text
|
||||
Buffer pipeline InfisicalSecret objects in ProcessRecord.
|
||||
Decrypt secrets only into ProcessStartInfo.Environment.
|
||||
Apply -Prefix to each secret name before injection.
|
||||
Apply -SecretsPrefix to each secret name before injection.
|
||||
Never write secret plaintext to user or machine environment scope.
|
||||
Honor -WhatIf / -Confirm.
|
||||
Default -AcceptableExitCodeList = @('0','3010').
|
||||
@@ -0,0 +1,221 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using PSInfisicalAPI.Logging;
|
||||
using Xunit;
|
||||
|
||||
namespace PSInfisicalAPI.Tests
|
||||
{
|
||||
public class CertificateStoreTargetingTests
|
||||
{
|
||||
private static readonly Assembly ModuleAssembly = typeof(PSInfisicalAPI.Connections.InfisicalConnection).Assembly;
|
||||
|
||||
private sealed class CapturingLogger : IInfisicalLogger
|
||||
{
|
||||
public List<string> Warnings { get; } = new List<string>();
|
||||
public List<string> Information_ { get; } = new List<string>();
|
||||
|
||||
public void Information(string component, string message) { Information_.Add(message); }
|
||||
public void Verbose(string component, string message) { }
|
||||
public void Debug(string component, string message) { }
|
||||
public void Warning(string component, string message) { Warnings.Add(message); }
|
||||
public void Error(string component, string message) { }
|
||||
}
|
||||
|
||||
private static X509Certificate2 CreateRoot(string name, out System.Security.Cryptography.RSA key)
|
||||
{
|
||||
key = System.Security.Cryptography.RSA.Create(2048);
|
||||
CertificateRequest request = new CertificateRequest(
|
||||
string.Concat("CN=", name), key,
|
||||
System.Security.Cryptography.HashAlgorithmName.SHA256,
|
||||
System.Security.Cryptography.RSASignaturePadding.Pkcs1);
|
||||
request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, true, 1, true));
|
||||
return request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(10));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyFriendlyName_Sets_The_Windows_Friendly_Name()
|
||||
{
|
||||
Type helper = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalCertificateRequestHelpers", true);
|
||||
MethodInfo apply = helper.GetMethod("ApplyFriendlyName", BindingFlags.Public | BindingFlags.Static);
|
||||
Assert.NotNull(apply);
|
||||
|
||||
System.Security.Cryptography.RSA key;
|
||||
using (X509Certificate2 cert = CreateRoot("FriendlyName.Probe", out key))
|
||||
using (key)
|
||||
{
|
||||
apply.Invoke(null, new object[] { cert, "WEB01", NullInfisicalLogger.Instance, "Test" });
|
||||
Assert.Equal("WEB01", cert.FriendlyName);
|
||||
|
||||
// A null or empty name must not clear a previously set value.
|
||||
apply.Invoke(null, new object[] { cert, null, NullInfisicalLogger.Instance, "Test" });
|
||||
apply.Invoke(null, new object[] { cert, string.Empty, NullInfisicalLogger.Instance, "Test" });
|
||||
Assert.Equal("WEB01", cert.FriendlyName);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyFriendlyName_Tolerates_Null_Certificate_And_Logger()
|
||||
{
|
||||
Type helper = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalCertificateRequestHelpers", true);
|
||||
MethodInfo apply = helper.GetMethod("ApplyFriendlyName", BindingFlags.Public | BindingFlags.Static);
|
||||
apply.Invoke(null, new object[] { null, "WEB01", null, "Test" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyInstalledChain_Names_The_Missing_Issuer_On_A_Partial_Chain()
|
||||
{
|
||||
// An intermediate whose root is not in any trusted store is exactly the state that surfaces in
|
||||
// Windows as "The issuer of this certificate could not be found".
|
||||
Type helper = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalCertificateRequestHelpers", true);
|
||||
MethodInfo verify = helper.GetMethod("VerifyInstalledChain", BindingFlags.Public | BindingFlags.Static);
|
||||
Assert.NotNull(verify);
|
||||
|
||||
System.Security.Cryptography.RSA rootKey;
|
||||
using (X509Certificate2 root = CreateRoot("VerifyChain.Root", out rootKey))
|
||||
using (rootKey)
|
||||
using (System.Security.Cryptography.RSA interKey = System.Security.Cryptography.RSA.Create(2048))
|
||||
{
|
||||
CertificateRequest interRequest = new CertificateRequest(
|
||||
"CN=VerifyChain.Intermediate", interKey,
|
||||
System.Security.Cryptography.HashAlgorithmName.SHA256,
|
||||
System.Security.Cryptography.RSASignaturePadding.Pkcs1);
|
||||
interRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
|
||||
|
||||
using (X509Certificate2 intermediate = interRequest.Create(root, DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(9), new byte[] { 9, 8, 7, 6, 5, 4, 3, 2 }))
|
||||
{
|
||||
CapturingLogger logger = new CapturingLogger();
|
||||
verify.Invoke(null, new object[] { intermediate, logger, "Test" });
|
||||
|
||||
string warning = Assert.Single(logger.Warnings);
|
||||
Assert.Contains("issuer", warning, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("VerifyChain.Root", warning);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyInstalledChain_Tolerates_Null_Inputs()
|
||||
{
|
||||
Type helper = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalCertificateRequestHelpers", true);
|
||||
MethodInfo verify = helper.GetMethod("VerifyInstalledChain", BindingFlags.Public | BindingFlags.Static);
|
||||
verify.Invoke(null, new object[] { null, new CapturingLogger(), "Test" });
|
||||
|
||||
System.Security.Cryptography.RSA key;
|
||||
using (X509Certificate2 cert = CreateRoot("VerifyChain.NullLogger", out key))
|
||||
using (key)
|
||||
{
|
||||
verify.Invoke(null, new object[] { cert, null, "Test" });
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FriendlyName_Is_Available_On_Every_Issuance_Parameter_Set()
|
||||
{
|
||||
// The Windows friendly name applies regardless of how the certificate was issued.
|
||||
Type cmdletType = ModuleAssembly.GetType("PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet", true);
|
||||
PropertyInfo friendlyName = cmdletType.GetProperty("FriendlyName");
|
||||
Assert.NotNull(friendlyName);
|
||||
|
||||
foreach (CustomAttributeData attribute in friendlyName.GetCustomAttributesData())
|
||||
{
|
||||
if (attribute.AttributeType != typeof(System.Management.Automation.ParameterAttribute)) { continue; }
|
||||
foreach (CustomAttributeNamedArgument named in attribute.NamedArguments)
|
||||
{
|
||||
Assert.NotEqual("ParameterSetName", named.MemberName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void InvokeTrustPromptWarning(StoreName storeName, StoreLocation storeLocation, IInfisicalLogger logger)
|
||||
{
|
||||
Type helper = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalCertificateRequestHelpers", true);
|
||||
MethodInfo method = helper.GetMethod("WarnIfInteractiveTrustPromptExpected", BindingFlags.Public | BindingFlags.Static);
|
||||
Assert.NotNull(method);
|
||||
method.Invoke(null, new object[] { storeName, storeLocation, logger, "TestComponent" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CurrentUser_Root_Install_Warns_About_The_Blocking_Trust_Dialog()
|
||||
{
|
||||
// X509Store.Add on CurrentUser\Root raises a modal Windows trust dialog and blocks until answered.
|
||||
// Without this warning the caller sees an unexplained hang.
|
||||
CapturingLogger logger = new CapturingLogger();
|
||||
InvokeTrustPromptWarning(StoreName.Root, StoreLocation.CurrentUser, logger);
|
||||
|
||||
string warning = Assert.Single(logger.Warnings);
|
||||
Assert.Contains("CurrentUser\\Root", warning);
|
||||
Assert.Contains("security confirmation", warning);
|
||||
Assert.Contains("LocalMachine", warning);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(StoreName.Root, StoreLocation.LocalMachine)]
|
||||
[InlineData(StoreName.My, StoreLocation.CurrentUser)]
|
||||
[InlineData(StoreName.My, StoreLocation.LocalMachine)]
|
||||
[InlineData(StoreName.CertificateAuthority, StoreLocation.CurrentUser)]
|
||||
[InlineData(StoreName.CertificateAuthority, StoreLocation.LocalMachine)]
|
||||
public void Non_Prompting_Store_Targets_Stay_Silent(StoreName storeName, StoreLocation storeLocation)
|
||||
{
|
||||
CapturingLogger logger = new CapturingLogger();
|
||||
InvokeTrustPromptWarning(storeName, storeLocation, logger);
|
||||
Assert.Empty(logger.Warnings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WarnIfInteractiveTrustPromptExpected_Tolerates_A_Null_Logger()
|
||||
{
|
||||
InvokeTrustPromptWarning(StoreName.Root, StoreLocation.CurrentUser, null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Base_Cmdlet_Exposes_Elevation_Aware_Store_Resolution()
|
||||
{
|
||||
Type baseType = typeof(PSInfisicalAPI.Cmdlets.InfisicalCmdletBase);
|
||||
|
||||
MethodInfo resolve = baseType.GetMethod("ResolveStoreLocation", BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
Assert.NotNull(resolve);
|
||||
Assert.Equal(typeof(StoreLocation), resolve.ReturnType);
|
||||
|
||||
MethodInfo elevated = baseType.GetMethod("IsElevated", BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
Assert.NotNull(elevated);
|
||||
Assert.Equal(typeof(bool), elevated.ReturnType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Chain_Certificates_Route_Root_And_Intermediate_To_Their_Own_Stores()
|
||||
{
|
||||
Type helper = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalCertificateRequestHelpers", true);
|
||||
MethodInfo classify = helper.GetMethod("GetChainCertificateTargetStore", BindingFlags.Public | BindingFlags.Static);
|
||||
|
||||
using (System.Security.Cryptography.RSA rootRsa = System.Security.Cryptography.RSA.Create(2048))
|
||||
using (System.Security.Cryptography.RSA leafRsa = System.Security.Cryptography.RSA.Create(2048))
|
||||
{
|
||||
DateTimeOffset notBefore = DateTimeOffset.UtcNow.AddMinutes(-5);
|
||||
DateTimeOffset notAfter = DateTimeOffset.UtcNow.AddDays(1);
|
||||
|
||||
CertificateRequest rootRequest = new CertificateRequest(
|
||||
"CN=StoreTargeting.Root", rootRsa,
|
||||
System.Security.Cryptography.HashAlgorithmName.SHA256,
|
||||
System.Security.Cryptography.RSASignaturePadding.Pkcs1);
|
||||
rootRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
|
||||
|
||||
using (X509Certificate2 root = rootRequest.CreateSelfSigned(notBefore, notAfter))
|
||||
{
|
||||
CertificateRequest interRequest = new CertificateRequest(
|
||||
"CN=StoreTargeting.Intermediate", leafRsa,
|
||||
System.Security.Cryptography.HashAlgorithmName.SHA256,
|
||||
System.Security.Cryptography.RSASignaturePadding.Pkcs1);
|
||||
interRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
|
||||
|
||||
using (X509Certificate2 intermediate = interRequest.Create(root, notBefore, notAfter, new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 }))
|
||||
{
|
||||
Assert.Equal(StoreName.Root, (StoreName)classify.Invoke(null, new object[] { root }));
|
||||
Assert.Equal(StoreName.CertificateAuthority, (StoreName)classify.Invoke(null, new object[] { intermediate }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -114,6 +114,170 @@ namespace PSInfisicalAPI.Tests
|
||||
Assert.Equal("DE", countryProp.GetValue(result));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("CN=WEB01", "WEB01")]
|
||||
[InlineData("cn=web01.contoso.local", "web01.contoso.local")]
|
||||
[InlineData("CN=WEB01,OU=IT,O=Contoso", "WEB01")]
|
||||
[InlineData(" CN=WEB01 ", "WEB01")]
|
||||
[InlineData("WEB01.contoso.local", "WEB01.contoso.local")]
|
||||
[InlineData(null, null)]
|
||||
public void MergeSubject_Normalizes_Rdn_Style_CommonName(string supplied, string expected)
|
||||
{
|
||||
Type helperType = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalCertificateRequestHelpers", true);
|
||||
MethodInfo merge = helperType.GetMethod("MergeSubject", BindingFlags.Public | BindingFlags.Static);
|
||||
|
||||
object result = merge.Invoke(null, new object[] { null, supplied, null, null, null, null, null, null });
|
||||
|
||||
Assert.Equal(expected, result.GetType().GetProperty("CommonName").GetValue(result));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeSubject_Normalizes_CommonName_Supplied_Through_Subject_Hashtable()
|
||||
{
|
||||
Type helperType = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalCertificateRequestHelpers", true);
|
||||
MethodInfo merge = helperType.GetMethod("MergeSubject", BindingFlags.Public | BindingFlags.Static);
|
||||
|
||||
Hashtable subject = new Hashtable { { "CN", "CN=WEB01" } };
|
||||
object result = merge.Invoke(null, new object[] { subject, null, null, null, null, null, null, null });
|
||||
|
||||
Assert.Equal("WEB01", result.GetType().GetProperty("CommonName").GetValue(result));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildDnsNames_Routes_Ip_Literals_From_DnsName_To_IpAddress_Sans()
|
||||
{
|
||||
// Get-InfisicalSANList emits host names and IP addresses in one list, and the documented usage
|
||||
// splats that whole list into -DnsName.
|
||||
PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet cmdlet = new PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet
|
||||
{
|
||||
DnsName = new[] { "WEB01", "172.16.32.24", "WEB01.contoso.local", "127.0.0.1", "::1" }
|
||||
};
|
||||
|
||||
List<string> ipAddresses = new List<string>();
|
||||
List<string> dnsNames = InvokeBuildDnsNames(cmdlet, new InfisicalCsrSubject { CommonName = "WEB01" }, ipAddresses);
|
||||
|
||||
Assert.Equal(new[] { "WEB01", "WEB01.contoso.local" }, dnsNames);
|
||||
Assert.Equal(new[] { "172.16.32.24", "127.0.0.1", "::1" }, ipAddresses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildDnsNames_Merges_Explicit_IpAddress_Parameter_And_Deduplicates()
|
||||
{
|
||||
PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet cmdlet = new PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet
|
||||
{
|
||||
DnsName = new[] { "WEB01", "10.0.0.5" },
|
||||
IpAddress = new[] { "10.0.0.5", "10.0.0.6" }
|
||||
};
|
||||
|
||||
List<string> ipAddresses = new List<string>();
|
||||
List<string> dnsNames = InvokeBuildDnsNames(cmdlet, new InfisicalCsrSubject { CommonName = "WEB01" }, ipAddresses);
|
||||
|
||||
Assert.Equal(new[] { "WEB01" }, dnsNames);
|
||||
Assert.Equal(new[] { "10.0.0.5", "10.0.0.6" }, ipAddresses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildDnsNames_Mirrors_Ip_CommonName_Into_IpAddress_Sans_Not_Dns()
|
||||
{
|
||||
PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet cmdlet = new PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet
|
||||
{
|
||||
DnsName = new[] { "WEB01.contoso.local" }
|
||||
};
|
||||
|
||||
List<string> ipAddresses = new List<string>();
|
||||
List<string> dnsNames = InvokeBuildDnsNames(cmdlet, new InfisicalCsrSubject { CommonName = "10.0.0.5" }, ipAddresses);
|
||||
|
||||
Assert.Equal(new[] { "WEB01.contoso.local" }, dnsNames);
|
||||
Assert.Equal(new[] { "10.0.0.5" }, ipAddresses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildDnsNames_Ip_Only_Request_Does_Not_Pick_Up_Local_Fqdn()
|
||||
{
|
||||
PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet cmdlet = new PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet
|
||||
{
|
||||
IpAddress = new[] { "10.0.0.5" }
|
||||
};
|
||||
|
||||
List<string> ipAddresses = new List<string>();
|
||||
List<string> dnsNames = InvokeBuildDnsNames(cmdlet, new InfisicalCsrSubject { CommonName = "10.0.0.5" }, ipAddresses);
|
||||
|
||||
Assert.Empty(dnsNames);
|
||||
Assert.Equal(new[] { "10.0.0.5" }, ipAddresses);
|
||||
}
|
||||
|
||||
private static List<string> InvokeBuildDnsNames(PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet cmdlet, InfisicalCsrSubject subject, List<string> ipAddresses)
|
||||
{
|
||||
MethodInfo build = cmdlet.GetType().GetMethod("BuildDnsNames", BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
Assert.NotNull(build);
|
||||
return (List<string>)build.Invoke(cmdlet, new object[] { subject, ipAddresses });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DirectIssuance_Guidance_Names_The_Parameters_That_Resolve_It()
|
||||
{
|
||||
// Infisical exposes no certificate-template issuance route over REST, so the actionable alternatives
|
||||
// are enabling direct issuance on the CA, a subscriber, or a profile.
|
||||
PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet cmdlet = new PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet
|
||||
{
|
||||
CertificateAuthorityId = "ca-1234",
|
||||
ProjectId = "proj-5678"
|
||||
};
|
||||
|
||||
MethodInfo build = cmdlet.GetType().GetMethod("BuildDirectIssuanceGuidance", BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
Assert.NotNull(build);
|
||||
|
||||
string guidance = (string)build.Invoke(cmdlet, new object[] { null });
|
||||
|
||||
Assert.Contains("ca-1234", guidance);
|
||||
Assert.Contains("proj-5678", guidance);
|
||||
Assert.Contains("-PkiSubscriberSlug", guidance);
|
||||
Assert.Contains("-CertificateProfileId", guidance);
|
||||
Assert.Contains("Get-InfisicalPkiSubscriber", guidance);
|
||||
Assert.Contains("Direct Issuance", guidance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DirectIssuance_Guidance_Prefers_The_Ca_Name_When_Known()
|
||||
{
|
||||
PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet cmdlet = new PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet
|
||||
{
|
||||
CertificateAuthorityId = "ca-1234",
|
||||
ProjectId = "proj-5678"
|
||||
};
|
||||
|
||||
PSInfisicalAPI.Models.InfisicalCertificateAuthority ca = new PSInfisicalAPI.Models.InfisicalCertificateAuthority
|
||||
{
|
||||
Id = "ca-1234",
|
||||
Name = "intermediate-ca",
|
||||
EnableDirectIssuance = false
|
||||
};
|
||||
|
||||
MethodInfo build = cmdlet.GetType().GetMethod("BuildDirectIssuanceGuidance", BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
string guidance = (string)build.Invoke(cmdlet, new object[] { ca });
|
||||
|
||||
Assert.Contains("intermediate-ca", guidance);
|
||||
Assert.Contains("ca-1234", guidance);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("WriteErrorForException")]
|
||||
[InlineData("ThrowTerminatingForException")]
|
||||
public void Failure_Handlers_Rethrow_Pipeline_Stops_Untouched(string handlerName)
|
||||
{
|
||||
// Select-Object -First makes WriteObject throw a PipelineStoppedException-derived type. Reporting it
|
||||
// as an error surfaces spurious "The pipeline has been stopped." warnings on normal early exits.
|
||||
PSInfisicalAPI.Cmdlets.GetInfisicalCertificateAuthorityCmdlet cmdlet = new PSInfisicalAPI.Cmdlets.GetInfisicalCertificateAuthorityCmdlet();
|
||||
MethodInfo method = typeof(PSInfisicalAPI.Cmdlets.InfisicalCmdletBase).GetMethod(handlerName, BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
Assert.NotNull(method);
|
||||
|
||||
PipelineStoppedException stop = new PipelineStoppedException();
|
||||
TargetInvocationException wrapper = Assert.Throws<TargetInvocationException>(
|
||||
() => method.Invoke(cmdlet, new object[] { "TestComponent", "TestOperation", stop }));
|
||||
|
||||
Assert.Same(stop, wrapper.InnerException);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SignCertificateBySubscriber_Uses_Pki_Subscribers_Template()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using PSInfisicalAPI.Logging;
|
||||
using Xunit;
|
||||
|
||||
namespace PSInfisicalAPI.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// The test project references PowerShellStandard.Library, which cannot host a runspace, so the logger's
|
||||
/// stream choice is asserted structurally: which Cmdlet.Write* method each level compiles down to.
|
||||
/// </summary>
|
||||
public class LoggerStreamRoutingTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("Error", "WriteVerbose")]
|
||||
[InlineData("Warning", "WriteWarning")]
|
||||
[InlineData("Information", "WriteVerbose")]
|
||||
[InlineData("Verbose", "WriteVerbose")]
|
||||
[InlineData("Debug", "WriteDebug")]
|
||||
public void PSCmdletLogger_Routes_Level_To_Expected_Stream(string levelMethod, string expectedWriteMethod)
|
||||
{
|
||||
MethodInfo method = typeof(PSCmdletLogger).GetMethod(levelMethod, BindingFlags.Public | BindingFlags.Instance);
|
||||
Assert.NotNull(method);
|
||||
|
||||
List<string> called = GetCalledMethodNames(method);
|
||||
Assert.Contains(expectedWriteMethod, called);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PSCmdletLogger_Error_Does_Not_Write_To_Warning_Stream()
|
||||
{
|
||||
// Every Logger.Error call site in this module logs and then throws, so the failure already reaches the
|
||||
// caller as an ErrorRecord. Duplicating it on the warning stream put failures under -WarningAction
|
||||
// instead of -ErrorAction and buried the real error under eight lines of noise.
|
||||
MethodInfo error = typeof(PSCmdletLogger).GetMethod("Error", BindingFlags.Public | BindingFlags.Instance);
|
||||
List<string> called = GetCalledMethodNames(error);
|
||||
|
||||
Assert.DoesNotContain("WriteWarning", called);
|
||||
Assert.DoesNotContain("WriteError", called);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_Cmdlet_Reports_Operation_Failures_As_Terminating_Errors()
|
||||
{
|
||||
// Operation failures go through WriteErrorForException so -ErrorAction decides the outcome.
|
||||
// ThrowTerminatingForException remains available for aborts that ignore -ErrorAction, but no cmdlet
|
||||
// should be using it for ordinary failures; this pins the convention against drift.
|
||||
Assembly assembly = typeof(PSInfisicalAPI.Cmdlets.InfisicalCmdletBase).Assembly;
|
||||
List<string> offenders = new List<string>();
|
||||
int inspected = 0;
|
||||
|
||||
foreach (Type type in assembly.GetTypes())
|
||||
{
|
||||
if (!typeof(PSInfisicalAPI.Cmdlets.InfisicalCmdletBase).IsAssignableFrom(type)) { continue; }
|
||||
if (type == typeof(PSInfisicalAPI.Cmdlets.InfisicalCmdletBase)) { continue; }
|
||||
|
||||
inspected++;
|
||||
foreach (MethodInfo method in type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly))
|
||||
{
|
||||
if (method.GetMethodBody() == null) { continue; }
|
||||
if (GetCalledMethodNames(method).Contains("ThrowTerminatingForException"))
|
||||
{
|
||||
offenders.Add(string.Concat(type.Name, ".", method.Name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(inspected > 40, string.Concat("Expected to inspect the cmdlet set, saw ", inspected.ToString()));
|
||||
Assert.Empty(offenders);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cmdlets_Route_Failures_Through_WriteErrorForException()
|
||||
{
|
||||
Assembly assembly = typeof(PSInfisicalAPI.Cmdlets.InfisicalCmdletBase).Assembly;
|
||||
Type cmdletType = assembly.GetType("PSInfisicalAPI.Cmdlets.GetInfisicalCertificateAuthorityCmdlet", true);
|
||||
MethodInfo processRecord = cmdletType.GetMethod("ProcessRecord", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly);
|
||||
Assert.NotNull(processRecord);
|
||||
|
||||
Assert.Contains("WriteErrorForException", GetCalledMethodNames(processRecord));
|
||||
}
|
||||
|
||||
private static List<string> GetCalledMethodNames(MethodInfo method)
|
||||
{
|
||||
List<string> names = new List<string>();
|
||||
MethodBody body = method.GetMethodBody();
|
||||
Assert.NotNull(body);
|
||||
|
||||
byte[] il = body.GetILAsByteArray();
|
||||
Assert.NotNull(il);
|
||||
|
||||
const byte Call = 0x28;
|
||||
const byte CallVirt = 0x6F;
|
||||
|
||||
for (int i = 0; i + 4 < il.Length; i++)
|
||||
{
|
||||
if (il[i] != Call && il[i] != CallVirt) { continue; }
|
||||
|
||||
int token = BitConverter.ToInt32(il, i + 1);
|
||||
try
|
||||
{
|
||||
MethodBase resolved = method.Module.ResolveMethod(token);
|
||||
if (resolved != null) { names.Add(resolved.Name); }
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
// Byte sequence was operand data rather than an opcode; ignore.
|
||||
}
|
||||
}
|
||||
|
||||
return names;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -205,7 +205,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ThrowTerminatingForException(Component, "Connect", exception);
|
||||
WriteErrorForException(Component, "Connect", exception);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ThrowTerminatingForException("ConvertToInfisicalCertificateCmdlet", "ConvertToCertificate", exception);
|
||||
WriteErrorForException("ConvertToInfisicalCertificateCmdlet", "ConvertToCertificate", exception);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Management.Automation;
|
||||
using System.Security;
|
||||
using PSInfisicalAPI.Common;
|
||||
using PSInfisicalAPI.Errors;
|
||||
using PSInfisicalAPI.Models;
|
||||
|
||||
@@ -21,6 +22,14 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
[Parameter]
|
||||
public SwitchParameter AsPlainText { get; set; }
|
||||
|
||||
[Parameter]
|
||||
[Alias("Prefix")]
|
||||
public string SecretsPrefix { get; set; }
|
||||
|
||||
[Parameter]
|
||||
[Alias("ForcePrefix")]
|
||||
public SwitchParameter ForceSecretsPrefix { get; set; }
|
||||
|
||||
private readonly List<InfisicalSecret> _buffer = new List<InfisicalSecret>();
|
||||
|
||||
protected override void ProcessRecord()
|
||||
@@ -40,20 +49,24 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
{
|
||||
try
|
||||
{
|
||||
Logger.Information("ConvertTo-InfisicalSecretDictionary", string.Concat("Processing ", _buffer.Count.ToString(System.Globalization.CultureInfo.InvariantCulture), " input secret(s)."));
|
||||
|
||||
if (AsPlainText.IsPresent)
|
||||
{
|
||||
Dictionary<string, string> plain = BuildDictionary<string>(secret => secret.GetPlainTextValue());
|
||||
Logger.Information("ConvertTo-InfisicalSecretDictionary", string.Concat("Built plain-text dictionary with ", plain.Count.ToString(System.Globalization.CultureInfo.InvariantCulture), " entry/entries."));
|
||||
WriteObject(plain);
|
||||
}
|
||||
else
|
||||
{
|
||||
Dictionary<string, SecureString> secure = BuildDictionary<SecureString>(secret => secret.SecretValue);
|
||||
Logger.Information("ConvertTo-InfisicalSecretDictionary", string.Concat("Built SecureString dictionary with ", secure.Count.ToString(System.Globalization.CultureInfo.InvariantCulture), " entry/entries."));
|
||||
WriteObject(secure);
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ThrowTerminatingForException("ConvertToInfisicalSecretDictionaryCmdlet", "ConvertToDictionary", exception);
|
||||
WriteErrorForException("ConvertToInfisicalSecretDictionaryCmdlet", "ConvertToDictionary", exception);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +76,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
|
||||
foreach (InfisicalSecret secret in _buffer)
|
||||
{
|
||||
string key = secret.SecretName ?? string.Empty;
|
||||
string key = InfisicalPrefix.Apply(secret.SecretName ?? string.Empty, SecretsPrefix, ForceSecretsPrefix.IsPresent);
|
||||
|
||||
if (dictionary.ContainsKey(key))
|
||||
{
|
||||
|
||||
@@ -65,7 +65,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ThrowTerminatingForException("CopyInfisicalSecretCmdlet", "DuplicateSecrets", exception);
|
||||
WriteErrorForException("CopyInfisicalSecretCmdlet", "DuplicateSecrets", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ThrowTerminatingForException("DisconnectInfisicalCmdlet", "Disconnect", exception);
|
||||
WriteErrorForException("DisconnectInfisicalCmdlet", "Disconnect", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ThrowTerminatingForException("ExportInfisicalCertificateCmdlet", "ExportCertificate", exception);
|
||||
WriteErrorForException("ExportInfisicalCertificateCmdlet", "ExportCertificate", exception);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ThrowTerminatingForException(Component, "ExportScepMdmProfile", exception);
|
||||
WriteErrorForException(Component, "ExportScepMdmProfile", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Management.Automation;
|
||||
using System.Text;
|
||||
using PSInfisicalAPI.Common;
|
||||
using PSInfisicalAPI.Errors;
|
||||
using PSInfisicalAPI.Exports;
|
||||
using PSInfisicalAPI.Models;
|
||||
@@ -38,7 +39,12 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
public InfisicalExportEncoding Encoding { get; set; } = InfisicalExportEncoding.UTF8;
|
||||
|
||||
[Parameter]
|
||||
public string Prefix { get; set; }
|
||||
[Alias("Prefix")]
|
||||
public string SecretsPrefix { get; set; }
|
||||
|
||||
[Parameter]
|
||||
[Alias("ForcePrefix")]
|
||||
public SwitchParameter ForceSecretsPrefix { get; set; }
|
||||
|
||||
private readonly List<InfisicalSecret> _buffer = new List<InfisicalSecret>();
|
||||
|
||||
@@ -69,9 +75,11 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
{
|
||||
}
|
||||
|
||||
Logger.Information("Export-InfisicalSecrets", string.Concat("Exporting ", _buffer.Count.ToString(System.Globalization.CultureInfo.InvariantCulture), " secret(s) as ", Format.ToString(), (Path != null ? string.Concat(" to '", Path.FullName, "'") : string.Empty), "."));
|
||||
|
||||
InfisicalExportRequest request = new InfisicalExportRequest
|
||||
{
|
||||
Secrets = ApplyPrefix(_buffer, Prefix),
|
||||
Secrets = ApplySecretsPrefix(_buffer, SecretsPrefix, ForceSecretsPrefix.IsPresent),
|
||||
Format = Format,
|
||||
Path = Path,
|
||||
Scope = Scope,
|
||||
@@ -84,11 +92,11 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ThrowTerminatingForException("ExportInfisicalSecretsCmdlet", string.Concat("Export-", Format.ToString()), exception);
|
||||
WriteErrorForException("ExportInfisicalSecretsCmdlet", string.Concat("Export-", Format.ToString()), exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static InfisicalSecret[] ApplyPrefix(List<InfisicalSecret> source, string prefix)
|
||||
private static InfisicalSecret[] ApplySecretsPrefix(List<InfisicalSecret> source, string prefix, bool force)
|
||||
{
|
||||
if (string.IsNullOrEmpty(prefix)) { return source.ToArray(); }
|
||||
|
||||
@@ -104,7 +112,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
Environment = original.Environment,
|
||||
Version = original.Version,
|
||||
Type = original.Type,
|
||||
SecretName = string.Concat(prefix, original.SecretName),
|
||||
SecretName = InfisicalPrefix.Apply(original.SecretName, prefix, force),
|
||||
SecretValue = original.SecretValue,
|
||||
SecretValueHidden = original.SecretValueHidden,
|
||||
SecretPath = original.SecretPath,
|
||||
|
||||
@@ -46,6 +46,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
|
||||
InfisicalCertificateApplication[] all = client.ListCertificateApplications(connection, ProjectId, Limit, Offset);
|
||||
Logger.Information("Get-InfisicalCertificateApplication", string.Concat("Returned ", all.Length.ToString(System.Globalization.CultureInfo.InvariantCulture), " certificate application(s)."));
|
||||
foreach (InfisicalCertificateApplication app in all)
|
||||
{
|
||||
WriteObject(app);
|
||||
@@ -53,7 +54,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ThrowTerminatingForException("GetInfisicalCertificateApplicationCmdlet", "GetCertificateApplication", exception);
|
||||
WriteErrorForException("GetInfisicalCertificateApplicationCmdlet", "GetCertificateApplication", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ThrowTerminatingForException("GetInfisicalCertificateApplicationEnrollmentCmdlet", "GetCertificateApplicationEnrollment", exception);
|
||||
WriteErrorForException("GetInfisicalCertificateApplicationEnrollmentCmdlet", "GetCertificateApplicationEnrollment", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
}
|
||||
|
||||
Logger.Information("Get-InfisicalCertificateAuthority", string.Concat("Returned ", all.Length.ToString(System.Globalization.CultureInfo.InvariantCulture), " certificate authority/authorities (kind=", Kind, ")."));
|
||||
foreach (InfisicalCertificateAuthority ca in all)
|
||||
{
|
||||
WriteObject(ca);
|
||||
@@ -59,7 +60,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ThrowTerminatingForException("GetInfisicalCertificateAuthorityCmdlet", "GetCertificateAuthority", exception);
|
||||
WriteErrorForException("GetInfisicalCertificateAuthorityCmdlet", "GetCertificateAuthority", exception);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -129,10 +129,12 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
|
||||
query.Offset = (query.Offset ?? 0) + page.Certificates.Length;
|
||||
}
|
||||
|
||||
Logger.Information("Get-InfisicalCertificate", string.Concat("Returned ", emitted.ToString(System.Globalization.CultureInfo.InvariantCulture), " certificate(s)."));
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ThrowTerminatingForException("GetInfisicalCertificateCmdlet", "GetCertificate", exception);
|
||||
WriteErrorForException("GetInfisicalCertificateCmdlet", "GetCertificate", exception);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
|
||||
InfisicalCertificatePolicy[] all = client.ListCertificatePolicies(connection, ProjectId, Limit, Offset);
|
||||
Logger.Information("Get-InfisicalCertificatePolicy", string.Concat("Returned ", all.Length.ToString(System.Globalization.CultureInfo.InvariantCulture), " certificate policy/policies."));
|
||||
foreach (InfisicalCertificatePolicy policy in all)
|
||||
{
|
||||
WriteObject(policy);
|
||||
@@ -46,7 +47,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ThrowTerminatingForException("GetInfisicalCertificatePolicyCmdlet", "GetCertificatePolicy", exception);
|
||||
WriteErrorForException("GetInfisicalCertificatePolicyCmdlet", "GetCertificatePolicy", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
|
||||
bool? includeConfigs = MyInvocation.BoundParameters.ContainsKey("IncludeConfigs") ? (bool?)IncludeConfigs.IsPresent : null;
|
||||
InfisicalCertificateProfile[] all = client.ListCertificateProfiles(connection, ProjectId, Limit, Offset, includeConfigs);
|
||||
Logger.Information("Get-InfisicalCertificateProfile", string.Concat("Returned ", all.Length.ToString(System.Globalization.CultureInfo.InvariantCulture), " certificate profile(s)."));
|
||||
foreach (InfisicalCertificateProfile profile in all)
|
||||
{
|
||||
WriteObject(profile);
|
||||
@@ -49,7 +50,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ThrowTerminatingForException("GetInfisicalCertificateProfileCmdlet", "GetCertificateProfile", exception);
|
||||
WriteErrorForException("GetInfisicalCertificateProfileCmdlet", "GetCertificateProfile", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
|
||||
InfisicalEnvironment[] envs = client.List(connection, ProjectId);
|
||||
Logger.Information("Get-InfisicalEnvironment", string.Concat("Returned ", envs.Length.ToString(System.Globalization.CultureInfo.InvariantCulture), " environment(s)."));
|
||||
foreach (InfisicalEnvironment env in envs)
|
||||
{
|
||||
WriteObject(env);
|
||||
@@ -42,7 +43,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ThrowTerminatingForException("GetInfisicalEnvironmentCmdlet", "GetEnvironment", exception);
|
||||
WriteErrorForException("GetInfisicalEnvironmentCmdlet", "GetEnvironment", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
using System;
|
||||
using System.Management.Automation;
|
||||
|
||||
namespace PSInfisicalAPI.Cmdlets
|
||||
{
|
||||
[Cmdlet(VerbsCommon.Get, "InfisicalEnvironmentVariable")]
|
||||
[OutputType(typeof(string))]
|
||||
public sealed class GetInfisicalEnvironmentVariableCmdlet : InfisicalCmdletBase
|
||||
{
|
||||
private const string Component = "Get-InfisicalEnvironmentVariable";
|
||||
|
||||
private static readonly EnvironmentVariableTarget[] TargetOrder = new[]
|
||||
{
|
||||
EnvironmentVariableTarget.Process,
|
||||
EnvironmentVariableTarget.User,
|
||||
EnvironmentVariableTarget.Machine
|
||||
};
|
||||
|
||||
[Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)]
|
||||
[ValidateNotNullOrEmpty]
|
||||
public string Name { get; set; }
|
||||
|
||||
[Parameter(Position = 1)]
|
||||
public EnvironmentVariableTarget? Scope { get; set; }
|
||||
|
||||
protected override void ProcessRecord()
|
||||
{
|
||||
EnvironmentVariableTarget[] targets = Scope.HasValue ? new[] { Scope.Value } : TargetOrder;
|
||||
|
||||
foreach (EnvironmentVariableTarget target in targets)
|
||||
{
|
||||
Logger.Verbose(Component, string.Concat("Searching ", target.ToString(), " scope for environment variable '", Name, "'."));
|
||||
|
||||
string value;
|
||||
try
|
||||
{
|
||||
value = Environment.GetEnvironmentVariable(Name, target);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Logger.Verbose(Component, string.Concat("Failed to read ", target.ToString(), " scope for environment variable '", Name, "': ", exception.Message));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(value))
|
||||
{
|
||||
Logger.Information(Component, string.Concat("Found environment variable '", Name, "' in ", target.ToString(), " scope."));
|
||||
WriteObject(value);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
string scopeDescription = Scope.HasValue ? string.Concat(Scope.Value.ToString(), " scope") : "Process, User, or Machine scope";
|
||||
Logger.Information(Component, string.Concat("Environment variable '", Name, "' was not found in ", scopeDescription, "."));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
|
||||
InfisicalFolder[] folders = client.List(connection, ProjectId, Environment, Path);
|
||||
Logger.Information("Get-InfisicalFolder", string.Concat("Returned ", folders.Length.ToString(System.Globalization.CultureInfo.InvariantCulture), " folder(s) from '", Path ?? "/", "'."));
|
||||
foreach (InfisicalFolder folder in folders)
|
||||
{
|
||||
WriteObject(folder);
|
||||
@@ -44,7 +45,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ThrowTerminatingForException("GetInfisicalFolderCmdlet", "GetFolder", exception);
|
||||
WriteErrorForException("GetInfisicalFolderCmdlet", "GetFolder", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
|
||||
InfisicalOrganization[] organizations = client.List(connection);
|
||||
Logger.Information("Get-InfisicalOrganization", string.Concat("Returned ", organizations.Length.ToString(System.Globalization.CultureInfo.InvariantCulture), " organization(s)."));
|
||||
foreach (InfisicalOrganization organization in organizations)
|
||||
{
|
||||
WriteObject(organization);
|
||||
@@ -40,7 +41,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ThrowTerminatingForException("GetInfisicalOrganizationCmdlet", "GetOrganization", exception);
|
||||
WriteErrorForException("GetInfisicalOrganizationCmdlet", "GetOrganization", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
|
||||
InfisicalPkiSubscriber[] all = client.ListPkiSubscribers(connection, ProjectId);
|
||||
Logger.Information("Get-InfisicalPkiSubscriber", string.Concat("Returned ", all.Length.ToString(System.Globalization.CultureInfo.InvariantCulture), " PKI subscriber(s)."));
|
||||
foreach (InfisicalPkiSubscriber subscriber in all)
|
||||
{
|
||||
WriteObject(subscriber);
|
||||
@@ -42,7 +43,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ThrowTerminatingForException("GetInfisicalPkiSubscriberCmdlet", "GetPkiSubscriber", exception);
|
||||
WriteErrorForException("GetInfisicalPkiSubscriberCmdlet", "GetPkiSubscriber", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
|
||||
InfisicalProject[] projects = client.List(connection, Type, IncludeRoles.IsPresent);
|
||||
Logger.Information("Get-InfisicalProject", string.Concat("Returned ", projects.Length.ToString(System.Globalization.CultureInfo.InvariantCulture), " project(s)."));
|
||||
foreach (InfisicalProject project in projects)
|
||||
{
|
||||
WriteObject(project);
|
||||
@@ -46,7 +47,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ThrowTerminatingForException("GetInfisicalProjectCmdlet", "GetProject", exception);
|
||||
WriteErrorForException("GetInfisicalProjectCmdlet", "GetProject", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ThrowTerminatingForException(Component, "GetSANList", exception);
|
||||
WriteErrorForException(Component, "GetSANList", exception);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ThrowTerminatingForException(Component, "GetScepMdmProfile", exception);
|
||||
WriteErrorForException(Component, "GetScepMdmProfile", exception);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Management.Automation;
|
||||
using PSInfisicalAPI.Connections;
|
||||
using PSInfisicalAPI.Models;
|
||||
@@ -57,8 +58,13 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
InfisicalSecret secret = client.Retrieve(connection, query);
|
||||
if (secret != null)
|
||||
{
|
||||
Logger.Information("Get-InfisicalSecret", string.Concat("Returned 1 secret for '", SecretName, "'."));
|
||||
WriteObject(secret);
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.Information("Get-InfisicalSecret", string.Concat("No secret returned for '", SecretName, "'."));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -79,6 +85,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
};
|
||||
|
||||
InfisicalSecret[] secrets = client.List(connection, listQuery);
|
||||
Logger.Information("Get-InfisicalSecret", string.Concat("Returned ", secrets.Length.ToString(CultureInfo.InvariantCulture), " secret(s) from '", SecretPath ?? "/", "' (recursive=", Recursive.IsPresent ? "true" : "false", ", includeImports=", IncludeImports.IsPresent ? "true" : "false", ")."));
|
||||
foreach (InfisicalSecret secret in secrets)
|
||||
{
|
||||
WriteObject(secret);
|
||||
@@ -86,7 +93,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ThrowTerminatingForException("GetInfisicalSecretCmdlet", "GetSecret", exception);
|
||||
WriteErrorForException("GetInfisicalSecretCmdlet", "GetSecret", exception);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
|
||||
bool? isAccessible = MyInvocation.BoundParameters.ContainsKey("IsAccessible") ? (bool?)IsAccessible.IsPresent : null;
|
||||
InfisicalSubOrganization[] subOrganizations = client.List(connection, Limit, Offset, Search, OrderBy, OrderDirection, isAccessible);
|
||||
Logger.Information("Get-InfisicalSubOrganization", string.Concat("Returned ", subOrganizations.Length.ToString(System.Globalization.CultureInfo.InvariantCulture), " sub-organization(s)."));
|
||||
foreach (InfisicalSubOrganization subOrganization in subOrganizations)
|
||||
{
|
||||
WriteObject(subOrganization);
|
||||
@@ -52,7 +53,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ThrowTerminatingForException("GetInfisicalSubOrganizationCmdlet", "GetSubOrganization", exception);
|
||||
WriteErrorForException("GetInfisicalSubOrganizationCmdlet", "GetSubOrganization", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
|
||||
InfisicalTag[] tags = client.List(connection, ProjectId);
|
||||
Logger.Information("Get-InfisicalTag", string.Concat("Returned ", tags.Length.ToString(System.Globalization.CultureInfo.InvariantCulture), " tag(s)."));
|
||||
foreach (InfisicalTag tag in tags)
|
||||
{
|
||||
WriteObject(tag);
|
||||
@@ -42,7 +43,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ThrowTerminatingForException("GetInfisicalTagCmdlet", "GetTag", exception);
|
||||
WriteErrorForException("GetInfisicalTagCmdlet", "GetTag", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Management.Automation;
|
||||
using System.Security;
|
||||
using PSInfisicalAPI.Common;
|
||||
using PSInfisicalAPI.Errors;
|
||||
using PSInfisicalAPI.Imports;
|
||||
using PSInfisicalAPI.Models;
|
||||
using PSInfisicalAPI.Security;
|
||||
|
||||
namespace PSInfisicalAPI.Cmdlets
|
||||
{
|
||||
[Cmdlet(VerbsData.Import, "InfisicalSecret")]
|
||||
[OutputType(typeof(Dictionary<string, SecureString>))]
|
||||
[OutputType(typeof(Dictionary<string, string>))]
|
||||
public sealed class ImportInfisicalSecretCmdlet : InfisicalCmdletBase
|
||||
{
|
||||
[Parameter(Mandatory = true, Position = 0)]
|
||||
[ValidateNotNull]
|
||||
public FileInfo Path { get; set; }
|
||||
|
||||
[Parameter(Mandatory = true)]
|
||||
public InfisicalImportFormat Format { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public InfisicalDuplicateKeyBehavior DuplicateKeyBehavior { get; set; } = InfisicalDuplicateKeyBehavior.Error;
|
||||
|
||||
[Parameter]
|
||||
public SwitchParameter AsPlainText { get; set; }
|
||||
|
||||
[Parameter]
|
||||
[Alias("Prefix")]
|
||||
public string SecretsPrefix { get; set; }
|
||||
|
||||
[Parameter]
|
||||
[Alias("ForcePrefix")]
|
||||
public SwitchParameter ForceSecretsPrefix { get; set; }
|
||||
|
||||
protected override void EndProcessing()
|
||||
{
|
||||
try
|
||||
{
|
||||
Path.Refresh();
|
||||
if (!Path.Exists)
|
||||
{
|
||||
throw new InfisicalImportException(string.Concat("Import path does not exist: ", Path.FullName));
|
||||
}
|
||||
|
||||
IInfisicalImporter importer = InfisicalImporterFactory.Create(Format);
|
||||
IList<KeyValuePair<string, string>> pairs = importer.Import(Path);
|
||||
Logger.Information("Import-InfisicalSecret", string.Concat("Parsed ", pairs.Count.ToString(System.Globalization.CultureInfo.InvariantCulture), " secret pair(s) from '", Path.FullName, "' (format=", Format.ToString(), ")."));
|
||||
|
||||
if (AsPlainText.IsPresent)
|
||||
{
|
||||
Dictionary<string, string> plain = BuildDictionary<string>(pairs, value => value ?? string.Empty);
|
||||
Logger.Information("Import-InfisicalSecret", string.Concat("Built plain-text dictionary with ", plain.Count.ToString(System.Globalization.CultureInfo.InvariantCulture), " entry/entries."));
|
||||
WriteObject(plain);
|
||||
}
|
||||
else
|
||||
{
|
||||
Dictionary<string, SecureString> secure = BuildDictionary<SecureString>(pairs, value => SecureStringUtility.ToReadOnlySecureString(value ?? string.Empty));
|
||||
Logger.Information("Import-InfisicalSecret", string.Concat("Built SecureString dictionary with ", secure.Count.ToString(System.Globalization.CultureInfo.InvariantCulture), " entry/entries."));
|
||||
WriteObject(secure);
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
WriteErrorForException("ImportInfisicalSecretCmdlet", "ImportSecret", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private Dictionary<string, TValue> BuildDictionary<TValue>(
|
||||
IList<KeyValuePair<string, string>> pairs,
|
||||
Func<string, TValue> valueSelector)
|
||||
{
|
||||
Dictionary<string, TValue> dictionary = new Dictionary<string, TValue>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (KeyValuePair<string, string> pair in pairs)
|
||||
{
|
||||
if (pair.Key == null) { continue; }
|
||||
string key = InfisicalPrefix.Apply(pair.Key, SecretsPrefix, ForceSecretsPrefix.IsPresent);
|
||||
|
||||
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(pair.Value);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
dictionary[key] = valueSelector(pair.Value);
|
||||
}
|
||||
|
||||
return dictionary;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Management.Automation;
|
||||
using System.Runtime.ExceptionServices;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using PSInfisicalAPI.Connections;
|
||||
using PSInfisicalAPI.Errors;
|
||||
using PSInfisicalAPI.Http;
|
||||
@@ -11,6 +15,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
{
|
||||
private IInfisicalLogger _logger;
|
||||
private IInfisicalHttpClient _httpClient;
|
||||
private bool? _isElevated;
|
||||
|
||||
protected IInfisicalLogger Logger
|
||||
{
|
||||
@@ -44,12 +49,104 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
return current != null && current.SkipCertificateCheck;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reports whether the host process is running elevated. Evaluated through the PowerShell engine rather
|
||||
/// than WindowsIdentity directly, because the module targets netstandard2.0 and does not carry a
|
||||
/// System.Security.Principal.Windows reference. Cached for the lifetime of the cmdlet instance.
|
||||
/// </summary>
|
||||
protected bool IsElevated()
|
||||
{
|
||||
if (_isElevated.HasValue) { return _isElevated.Value; }
|
||||
|
||||
try
|
||||
{
|
||||
Collection<PSObject> results = InvokeCommand.InvokeScript(
|
||||
"[bool]([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)");
|
||||
_isElevated = results != null
|
||||
&& results.Count > 0
|
||||
&& results[0] != null
|
||||
&& results[0].BaseObject != null
|
||||
&& Convert.ToBoolean(results[0].BaseObject, CultureInfo.InvariantCulture);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Logger.Verbose(GetType().Name, string.Concat("Elevation check failed; assuming non-elevated. ", exception.Message));
|
||||
_isElevated = false;
|
||||
}
|
||||
|
||||
return _isElevated.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Picks the certificate store location when the caller did not bind -StoreLocation. An elevated process
|
||||
/// installs machine-wide so services and other users can use the certificate; a non-elevated one has no
|
||||
/// write access to LocalMachine and falls back to the user's own stores.
|
||||
/// </summary>
|
||||
protected StoreLocation ResolveStoreLocation(StoreLocation boundValue)
|
||||
{
|
||||
if (MyInvocation != null && MyInvocation.BoundParameters.ContainsKey("StoreLocation"))
|
||||
{
|
||||
return boundValue;
|
||||
}
|
||||
|
||||
bool elevated = IsElevated();
|
||||
StoreLocation resolved = elevated ? StoreLocation.LocalMachine : StoreLocation.CurrentUser;
|
||||
Logger.Information(GetType().Name, string.Concat(
|
||||
"Process is ", elevated ? "elevated" : "not elevated",
|
||||
"; defaulting -StoreLocation to ", resolved.ToString(),
|
||||
". Pass -StoreLocation explicitly to override."));
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reports an operation failure as a non-terminating error, which is what lets -ErrorAction decide the
|
||||
/// outcome: Continue prints and carries on, SilentlyContinue and Ignore suppress, Inquire prompts, and
|
||||
/// Stop is promoted by the engine into a terminating error that try/catch sees. Scripts that want to
|
||||
/// catch these must ask for it with -ErrorAction Stop or $ErrorActionPreference = 'Stop'.
|
||||
/// </summary>
|
||||
protected void WriteErrorForException(string component, string operation, Exception exception)
|
||||
{
|
||||
ErrorRecord record = BuildFailureRecord(component, operation, exception);
|
||||
WriteError(record);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reports a failure the cmdlet cannot continue past regardless of -ErrorAction. Reserved for aborts that
|
||||
/// are not per-item failures; ordinary operation failures belong on <see cref="WriteErrorForException"/>.
|
||||
/// </summary>
|
||||
protected void ThrowTerminatingForException(string component, string operation, Exception exception)
|
||||
{
|
||||
ErrorRecord record = BuildFailureRecord(component, operation, exception);
|
||||
ThrowTerminatingError(record);
|
||||
}
|
||||
|
||||
private ErrorRecord BuildFailureRecord(string component, string operation, Exception exception)
|
||||
{
|
||||
if (IsPipelineControlException(exception))
|
||||
{
|
||||
ExceptionDispatchInfo.Capture(exception).Throw();
|
||||
}
|
||||
|
||||
InfisicalErrorDetails details = InfisicalErrorHandler.BuildDetails(component, operation, exception);
|
||||
InfisicalErrorHandler.LogFailure(Logger, details);
|
||||
ErrorRecord record = InfisicalErrorHandler.ToErrorRecord(exception, details);
|
||||
ThrowTerminatingError(record);
|
||||
return InfisicalErrorHandler.ToErrorRecord(exception, details);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Identifies exceptions the PowerShell engine uses to unwind a pipeline rather than to report a fault.
|
||||
/// Downstream cmdlets that stop early (<c>Select-Object -First</c>, <c>Where-Object</c> feeding such a
|
||||
/// cmdlet, Ctrl+C) make <see cref="System.Management.Automation.Cmdlet.WriteObject(object)"/> throw one of
|
||||
/// these. Reporting them as errors turns a normal early exit into spurious "The pipeline has been stopped."
|
||||
/// output, so they must propagate untouched.
|
||||
/// </summary>
|
||||
protected static bool IsPipelineControlException(Exception exception)
|
||||
{
|
||||
// StopUpstreamCommandsException (internal, thrown by Select-Object -First) derives from
|
||||
// PipelineStoppedException, so the base type covers it.
|
||||
return exception is PipelineStoppedException
|
||||
|| exception is PipelineClosedException
|
||||
|| exception is HaltCommandException;
|
||||
}
|
||||
|
||||
protected string ResolveApiVersion(InfisicalConnection connection, string explicitValue)
|
||||
|
||||
@@ -32,20 +32,22 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
{
|
||||
try
|
||||
{
|
||||
StoreLocation resolvedStoreLocation = ResolveStoreLocation(StoreLocation);
|
||||
|
||||
X509Certificate2 cert = ResolveCertificate();
|
||||
if (cert == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
InstallCertificate(cert, StoreName, StoreLocation);
|
||||
InstallCertificate(cert, StoreName, resolvedStoreLocation);
|
||||
|
||||
if (IncludeChain.IsPresent && string.Equals(ParameterSetName, "FromCertificate", StringComparison.Ordinal) == false)
|
||||
{
|
||||
foreach (X509Certificate2 chainCert in ResolveChain())
|
||||
{
|
||||
StoreName chainStore = InfisicalCertificateRequestHelpers.GetChainCertificateTargetStore(chainCert);
|
||||
InstallCertificate(chainCert, chainStore, StoreLocation);
|
||||
InstallCertificate(chainCert, chainStore, resolvedStoreLocation);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +58,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ThrowTerminatingForException("InstallInfisicalCertificateCmdlet", "InstallCertificate", exception);
|
||||
WriteErrorForException("InstallInfisicalCertificateCmdlet", "InstallCertificate", exception);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +90,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
return;
|
||||
}
|
||||
|
||||
InfisicalCertificateRequestHelpers.WarnIfInteractiveTrustPromptExpected(storeName, storeLocation, Logger, "InstallInfisicalCertificateCmdlet");
|
||||
store.Add(cert);
|
||||
Logger.Information("InstallInfisicalCertificateCmdlet", string.Concat("Installed certificate to ", target, "."));
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ThrowTerminatingForException("NewInfisicalEnvironmentCmdlet", "CreateEnvironment", exception);
|
||||
WriteErrorForException("NewInfisicalEnvironmentCmdlet", "CreateEnvironment", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ThrowTerminatingForException("NewInfisicalFolderCmdlet", "CreateFolder", exception);
|
||||
WriteErrorForException("NewInfisicalFolderCmdlet", "CreateFolder", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ThrowTerminatingForException("NewInfisicalOrganizationCmdlet", "CreateOrganization", exception);
|
||||
WriteErrorForException("NewInfisicalOrganizationCmdlet", "CreateOrganization", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ThrowTerminatingForException("NewInfisicalProjectCmdlet", "CreateProject", exception);
|
||||
WriteErrorForException("NewInfisicalProjectCmdlet", "CreateProject", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ThrowTerminatingForException("NewInfisicalScepDynamicChallengeCmdlet", "GenerateScepDynamicChallenge", exception);
|
||||
WriteErrorForException("NewInfisicalScepDynamicChallengeCmdlet", "GenerateScepDynamicChallenge", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,10 +58,12 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
Secrets = InfisicalBulkSecretConverter.ToCreateItems(Secrets)
|
||||
};
|
||||
|
||||
Logger.Information("New-InfisicalSecret", string.Concat("Bulk-creating ", Secrets.Length.ToString(System.Globalization.CultureInfo.InvariantCulture), " secret(s)."));
|
||||
InfisicalSecretsClient bulkClient = new InfisicalSecretsClient(HttpClient, Logger);
|
||||
InfisicalSecret[] created = bulkClient.CreateBatch(connection, bulk);
|
||||
if (created != null)
|
||||
{
|
||||
Logger.Information("New-InfisicalSecret", string.Concat("Server returned ", created.Length.ToString(System.Globalization.CultureInfo.InvariantCulture), " created secret(s)."));
|
||||
foreach (InfisicalSecret secret in created) { WriteObject(secret); }
|
||||
}
|
||||
|
||||
@@ -97,7 +99,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ThrowTerminatingForException("NewInfisicalSecretCmdlet", "CreateSecret", exception);
|
||||
WriteErrorForException("NewInfisicalSecretCmdlet", "CreateSecret", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ThrowTerminatingForException("NewInfisicalSubOrganizationCmdlet", "CreateSubOrganization", exception);
|
||||
WriteErrorForException("NewInfisicalSubOrganizationCmdlet", "CreateSubOrganization", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ThrowTerminatingForException("NewInfisicalTagCmdlet", "CreateTag", exception);
|
||||
WriteErrorForException("NewInfisicalTagCmdlet", "CreateTag", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ThrowTerminatingForException("RemoveInfisicalEnvironmentCmdlet", "DeleteEnvironment", exception);
|
||||
WriteErrorForException("RemoveInfisicalEnvironmentCmdlet", "DeleteEnvironment", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ThrowTerminatingForException("RemoveInfisicalFolderCmdlet", "DeleteFolder", exception);
|
||||
WriteErrorForException("RemoveInfisicalFolderCmdlet", "DeleteFolder", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ThrowTerminatingForException("RemoveInfisicalOrganizationCmdlet", "DeleteOrganization", exception);
|
||||
WriteErrorForException("RemoveInfisicalOrganizationCmdlet", "DeleteOrganization", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ThrowTerminatingForException("RemoveInfisicalProjectCmdlet", "DeleteProject", exception);
|
||||
WriteErrorForException("RemoveInfisicalProjectCmdlet", "DeleteProject", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
SecretNames = SecretNames
|
||||
};
|
||||
|
||||
Logger.Information("Remove-InfisicalSecret", string.Concat("Bulk-removing ", SecretNames.Length.ToString(System.Globalization.CultureInfo.InvariantCulture), " secret(s)."));
|
||||
client.DeleteBatch(connection, bulk);
|
||||
|
||||
if (PassThru.IsPresent)
|
||||
@@ -78,7 +79,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ThrowTerminatingForException("RemoveInfisicalSecretCmdlet", "DeleteSecret", exception);
|
||||
WriteErrorForException("RemoveInfisicalSecretCmdlet", "DeleteSecret", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ThrowTerminatingForException("RemoveInfisicalSubOrganizationCmdlet", "DeleteSubOrganization", exception);
|
||||
WriteErrorForException("RemoveInfisicalSubOrganizationCmdlet", "DeleteSubOrganization", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ThrowTerminatingForException("RemoveInfisicalTagCmdlet", "DeleteTag", exception);
|
||||
WriteErrorForException("RemoveInfisicalTagCmdlet", "DeleteTag", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Collections.Generic;
|
||||
using System.Management.Automation;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using PSInfisicalAPI.Connections;
|
||||
using PSInfisicalAPI.Errors;
|
||||
using PSInfisicalAPI.Models;
|
||||
using PSInfisicalAPI.Pki;
|
||||
|
||||
@@ -48,7 +49,9 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
[Parameter(ParameterSetName = "ByProfile")] public string NotBefore { get; set; }
|
||||
[Parameter(ParameterSetName = "ByCa")]
|
||||
[Parameter(ParameterSetName = "ByProfile")] public string NotAfter { get; set; }
|
||||
[Parameter(ParameterSetName = "ByCa")] public string FriendlyName { get; set; }
|
||||
// Available on every parameter set: it names the installed certificate in the Windows store. The CA path
|
||||
// additionally forwards it to Infisical as the issued certificate's friendlyName.
|
||||
[Parameter] public string FriendlyName { get; set; }
|
||||
[Parameter(ParameterSetName = "ByCa")] public string PkiCollectionId { get; set; }
|
||||
[Parameter(ParameterSetName = "ByCa")]
|
||||
[Parameter(ParameterSetName = "ByProfile")] public string[] KeyUsage { get; set; }
|
||||
@@ -78,12 +81,16 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
InfisicalConnection connection = InfisicalSessionManager.RequireCurrent();
|
||||
InfisicalPkiClient client = new InfisicalPkiClient(HttpClient, Logger);
|
||||
|
||||
// Resolved once so reuse detection looks in the same stores the install will write to.
|
||||
StoreLocation resolvedStoreLocation = ResolveStoreLocation(StoreLocation);
|
||||
|
||||
InfisicalCsrSubject csrSubject = InfisicalCertificateRequestHelpers.MergeSubject(Subject, CommonName, Country, State, Locality, Organization, OrganizationalUnit, EmailAddress);
|
||||
List<string> dnsNames = BuildDnsNames(csrSubject);
|
||||
List<string> ipAddresses = new List<string>();
|
||||
List<string> dnsNames = BuildDnsNames(csrSubject, ipAddresses);
|
||||
if (string.IsNullOrEmpty(csrSubject.CommonName) && dnsNames.Count > 0) { csrSubject.CommonName = dnsNames[0]; }
|
||||
if (string.IsNullOrEmpty(csrSubject.CommonName)) { throw new InvalidOperationException("Subject CommonName could not be determined and no DnsName was provided."); }
|
||||
|
||||
X509Certificate2 existing = TryFindExisting(client, connection, ProjectId, csrSubject.CommonName);
|
||||
X509Certificate2 existing = TryFindExisting(client, connection, ProjectId, csrSubject.CommonName, resolvedStoreLocation);
|
||||
if (existing != null && !Force.IsPresent && !(AllowRenewal.IsPresent && InfisicalLocalCertificateLookup.IsRenewable(existing, RenewalThresholdDays)))
|
||||
{
|
||||
Logger.Information(Component, string.Concat("Reusing existing certificate (Thumbprint=", existing.Thumbprint, ", NotAfter=", existing.NotAfter.ToString("u"), ")."));
|
||||
@@ -104,6 +111,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
catch (Exception bundleException)
|
||||
{
|
||||
if (IsPipelineControlException(bundleException)) { throw; }
|
||||
Logger.Verbose(Component, string.Concat("Infisical bundle fetch for reuse path failed (continuing with local-only chain): ", bundleException.Message));
|
||||
}
|
||||
}
|
||||
@@ -112,12 +120,14 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
return;
|
||||
}
|
||||
|
||||
string target = string.Concat("PKI subscriber '", PkiSubscriberSlug ?? "(n/a)", "', CA '", CertificateAuthorityId ?? "(n/a)", "', or profile '", CertificateProfileId ?? "(n/a)", "' for CN=", csrSubject.CommonName);
|
||||
string issuer = ResolveIssuancePath(client, connection);
|
||||
|
||||
string target = string.Concat(issuer, " for CN=", csrSubject.CommonName);
|
||||
if (!ShouldProcess(target, "Request new certificate")) { return; }
|
||||
|
||||
InfisicalCsrOptions csrOptions = new InfisicalCsrOptions { KeyAlgorithm = KeyAlgorithm, RsaKeySize = KeySize, EcCurve = Curve };
|
||||
InfisicalCsrResult csr = InfisicalCsrBuilder.Build(csrSubject, dnsNames, IpAddress, csrOptions);
|
||||
InfisicalSignedCertificate signed = SignCertificate(client, connection, ProjectId, csr.CsrPem);
|
||||
InfisicalCsrResult csr = InfisicalCsrBuilder.Build(csrSubject, dnsNames, ipAddresses, csrOptions);
|
||||
InfisicalSignedCertificate signed = SignCertificate(client, connection, ProjectId, csr.CsrPem, csrSubject);
|
||||
signed.PrivateKeyPem = csr.PrivateKeyPem;
|
||||
|
||||
if (string.IsNullOrEmpty(signed.CertificatePem))
|
||||
@@ -129,15 +139,24 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
return;
|
||||
}
|
||||
|
||||
X509KeyStorageFlags resolvedFlags = ResolveEffectiveKeyStorageFlags();
|
||||
X509KeyStorageFlags resolvedFlags = ResolveEffectiveKeyStorageFlags(resolvedStoreLocation);
|
||||
X509Certificate2 cert = PemCertificateBuilder.Build(signed.CertificatePem, signed.PrivateKeyPem, signed.CertificateChainPem, resolvedFlags);
|
||||
|
||||
InfisicalCertificateRequestHelpers.ApplyFriendlyName(cert, ResolveLocalFriendlyName(csrSubject), Logger, Component);
|
||||
|
||||
if (Install.IsPresent)
|
||||
{
|
||||
InfisicalCertificateRequestHelpers.InstallToStore(cert, StoreName, StoreLocation, Force.IsPresent, Logger, Component);
|
||||
// Issuers first, so the leaf is already chainable the moment it lands in the store.
|
||||
if (InstallChain.IsPresent)
|
||||
{
|
||||
InfisicalCertificateRequestHelpers.InstallChain(signed, StoreLocation, Force.IsPresent, Logger, Component);
|
||||
InfisicalCertificateRequestHelpers.InstallChain(signed, resolvedStoreLocation, Force.IsPresent, Logger, Component);
|
||||
}
|
||||
|
||||
InfisicalCertificateRequestHelpers.InstallToStore(cert, StoreName, resolvedStoreLocation, Force.IsPresent, Logger, Component);
|
||||
|
||||
if (InstallChain.IsPresent)
|
||||
{
|
||||
InfisicalCertificateRequestHelpers.VerifyInstalledChain(cert, Logger, Component);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,25 +179,135 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ThrowTerminatingForException(Component, "RequestCertificate", exception);
|
||||
WriteErrorForException(Component, "RequestCertificate", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private List<string> BuildDnsNames(InfisicalCsrSubject subject)
|
||||
/// <summary>
|
||||
/// The Windows friendly name shown in certmgr. Defaults to the common name in upper case, which is the
|
||||
/// host identity operators look for; -FriendlyName overrides it.
|
||||
/// </summary>
|
||||
private string ResolveLocalFriendlyName(InfisicalCsrSubject subject)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(FriendlyName)) { return FriendlyName; }
|
||||
if (subject == null || string.IsNullOrEmpty(subject.CommonName)) { return null; }
|
||||
return subject.CommonName.ToUpperInvariant();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// States which issuer will sign this request, and rejects an unusable one before a keypair is generated.
|
||||
/// Direct CA signing is only permitted when the CA has direct issuance enabled; without this check the
|
||||
/// cmdlet builds a CSR and learns that from a 400 at the very end.
|
||||
/// </summary>
|
||||
private string ResolveIssuancePath(InfisicalPkiClient client, InfisicalConnection connection)
|
||||
{
|
||||
if (string.Equals(ParameterSetName, "BySubscriber", StringComparison.Ordinal))
|
||||
{
|
||||
Logger.Information(Component, string.Concat("Issuing via PKI subscriber '", PkiSubscriberSlug, "' in project '", ProjectId, "'."));
|
||||
return string.Concat("PKI subscriber '", PkiSubscriberSlug, "'");
|
||||
}
|
||||
|
||||
if (string.Equals(ParameterSetName, "ByProfile", StringComparison.Ordinal))
|
||||
{
|
||||
Logger.Information(Component, string.Concat("Issuing via certificate profile '", CertificateProfileId, "' in project '", ProjectId, "'."));
|
||||
return string.Concat("certificate profile '", CertificateProfileId, "'");
|
||||
}
|
||||
|
||||
InfisicalCertificateAuthority ca = null;
|
||||
try
|
||||
{
|
||||
ca = client.GetInternalCertificateAuthority(connection, CertificateAuthorityId, ProjectId);
|
||||
}
|
||||
catch (Exception lookupException)
|
||||
{
|
||||
if (IsPipelineControlException(lookupException)) { throw; }
|
||||
|
||||
// A caller may be able to sign without permission to read the CA record. Defer to the API.
|
||||
Logger.Verbose(Component, string.Concat("Could not read certificate authority '", CertificateAuthorityId, "' for preflight (continuing): ", lookupException.Message));
|
||||
return string.Concat("certificate authority '", CertificateAuthorityId, "'");
|
||||
}
|
||||
|
||||
if (ca != null && ca.EnableDirectIssuance.HasValue && !ca.EnableDirectIssuance.Value)
|
||||
{
|
||||
throw new InfisicalConfigurationException(BuildDirectIssuanceGuidance(ca));
|
||||
}
|
||||
|
||||
string caLabel = ca != null ? (ca.Name ?? ca.FriendlyName ?? CertificateAuthorityId) : CertificateAuthorityId;
|
||||
Logger.Information(Component, string.Concat("Issuing directly via certificate authority '", caLabel, "' (", CertificateAuthorityId, "); direct issuance is enabled."));
|
||||
return string.Concat("certificate authority '", caLabel, "'");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restates the direct-issuance restriction in terms of the parameters that resolve it. Infisical's REST
|
||||
/// API exposes no certificate-template issuance route, so the alternatives are a subscriber or a profile.
|
||||
/// </summary>
|
||||
private string BuildDirectIssuanceGuidance(InfisicalCertificateAuthority ca)
|
||||
{
|
||||
string caLabel = ca != null ? (ca.Name ?? ca.FriendlyName ?? CertificateAuthorityId) : CertificateAuthorityId;
|
||||
return string.Concat(
|
||||
"Certificate authority '", caLabel, "' (", CertificateAuthorityId, ") has direct issuance disabled, so it cannot sign a CSR on its own. ",
|
||||
"Either enable direct issuance on the CA in Infisical (Certificate Authorities > the CA > Enable Direct Issuance), ",
|
||||
"or issue through a subscriber or profile instead: Request-InfisicalCertificate -PkiSubscriberSlug <name> (see Get-InfisicalPkiSubscriber -ProjectId '", ProjectId ?? "<projectId>", "') ",
|
||||
"or -CertificateProfileId <id> (see Get-InfisicalCertificateProfile).");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Splits the requested SAN values into DNS names and IP addresses. Get-InfisicalSANList emits both kinds
|
||||
/// in one list, so IP literals arriving through -DnsName are routed to the IP SAN bucket rather than
|
||||
/// emitted as malformed dNSName entries.
|
||||
/// </summary>
|
||||
private List<string> BuildDnsNames(InfisicalCsrSubject subject, List<string> ipAddresses)
|
||||
{
|
||||
List<string> result = new List<string>();
|
||||
if (DnsName != null) { foreach (string dns in DnsName) { if (!string.IsNullOrEmpty(dns)) { result.Add(dns); } } }
|
||||
if (result.Count == 0)
|
||||
AddSanCandidates(DnsName, result, ipAddresses);
|
||||
AddSanCandidates(IpAddress, null, ipAddresses);
|
||||
|
||||
// Fall back to the local FQDN only when no SAN of either kind was requested; an explicit IP-only
|
||||
// request must not silently pick up this machine's name.
|
||||
if (result.Count == 0 && ipAddresses.Count == 0)
|
||||
{
|
||||
string fqdn = InfisicalCertificateRequestHelpers.ResolveLocalFqdn();
|
||||
if (!string.IsNullOrEmpty(fqdn)) { result.Add(fqdn); }
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(subject.CommonName) && !result.Contains(subject.CommonName)) { result.Insert(0, subject.CommonName); }
|
||||
// The common name is mirrored into the SAN list because most validators ignore a CN that has no
|
||||
// matching SAN entry. An IP common name belongs in the iPAddress bucket, not the dNSName one.
|
||||
if (!string.IsNullOrEmpty(subject.CommonName))
|
||||
{
|
||||
if (IsIpLiteral(subject.CommonName))
|
||||
{
|
||||
if (!ipAddresses.Contains(subject.CommonName)) { ipAddresses.Insert(0, subject.CommonName); }
|
||||
}
|
||||
else if (!result.Contains(subject.CommonName))
|
||||
{
|
||||
result.Insert(0, subject.CommonName);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private X509Certificate2 TryFindExisting(InfisicalPkiClient client, InfisicalConnection connection, string projectId, string commonName)
|
||||
private static void AddSanCandidates(IEnumerable<string> candidates, List<string> dnsNames, List<string> ipAddresses)
|
||||
{
|
||||
if (candidates == null) { return; }
|
||||
foreach (string candidate in candidates)
|
||||
{
|
||||
if (string.IsNullOrEmpty(candidate)) { continue; }
|
||||
string value = candidate.Trim();
|
||||
if (value.Length == 0) { continue; }
|
||||
|
||||
List<string> bucket = IsIpLiteral(value) ? ipAddresses : dnsNames;
|
||||
if (bucket != null && !bucket.Contains(value)) { bucket.Add(value); }
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsIpLiteral(string value)
|
||||
{
|
||||
System.Net.IPAddress parsed;
|
||||
return System.Net.IPAddress.TryParse(value, out parsed);
|
||||
}
|
||||
|
||||
private X509Certificate2 TryFindExisting(InfisicalPkiClient client, InfisicalConnection connection, string projectId, string commonName, StoreLocation storeLocation)
|
||||
{
|
||||
List<string> candidateSerials = new List<string>();
|
||||
try
|
||||
@@ -192,23 +321,33 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
catch (Exception searchException)
|
||||
{
|
||||
if (IsPipelineControlException(searchException)) { throw; }
|
||||
Logger.Verbose(Component, string.Concat("Infisical search for idempotency check failed: ", searchException.Message));
|
||||
}
|
||||
|
||||
return InfisicalLocalCertificateLookup.FindMatch(StoreName, StoreLocation, commonName, candidateSerials);
|
||||
return InfisicalLocalCertificateLookup.FindMatch(StoreName, storeLocation, commonName, candidateSerials);
|
||||
}
|
||||
|
||||
private X509KeyStorageFlags ResolveEffectiveKeyStorageFlags()
|
||||
private X509KeyStorageFlags ResolveEffectiveKeyStorageFlags(StoreLocation storeLocation)
|
||||
{
|
||||
if (MyInvocation.BoundParameters.ContainsKey("KeyStorageFlags"))
|
||||
{
|
||||
return KeyStorageFlags;
|
||||
}
|
||||
|
||||
return InfisicalCertificateRequestHelpers.ResolveKeyStorageFlags(PrivateKeyProtection, PersistKey.IsPresent, MachineKey.IsPresent);
|
||||
// A certificate installed into LocalMachine needs its private key in the machine key store, otherwise
|
||||
// the key lands in the calling user's profile and the installed certificate has no usable key for
|
||||
// services or other users.
|
||||
bool machineKey = MachineKey.IsPresent || (Install.IsPresent && storeLocation == StoreLocation.LocalMachine);
|
||||
if (machineKey && !MachineKey.IsPresent)
|
||||
{
|
||||
Logger.Verbose(Component, "Installing to LocalMachine; using a machine key store so the private key is usable outside this user profile.");
|
||||
}
|
||||
|
||||
return InfisicalCertificateRequestHelpers.ResolveKeyStorageFlags(PrivateKeyProtection, PersistKey.IsPresent, machineKey);
|
||||
}
|
||||
|
||||
private InfisicalSignedCertificate SignCertificate(InfisicalPkiClient client, InfisicalConnection connection, string projectId, string csrPem)
|
||||
private InfisicalSignedCertificate SignCertificate(InfisicalPkiClient client, InfisicalConnection connection, string projectId, string csrPem, InfisicalCsrSubject subject)
|
||||
{
|
||||
if (string.Equals(ParameterSetName, "BySubscriber", StringComparison.Ordinal))
|
||||
{
|
||||
@@ -217,11 +356,49 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
|
||||
if (string.Equals(ParameterSetName, "ByProfile", StringComparison.Ordinal))
|
||||
{
|
||||
InfisicalCsrSubject subject = InfisicalCertificateRequestHelpers.MergeSubject(Subject, CommonName, Country, State, Locality, Organization, OrganizationalUnit, EmailAddress);
|
||||
return client.IssueCertificateByProfile(connection, CertificateProfileId, csrPem, subject.CommonName, subject.Organization, subject.OrganizationalUnit, subject.Country, subject.State, subject.Locality, Ttl, NotBefore, NotAfter, KeyUsage, ExtendedKeyUsage);
|
||||
}
|
||||
|
||||
return client.SignCertificateByCa(connection, CertificateAuthorityId, csrPem, CommonName, null, Ttl, NotBefore, NotAfter, FriendlyName, PkiCollectionId, KeyUsage, ExtendedKeyUsage);
|
||||
try
|
||||
{
|
||||
return client.SignCertificateByCa(connection, CertificateAuthorityId, csrPem, subject.CommonName, null, Ttl, NotBefore, NotAfter, FriendlyName, PkiCollectionId, KeyUsage, ExtendedKeyUsage);
|
||||
}
|
||||
catch (InfisicalApiException apiException)
|
||||
{
|
||||
throw EnrichDirectIssuanceFailure(apiException);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Backstop for when the preflight in <see cref="ResolveIssuancePath"/> could not read the CA record and
|
||||
/// the API rejects the signing request instead. The raw 400 does not say which cmdlet parameter to reach
|
||||
/// for, so restate it in the module's own terms.
|
||||
/// </summary>
|
||||
private InfisicalApiException EnrichDirectIssuanceFailure(InfisicalApiException apiException)
|
||||
{
|
||||
if (apiException == null || apiException.StatusCode != 400) { return apiException; }
|
||||
|
||||
string apiMessage = apiException.ApiErrorMessage ?? apiException.Message ?? string.Empty;
|
||||
if (apiMessage.IndexOf("template or subscriber", StringComparison.OrdinalIgnoreCase) < 0)
|
||||
{
|
||||
return apiException;
|
||||
}
|
||||
|
||||
string guidance = string.Concat(BuildDirectIssuanceGuidance(null), " Original API error: ", apiMessage);
|
||||
|
||||
return new InfisicalApiException(guidance, apiException)
|
||||
{
|
||||
StatusCode = apiException.StatusCode,
|
||||
ReasonPhrase = apiException.ReasonPhrase,
|
||||
ApiErrorCode = apiException.ApiErrorCode,
|
||||
ApiErrorMessage = apiException.ApiErrorMessage,
|
||||
ApiRequestId = apiException.ApiRequestId,
|
||||
SanitizedBody = apiException.SanitizedBody,
|
||||
EndpointName = apiException.EndpointName,
|
||||
RequestMethod = apiException.RequestMethod,
|
||||
Component = apiException.Component,
|
||||
Operation = apiException.Operation
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,18 +89,23 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
public SwitchParameter ContinueOnError { get; set; }
|
||||
|
||||
[Parameter(ValueFromPipeline = true)]
|
||||
[Alias("Secrets", "InputObject")]
|
||||
public InfisicalSecret[] Secret { get; set; }
|
||||
[Alias("Secret", "InputObject")]
|
||||
public InfisicalSecret[] Secrets { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string Prefix { get; set; }
|
||||
[Alias("Prefix")]
|
||||
public string SecretsPrefix { get; set; }
|
||||
|
||||
[Parameter]
|
||||
[Alias("ForcePrefix")]
|
||||
public SwitchParameter ForceSecretsPrefix { get; set; }
|
||||
|
||||
private readonly List<InfisicalSecret> _secretBuffer = new List<InfisicalSecret>();
|
||||
|
||||
protected override void ProcessRecord()
|
||||
{
|
||||
if (Secret == null) { return; }
|
||||
foreach (InfisicalSecret secret in Secret)
|
||||
if (Secrets == null) { return; }
|
||||
foreach (InfisicalSecret secret in Secrets)
|
||||
{
|
||||
if (secret != null) { _secretBuffer.Add(secret); }
|
||||
}
|
||||
@@ -116,6 +121,9 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
|
||||
if (!ShouldProcess(target, "Start process with Infisical secrets")) { return; }
|
||||
|
||||
int envVarCount = EnvironmentVariables != null ? EnvironmentVariables.Count : 0;
|
||||
Logger.Information("Start-InfisicalProcess", string.Concat("Injecting ", _secretBuffer.Count.ToString(System.Globalization.CultureInfo.InvariantCulture), " secret(s) and ", envVarCount.ToString(System.Globalization.CultureInfo.InvariantCulture), " explicit environment variable(s) into process environment."));
|
||||
|
||||
InfisicalProcessOptions options = new InfisicalProcessOptions
|
||||
{
|
||||
FilePath = FilePath,
|
||||
@@ -135,7 +143,8 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
LogOutput = LogOutput.IsPresent,
|
||||
ContinueOnError = ContinueOnError.IsPresent,
|
||||
Secrets = _secretBuffer.ToArray(),
|
||||
Prefix = Prefix
|
||||
SecretsPrefix = SecretsPrefix,
|
||||
ForceSecretsPrefix = ForceSecretsPrefix.IsPresent
|
||||
};
|
||||
|
||||
InfisicalProcessResult result = InfisicalProcessRunner.Run(options, Logger);
|
||||
@@ -146,13 +155,13 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
string message = string.Concat("Process '", FilePath, "' exited with code ", result.ExitCode.HasValue ? result.ExitCode.Value.ToString() : "<null>", " which is not in the acceptable exit code list.");
|
||||
InvalidOperationException exception = new InvalidOperationException(message);
|
||||
ErrorRecord error = new ErrorRecord(exception, "StartInfisicalProcess.UnacceptableExitCode", ErrorCategory.InvalidResult, result);
|
||||
ThrowTerminatingError(error);
|
||||
WriteError(error);
|
||||
}
|
||||
}
|
||||
catch (PipelineStoppedException) { throw; }
|
||||
catch (Exception exception)
|
||||
{
|
||||
ThrowTerminatingForException(Component, "StartProcess", exception);
|
||||
WriteErrorForException(Component, "StartProcess", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ThrowTerminatingForException("UninstallInfisicalCertificateCmdlet", "UninstallCertificate", exception);
|
||||
WriteErrorForException("UninstallInfisicalCertificateCmdlet", "UninstallCertificate", exception);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ThrowTerminatingForException("UpdateInfisicalEnvironmentCmdlet", "UpdateEnvironment", exception);
|
||||
WriteErrorForException("UpdateInfisicalEnvironmentCmdlet", "UpdateEnvironment", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ThrowTerminatingForException("UpdateInfisicalFolderCmdlet", "UpdateFolder", exception);
|
||||
WriteErrorForException("UpdateInfisicalFolderCmdlet", "UpdateFolder", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ThrowTerminatingForException("UpdateInfisicalOrganizationCmdlet", "UpdateOrganization", exception);
|
||||
WriteErrorForException("UpdateInfisicalOrganizationCmdlet", "UpdateOrganization", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ThrowTerminatingForException("UpdateInfisicalProjectCmdlet", "UpdateProject", exception);
|
||||
WriteErrorForException("UpdateInfisicalProjectCmdlet", "UpdateProject", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,10 +56,12 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
Secrets = InfisicalBulkSecretConverter.ToUpdateItems(Secrets)
|
||||
};
|
||||
|
||||
Logger.Information("Update-InfisicalSecret", string.Concat("Bulk-updating ", Secrets.Length.ToString(System.Globalization.CultureInfo.InvariantCulture), " secret(s)."));
|
||||
InfisicalSecretsClient bulkClient = new InfisicalSecretsClient(HttpClient, Logger);
|
||||
InfisicalSecret[] updated = bulkClient.UpdateBatch(connection, bulk);
|
||||
if (updated != null)
|
||||
{
|
||||
Logger.Information("Update-InfisicalSecret", string.Concat("Server returned ", updated.Length.ToString(System.Globalization.CultureInfo.InvariantCulture), " updated secret(s)."));
|
||||
foreach (InfisicalSecret secret in updated) { WriteObject(secret); }
|
||||
}
|
||||
|
||||
@@ -96,7 +98,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ThrowTerminatingForException("UpdateInfisicalSecretCmdlet", "UpdateSecret", exception);
|
||||
WriteErrorForException("UpdateInfisicalSecretCmdlet", "UpdateSecret", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ThrowTerminatingForException("UpdateInfisicalSubOrganizationCmdlet", "UpdateSubOrganization", exception);
|
||||
WriteErrorForException("UpdateInfisicalSubOrganizationCmdlet", "UpdateSubOrganization", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ThrowTerminatingForException("UpdateInfisicalTagCmdlet", "UpdateTag", exception);
|
||||
WriteErrorForException("UpdateInfisicalTagCmdlet", "UpdateTag", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,22 +62,7 @@ namespace PSInfisicalAPI.Cmdlets
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ThrowTerminatingForException(Component, "WriteScepMdmProfileToWmi", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsElevated()
|
||||
{
|
||||
try
|
||||
{
|
||||
Collection<PSObject> results = InvokeCommand.InvokeScript("[bool]([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)");
|
||||
if (results == null || results.Count == 0 || results[0] == null || results[0].BaseObject == null) { return false; }
|
||||
return Convert.ToBoolean(results[0].BaseObject, CultureInfo.InvariantCulture);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Verbose(Component, string.Concat("Elevation check failed; assuming non-elevated. ", ex.Message));
|
||||
return false;
|
||||
WriteErrorForException(Component, "WriteScepMdmProfileToWmi", exception);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
using System;
|
||||
|
||||
namespace PSInfisicalAPI.Common
|
||||
{
|
||||
public static class InfisicalPrefix
|
||||
{
|
||||
public static string Apply(string original, string prefix, bool force)
|
||||
{
|
||||
if (string.IsNullOrEmpty(prefix)) { return original ?? string.Empty; }
|
||||
if (original == null) { return prefix; }
|
||||
if (!force && original.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) { return original; }
|
||||
return string.Concat(prefix, original);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,13 @@ namespace PSInfisicalAPI.Errors
|
||||
public InfisicalExportException(string message, Exception innerException) : base(message, innerException) { }
|
||||
}
|
||||
|
||||
public class InfisicalImportException : InfisicalException
|
||||
{
|
||||
public InfisicalImportException() { }
|
||||
public InfisicalImportException(string message) : base(message) { }
|
||||
public InfisicalImportException(string message, Exception innerException) : base(message, innerException) { }
|
||||
}
|
||||
|
||||
public class InfisicalConfigurationException : InfisicalException
|
||||
{
|
||||
public InfisicalConfigurationException() { }
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using PSInfisicalAPI.Errors;
|
||||
|
||||
namespace PSInfisicalAPI.Imports
|
||||
{
|
||||
public sealed class EnvInfisicalImporter : IInfisicalImporter
|
||||
{
|
||||
public IList<KeyValuePair<string, string>> Import(FileInfo path)
|
||||
{
|
||||
if (path == null) { throw new InfisicalImportException("Path is required for ENV import."); }
|
||||
|
||||
List<KeyValuePair<string, string>> result = new List<KeyValuePair<string, string>>();
|
||||
string[] lines = File.ReadAllLines(path.FullName);
|
||||
|
||||
foreach (string raw in lines)
|
||||
{
|
||||
if (raw == null) { continue; }
|
||||
string line = raw.Trim();
|
||||
if (line.Length == 0) { continue; }
|
||||
if (line[0] == '#') { continue; }
|
||||
|
||||
int idx = line.IndexOf('=');
|
||||
if (idx <= 0) { continue; }
|
||||
|
||||
string key = line.Substring(0, idx).Trim();
|
||||
string value = line.Substring(idx + 1);
|
||||
if (key.Length == 0) { continue; }
|
||||
|
||||
result.Add(new KeyValuePair<string, string>(key, value));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
|
||||
namespace PSInfisicalAPI.Imports
|
||||
{
|
||||
public interface IInfisicalImporter
|
||||
{
|
||||
IList<KeyValuePair<string, string>> Import(FileInfo path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using PSInfisicalAPI.Errors;
|
||||
using PSInfisicalAPI.Models;
|
||||
|
||||
namespace PSInfisicalAPI.Imports
|
||||
{
|
||||
public static class InfisicalImporterFactory
|
||||
{
|
||||
public static IInfisicalImporter Create(InfisicalImportFormat format)
|
||||
{
|
||||
switch (format)
|
||||
{
|
||||
case InfisicalImportFormat.Json: return new JsonInfisicalImporter();
|
||||
case InfisicalImportFormat.Yaml: return new YamlInfisicalImporter();
|
||||
case InfisicalImportFormat.Env: return new EnvInfisicalImporter();
|
||||
case InfisicalImportFormat.Xml: return new XmlInfisicalImporter();
|
||||
default: throw new InfisicalImportException(string.Concat("Unsupported import format: ", format.ToString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using PSInfisicalAPI.Errors;
|
||||
|
||||
namespace PSInfisicalAPI.Imports
|
||||
{
|
||||
public sealed class JsonInfisicalImporter : IInfisicalImporter
|
||||
{
|
||||
public IList<KeyValuePair<string, string>> Import(FileInfo path)
|
||||
{
|
||||
if (path == null) { throw new InfisicalImportException("Path is required for JSON import."); }
|
||||
|
||||
List<KeyValuePair<string, string>> result = new List<KeyValuePair<string, string>>();
|
||||
string text = File.ReadAllText(path.FullName);
|
||||
JToken root = JToken.Parse(text);
|
||||
|
||||
if (root.Type == JTokenType.Array)
|
||||
{
|
||||
foreach (JToken item in (JArray)root)
|
||||
{
|
||||
if (item == null || item.Type != JTokenType.Object) { continue; }
|
||||
string key = ReadString((JObject)item, "SecretName") ?? ReadString((JObject)item, "secretName");
|
||||
string value = ReadString((JObject)item, "SecretValue") ?? ReadString((JObject)item, "secretValue");
|
||||
if (string.IsNullOrEmpty(key)) { continue; }
|
||||
result.Add(new KeyValuePair<string, string>(key, value ?? string.Empty));
|
||||
}
|
||||
}
|
||||
else if (root.Type == JTokenType.Object)
|
||||
{
|
||||
foreach (JProperty prop in ((JObject)root).Properties())
|
||||
{
|
||||
if (prop == null || string.IsNullOrEmpty(prop.Name)) { continue; }
|
||||
string value = prop.Value != null && prop.Value.Type != JTokenType.Null ? prop.Value.ToString() : string.Empty;
|
||||
result.Add(new KeyValuePair<string, string>(prop.Name, value));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InfisicalImportException("JSON import expects an array of secret objects or a flat key/value object.");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string ReadString(JObject obj, string name)
|
||||
{
|
||||
JToken token;
|
||||
if (!obj.TryGetValue(name, out token) || token == null || token.Type == JTokenType.Null) { return null; }
|
||||
return token.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Xml;
|
||||
using PSInfisicalAPI.Errors;
|
||||
|
||||
namespace PSInfisicalAPI.Imports
|
||||
{
|
||||
public sealed class XmlInfisicalImporter : IInfisicalImporter
|
||||
{
|
||||
public IList<KeyValuePair<string, string>> Import(FileInfo path)
|
||||
{
|
||||
if (path == null) { throw new InfisicalImportException("Path is required for XML import."); }
|
||||
|
||||
List<KeyValuePair<string, string>> result = new List<KeyValuePair<string, string>>();
|
||||
XmlDocument document = new XmlDocument();
|
||||
document.Load(path.FullName);
|
||||
|
||||
XmlNode root = document.DocumentElement;
|
||||
if (root == null || !string.Equals(root.LocalName, "Secrets", System.StringComparison.Ordinal))
|
||||
{
|
||||
throw new InfisicalImportException("XML import expects a root <Secrets> element.");
|
||||
}
|
||||
|
||||
foreach (XmlNode node in root.ChildNodes)
|
||||
{
|
||||
if (node == null || node.NodeType != XmlNodeType.Element) { continue; }
|
||||
if (!string.Equals(node.LocalName, "Secret", System.StringComparison.Ordinal)) { continue; }
|
||||
|
||||
string key = ReadChild(node, "SecretName");
|
||||
string value = ReadChild(node, "SecretValue");
|
||||
if (string.IsNullOrEmpty(key)) { continue; }
|
||||
result.Add(new KeyValuePair<string, string>(key, value ?? string.Empty));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string ReadChild(XmlNode parent, string name)
|
||||
{
|
||||
foreach (XmlNode child in parent.ChildNodes)
|
||||
{
|
||||
if (child == null || child.NodeType != XmlNodeType.Element) { continue; }
|
||||
if (string.Equals(child.LocalName, name, System.StringComparison.Ordinal))
|
||||
{
|
||||
return child.InnerText;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using PSInfisicalAPI.Errors;
|
||||
using YamlDotNet.Serialization;
|
||||
|
||||
namespace PSInfisicalAPI.Imports
|
||||
{
|
||||
public sealed class YamlInfisicalImporter : IInfisicalImporter
|
||||
{
|
||||
public IList<KeyValuePair<string, string>> Import(FileInfo path)
|
||||
{
|
||||
if (path == null) { throw new InfisicalImportException("Path is required for YAML import."); }
|
||||
|
||||
List<KeyValuePair<string, string>> result = new List<KeyValuePair<string, string>>();
|
||||
string text = File.ReadAllText(path.FullName);
|
||||
|
||||
IDeserializer deserializer = new DeserializerBuilder().Build();
|
||||
object root = deserializer.Deserialize<object>(text);
|
||||
if (root == null) { return result; }
|
||||
|
||||
IDictionary rootMap = root as IDictionary;
|
||||
if (rootMap != null && rootMap.Contains("Secrets"))
|
||||
{
|
||||
IList entries = rootMap["Secrets"] as IList;
|
||||
if (entries != null)
|
||||
{
|
||||
foreach (object entry in entries)
|
||||
{
|
||||
IDictionary map = entry as IDictionary;
|
||||
if (map == null) { continue; }
|
||||
string key = AsString(map["SecretName"]);
|
||||
string value = AsString(map["SecretValue"]);
|
||||
if (string.IsNullOrEmpty(key)) { continue; }
|
||||
result.Add(new KeyValuePair<string, string>(key, value ?? string.Empty));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
if (rootMap != null)
|
||||
{
|
||||
foreach (DictionaryEntry kvp in rootMap)
|
||||
{
|
||||
string key = AsString(kvp.Key);
|
||||
if (string.IsNullOrEmpty(key)) { continue; }
|
||||
result.Add(new KeyValuePair<string, string>(key, AsString(kvp.Value) ?? string.Empty));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
throw new InfisicalImportException("YAML import expects a 'Secrets' root list or a flat key/value mapping.");
|
||||
}
|
||||
|
||||
private static string AsString(object value)
|
||||
{
|
||||
if (value == null) { return null; }
|
||||
return value.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,10 +36,17 @@ namespace PSInfisicalAPI.Logging
|
||||
_cmdlet.WriteWarning(line);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Error-level lines are diagnostic breadcrumbs: every call site in this module logs one and then throws,
|
||||
/// so the failure itself always reaches the caller as an ErrorRecord carrying the same detail. Emitting
|
||||
/// them on the warning stream duplicated that failure eight lines deep and put it under -WarningAction
|
||||
/// instead of -ErrorAction. They belong on the verbose stream, where -Verbose opts into the trail and the
|
||||
/// ErrorRecord remains the single authority on what failed.
|
||||
/// </summary>
|
||||
public void Error(string component, string message)
|
||||
{
|
||||
string line = InfisicalLogFormatter.FormatNow(InfisicalLogLevel.Error, component, message);
|
||||
_cmdlet.WriteWarning(line);
|
||||
_cmdlet.WriteVerbose(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace PSInfisicalAPI.Models
|
||||
{
|
||||
public enum InfisicalImportFormat
|
||||
{
|
||||
Json,
|
||||
Yaml,
|
||||
Env,
|
||||
Xml
|
||||
}
|
||||
}
|
||||
@@ -32,9 +32,29 @@ namespace PSInfisicalAPI.Pki
|
||||
if (!string.IsNullOrEmpty(organizationalUnit)) { result.OrganizationalUnit = organizationalUnit; }
|
||||
if (!string.IsNullOrEmpty(emailAddress)) { result.EmailAddress = emailAddress; }
|
||||
|
||||
result.CommonName = NormalizeCommonName(result.CommonName);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reduces a caller-supplied common name to the bare CN value. Callers commonly pass the RDN form
|
||||
/// ("CN=HOST") or a full DN ("CN=HOST,OU=IT"); using either verbatim produces a doubled "CN=CN=HOST"
|
||||
/// subject and a bogus "CN=HOST" DNS SAN, since the CSR builder adds the CN= prefix itself.
|
||||
/// </summary>
|
||||
public static string NormalizeCommonName(string commonName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(commonName)) { return commonName; }
|
||||
|
||||
string value = commonName.Trim();
|
||||
if (!value.StartsWith("CN=", StringComparison.OrdinalIgnoreCase)) { return value; }
|
||||
|
||||
value = value.Substring(3);
|
||||
int separator = value.IndexOf(',');
|
||||
if (separator >= 0) { value = value.Substring(0, separator); }
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
|
||||
public static string ResolveLocalFqdn()
|
||||
{
|
||||
try
|
||||
@@ -76,6 +96,7 @@ namespace PSInfisicalAPI.Pki
|
||||
store.RemoveRange(existing);
|
||||
}
|
||||
|
||||
WarnIfInteractiveTrustPromptExpected(storeName, storeLocation, logger, component);
|
||||
store.Add(cert);
|
||||
logger.Information(component, string.Concat("Installed certificate to ", target, "."));
|
||||
}
|
||||
@@ -85,6 +106,24 @@ namespace PSInfisicalAPI.Pki
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adding to the per-user Root store makes Windows raise a modal trust confirmation dialog, and
|
||||
/// X509Store.Add blocks until it is answered. When that dialog is hidden or the session is
|
||||
/// non-interactive the caller just sees an unexplained hang, so say so before blocking. LocalMachine\Root
|
||||
/// does not prompt, because writing it already required elevation.
|
||||
/// </summary>
|
||||
public static void WarnIfInteractiveTrustPromptExpected(StoreName storeName, StoreLocation storeLocation, IInfisicalLogger logger, string component)
|
||||
{
|
||||
if (storeName != StoreName.Root || storeLocation != StoreLocation.CurrentUser) { return; }
|
||||
if (logger == null) { return; }
|
||||
|
||||
logger.Warning(component, string.Concat(
|
||||
"Installing a root certificate into CurrentUser\\Root. Windows will display a security confirmation ",
|
||||
"dialog and this call cannot continue until it is answered - if no dialog is visible, check for it ",
|
||||
"behind the console window or on another desktop. Run elevated (or pass ",
|
||||
"-StoreLocation LocalMachine) to install machine-wide without a prompt."));
|
||||
}
|
||||
|
||||
public static void InstallChain(InfisicalSignedCertificate signed, StoreLocation storeLocation, bool force, IInfisicalLogger logger, string component)
|
||||
{
|
||||
List<X509Certificate2> chainCerts = CollectChainCertificates(signed);
|
||||
@@ -102,11 +141,93 @@ namespace PSInfisicalAPI.Pki
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Routes a chain member by what it is rather than by any caller preference: a self-signed certificate is
|
||||
/// a root and belongs in the trusted-root store, anything else is a subordinate CA and belongs in the
|
||||
/// intermediate store. Only the leaf honours -StoreName.
|
||||
/// </summary>
|
||||
public static StoreName GetChainCertificateTargetStore(X509Certificate2 cert)
|
||||
{
|
||||
return IsSelfSigned(cert) ? StoreName.Root : StoreName.CertificateAuthority;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the Windows friendly name shown in certmgr. Not available on every platform, and never important
|
||||
/// enough to fail an otherwise successful issuance.
|
||||
/// </summary>
|
||||
public static void ApplyFriendlyName(X509Certificate2 cert, string friendlyName, IInfisicalLogger logger, string component)
|
||||
{
|
||||
if (cert == null || string.IsNullOrEmpty(friendlyName)) { return; }
|
||||
|
||||
try
|
||||
{
|
||||
cert.FriendlyName = friendlyName;
|
||||
if (logger != null) { logger.Verbose(component, string.Concat("Set certificate friendly name to '", friendlyName, "'.")); }
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
if (logger != null) { logger.Verbose(component, string.Concat("Could not set the certificate friendly name (continuing): ", exception.Message)); }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms the freshly installed certificate actually chains to a trusted root using the machine's own
|
||||
/// stores. A chain that stops short is what surfaces in Windows as "The issuer of this certificate could
|
||||
/// not be found", so name the missing issuer rather than letting it be discovered later.
|
||||
/// </summary>
|
||||
public static void VerifyInstalledChain(X509Certificate2 leaf, IInfisicalLogger logger, string component)
|
||||
{
|
||||
if (leaf == null || logger == null) { return; }
|
||||
|
||||
try
|
||||
{
|
||||
using (X509Chain chain = new X509Chain())
|
||||
{
|
||||
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
|
||||
if (chain.Build(leaf))
|
||||
{
|
||||
logger.Information(component, string.Concat("Certificate chain verified to a trusted root (", chain.ChainElements.Count.ToString(System.Globalization.CultureInfo.InvariantCulture), " elements)."));
|
||||
return;
|
||||
}
|
||||
|
||||
bool partialChain = false;
|
||||
List<string> problems = new List<string>();
|
||||
foreach (X509ChainStatus status in chain.ChainStatus)
|
||||
{
|
||||
if (status.Status == X509ChainStatusFlags.NoError) { continue; }
|
||||
if (status.Status == X509ChainStatusFlags.PartialChain || status.Status == X509ChainStatusFlags.UntrustedRoot) { partialChain = true; }
|
||||
problems.Add(status.Status.ToString());
|
||||
}
|
||||
|
||||
string topSubject = leaf.Subject;
|
||||
string missingIssuer = leaf.Issuer;
|
||||
if (chain.ChainElements.Count > 0)
|
||||
{
|
||||
X509Certificate2 top = chain.ChainElements[chain.ChainElements.Count - 1].Certificate;
|
||||
topSubject = top.Subject;
|
||||
missingIssuer = top.Issuer;
|
||||
}
|
||||
|
||||
string detail = problems.Count > 0 ? string.Join(", ", problems.ToArray()) : "unknown";
|
||||
if (partialChain)
|
||||
{
|
||||
logger.Warning(component, string.Concat(
|
||||
"Certificate chain is incomplete (", detail, "). The highest certificate installed is '", topSubject,
|
||||
"', whose issuer '", missingIssuer, "' is not present in the trusted stores. Windows will report ",
|
||||
"\"The issuer of this certificate could not be found\" until that issuer is installed."));
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.Warning(component, string.Concat("Certificate chain did not validate (", detail, ")."));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.Verbose(component, string.Concat("Chain verification could not run (continuing): ", exception.Message));
|
||||
}
|
||||
}
|
||||
|
||||
public static X509KeyStorageFlags ResolveKeyStorageFlags(InfisicalPrivateKeyProtection protection, bool persistKey, bool machineKey)
|
||||
{
|
||||
X509KeyStorageFlags flags = X509KeyStorageFlags.DefaultKeySet;
|
||||
|
||||
@@ -25,6 +25,7 @@ namespace PSInfisicalAPI.Process
|
||||
public bool LogOutput { get; set; }
|
||||
public bool ContinueOnError { get; set; }
|
||||
public InfisicalSecret[] Secrets { get; set; }
|
||||
public string Prefix { get; set; }
|
||||
public string SecretsPrefix { get; set; }
|
||||
public bool ForceSecretsPrefix { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using PSInfisicalAPI.Common;
|
||||
using PSInfisicalAPI.Logging;
|
||||
using PSInfisicalAPI.Models;
|
||||
using PSInfisicalAPI.Security;
|
||||
@@ -23,7 +24,7 @@ namespace PSInfisicalAPI.Process
|
||||
|
||||
if (options.EnvironmentVariables != null && options.EnvironmentVariables.Count > 0)
|
||||
{
|
||||
Log(logger, string.Concat("Injecting ", options.EnvironmentVariables.Count, " explicit environment variable(s) into the process."));
|
||||
LogInformation(logger, string.Concat("Injecting ", options.EnvironmentVariables.Count, " explicit environment variable(s) into the process."));
|
||||
foreach (DictionaryEntry entry in options.EnvironmentVariables)
|
||||
{
|
||||
if (entry.Key == null) { continue; }
|
||||
@@ -35,11 +36,11 @@ namespace PSInfisicalAPI.Process
|
||||
|
||||
if (options.Secrets == null || options.Secrets.Length == 0) { return; }
|
||||
|
||||
Log(logger, string.Concat("Injecting ", options.Secrets.Length, " Infisical secret(s) into the process environment."));
|
||||
LogInformation(logger, string.Concat("Injecting ", options.Secrets.Length, " Infisical secret(s) into the process environment."));
|
||||
foreach (InfisicalSecret secret in options.Secrets)
|
||||
{
|
||||
if (secret == null || string.IsNullOrEmpty(secret.SecretName) || secret.SecretValue == null) { continue; }
|
||||
string name = string.IsNullOrEmpty(options.Prefix) ? secret.SecretName : string.Concat(options.Prefix, secret.SecretName);
|
||||
string name = InfisicalPrefix.Apply(secret.SecretName, options.SecretsPrefix, options.ForceSecretsPrefix);
|
||||
SecureStringUtility.UsePlainText(secret.SecretValue, plain =>
|
||||
{
|
||||
processEnv[name] = plain;
|
||||
@@ -192,5 +193,10 @@ namespace PSInfisicalAPI.Process
|
||||
{
|
||||
if (logger != null) { logger.Verbose(Component, message); }
|
||||
}
|
||||
|
||||
private static void LogInformation(IInfisicalLogger logger, string message)
|
||||
{
|
||||
if (logger != null) { logger.Information(Component, message); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,8 +75,8 @@ namespace PSInfisicalAPI.Secrets
|
||||
InfisicalSecretListResponseDto dto = _serializer.Deserialize<InfisicalSecretListResponseDto>(response.Body);
|
||||
response.Clear();
|
||||
|
||||
InfisicalSecret[] mapped = InfisicalSecretMapper.MapMany(dto != null ? dto.Secrets : null);
|
||||
_logger.Information(Component, "Infisical secrets retrieval was successful.");
|
||||
InfisicalSecret[] mapped = MergeListAndImports(dto);
|
||||
_logger.Information(Component, string.Concat("Infisical secrets retrieval was successful. Returned ", mapped.Length.ToString(CultureInfo.InvariantCulture), " secret(s)."));
|
||||
return mapped;
|
||||
}
|
||||
catch (Exception)
|
||||
@@ -465,6 +465,66 @@ namespace PSInfisicalAPI.Secrets
|
||||
}
|
||||
}
|
||||
|
||||
private InfisicalSecret[] MergeListAndImports(InfisicalSecretListResponseDto dto)
|
||||
{
|
||||
if (dto == null) { return Array.Empty<InfisicalSecret>(); }
|
||||
|
||||
InfisicalSecret[] local = InfisicalSecretMapper.MapMany(dto.Secrets);
|
||||
|
||||
if (dto.Imports == null || dto.Imports.Count == 0)
|
||||
{
|
||||
return local;
|
||||
}
|
||||
|
||||
Dictionary<string, InfisicalSecret> merged = new Dictionary<string, InfisicalSecret>(StringComparer.Ordinal);
|
||||
int importsTotal = 0;
|
||||
|
||||
foreach (InfisicalSecretImportDto import in dto.Imports)
|
||||
{
|
||||
if (import == null) { continue; }
|
||||
InfisicalSecret[] importedSecrets = InfisicalSecretMapper.MapMany(import.Secrets);
|
||||
importsTotal += importedSecrets.Length;
|
||||
|
||||
_logger.Information(Component, string.Concat(
|
||||
"Including ",
|
||||
importedSecrets.Length.ToString(CultureInfo.InvariantCulture),
|
||||
" secret(s) from import '",
|
||||
import.SecretPath ?? string.Empty,
|
||||
"' (environment='",
|
||||
import.Environment ?? string.Empty,
|
||||
"')."));
|
||||
|
||||
foreach (InfisicalSecret secret in importedSecrets)
|
||||
{
|
||||
if (secret == null || string.IsNullOrEmpty(secret.SecretName)) { continue; }
|
||||
merged[secret.SecretName] = secret;
|
||||
}
|
||||
}
|
||||
|
||||
int overrides = 0;
|
||||
foreach (InfisicalSecret secret in local)
|
||||
{
|
||||
if (secret == null || string.IsNullOrEmpty(secret.SecretName)) { continue; }
|
||||
if (merged.ContainsKey(secret.SecretName)) { overrides++; }
|
||||
merged[secret.SecretName] = secret;
|
||||
}
|
||||
|
||||
_logger.Information(Component, string.Concat(
|
||||
"Merged secrets: local=",
|
||||
local.Length.ToString(CultureInfo.InvariantCulture),
|
||||
", imports=",
|
||||
importsTotal.ToString(CultureInfo.InvariantCulture),
|
||||
", local-overrode-import=",
|
||||
overrides.ToString(CultureInfo.InvariantCulture),
|
||||
", final=",
|
||||
merged.Count.ToString(CultureInfo.InvariantCulture),
|
||||
"."));
|
||||
|
||||
InfisicalSecret[] result = new InfisicalSecret[merged.Count];
|
||||
merged.Values.CopyTo(result, 0);
|
||||
return result;
|
||||
}
|
||||
|
||||
private InfisicalHttpResponse SendWithVersionFallback(
|
||||
InfisicalConnection connection,
|
||||
string endpointName,
|
||||
|
||||
Reference in New Issue
Block a user