Private/CAVendorBatch.ps1

<#
.SYNOPSIS
    CA Manager - Rollout: batch-request vendor certificates (menu 17). Requires Modules\CACore.ps1,
    Modules\CATemplates.ps1 (Get-CAManualApprovalTemplates), Modules\CAInteractive.ps1
    (Test-SecureStringMatch), and Request-VPNCertCore.ps1 - dot-sourced by Modules\CATestSuite.ps1,
    which CA-Manager.ps1 loads first.
 
.DESCRIPTION
    2026-09-30, per the maintainer: vendor rollout - a tech (Luke) issues a PFX per vendor account in one
    sitting, the same submit -> one approval pass -> retrieve/export shape as the test-certificate
    suite (menu 11). Asked ONCE per run: the output folder and a run password (confirmed). Then, per
    certificate: the template (numbered list, blank keeps the last one used this run), the AD user,
    and the PFX password (Enter re-uses the run password, N sets one just for this PFX). Each request
    is submitted as soon as it's entered, so a failure shows next to the vendor it belongs to.
 
    Nothing is remembered between runs (per the maintainer) - not the folder, template, or passwords. A
    summary file in the output folder lists every PFX and which password it got (run or its own),
    never the passwords themselves.
#>


function Read-CAVendorTemplateChoice {
    <#
    .SYNOPSIS
        One template prompt. Lists -Templates numbered (the current one marked) and returns the
        chosen template object, 'Done', or 'Cancel'. Blank keeps -Current; a number picks another.
        'D' (done) is offered only once -CanFinish; 'B' (cancel) only while -CanCancel.
    #>

    param(
        [Parameter(Mandatory)][object[]]$Templates,
        $Current,
        [switch]$CanFinish,
        [switch]$CanCancel
    )
    while ($true) {
        Write-Host ""
        Write-Host " Manual-approval templates published on this CA:" -ForegroundColor Cyan
        for ($i = 0; $i -lt $Templates.Count; $i++) {
            $mark = if ($Current -and $Templates[$i].Cn -eq $Current.Cn) { ' (current)' } else { '' }
            Write-Host (" [{0}] {1}{2}" -f ($i + 1), $Templates[$i].DisplayName, $mark) -ForegroundColor Gray
        }
        $options = @()
        if ($Current) { $options += "blank to keep '$($Current.DisplayName)'" }
        $options += "1-$($Templates.Count) to select$(if ($Current) { ' another' })"
        if ($CanFinish) { $options += "D when done" }
        if ($CanCancel) { $options += "B to cancel" }
        $answer = ([string](Read-Host " Template ($($options -join ', '))")).Trim()

        if (-not $answer -and $Current) { return $Current }
        if ($CanFinish -and $answer -match '^(?i)d(one)?$') { return 'Done' }
        if ($CanCancel -and $answer -match '^(?i)b(ack)?$') { return 'Cancel' }
        $n = 0
        if ([int]::TryParse($answer, [ref]$n) -and $n -ge 1 -and $n -le $Templates.Count) { return $Templates[$n - 1] }
        Write-Host " Enter one of: $($options -join '; ')." -ForegroundColor Yellow
    }
}

function Read-CAVendorPfxPassword {
    <#
    .SYNOPSIS
        The per-PFX password step: Enter re-uses -RunPassword (the one set at the start of the run),
        N types a new one (confirmed) for this PFX only. Returns @{ Password; Own }.
    #>

    param([Parameter(Mandatory)][securestring]$RunPassword)
    while ($true) {
        $answer = ([string](Read-Host " PFX password: [Enter] re-use the password set at the beginning of the run, or N for a new one for this PFX")).Trim()
        if (-not $answer) { return [pscustomobject]@{ Password = $RunPassword; Own = $false } }
        if ($answer -match '^(?i)n(ew)?$') {
            while ($true) {
                $pw1 = Read-Host " New password for this PFX only" -AsSecureString
                $pw2 = Read-Host " Confirm it" -AsSecureString
                if (Test-SecureStringMatch $pw1 $pw2) { return [pscustomobject]@{ Password = $pw1; Own = $true } }
                Write-Host " Passwords did not match - try again." -ForegroundColor Yellow
            }
        }
        Write-Host " Press Enter to re-use the run password, or N for a new one." -ForegroundColor Yellow
    }
}

function Invoke-CAMenuVendorBatch {
    <#
    .SYNOPSIS
        Menu 17. Batch-request vendor certificates: template / user / password per PFX, one output
        folder and run password for the whole batch, one approval pass, then retrieve + export.
    .PARAMETER Status
        The Get-CAStatus object (for the issuance-readiness gate).
    .PARAMETER CAAnswers
        Optional; only Company_Name is read (summary file name).
    #>

    param(
        [Parameter(Mandatory)]$Status,
        $CAAnswers
    )

    Write-CAHeader "Batch-request vendor certificates"

    if (-not (Get-Command New-VPNCertRequest -ErrorAction SilentlyContinue)) {
        Write-Host "Request-VPNCertCore.ps1 was not found - cannot issue certs." -ForegroundColor Red
        Write-Host "(Expected next to CA-Manager.ps1 in a built CAManager.zip, or under" -ForegroundColor Gray
        Write-Host " IPSEC AIO\MiscTools\Tools\ when running from the repo.)" -ForegroundColor Gray
        Read-Host "Press Enter to return to the menu" | Out-Null
        return
    }

    # --- order gate: same as menu 11 - issued certs bake in whatever revocation URLs are live now ---
    if (-not $Status.IssuanceReady) {
        Write-Host "Issuance readiness is NOT READY." -ForegroundColor Yellow
        Write-Host "Any certificate issued now bakes in whatever AIA / CDP / OCSP URLs the CA currently" -ForegroundColor Yellow
        Write-Host "has - which won't be the App Proxy URLs yet, so vendor certs may fail revocation" -ForegroundColor Yellow
        Write-Host "checks later. Finish Setup (8, 9) first unless you know why not." -ForegroundColor Yellow
        Write-Host ""
        $ack = Read-Host "Type YES (all caps) to issue vendor certs anyway, anything else to cancel"
        if ($ack -cne 'YES') { Write-Host "Cancelled." -ForegroundColor Gray; Read-Host "Press Enter" | Out-Null; return }
    }

    $templates = @(Get-CAManualApprovalTemplates)
    if (-not $templates.Count) {
        Write-Host "No published templates with 'Manual approval' (PEND_ALL_REQUESTS) found on this CA." -ForegroundColor Yellow
        Write-Host "Run menu 4 (or 16) first to create/publish a manual template." -ForegroundColor Yellow
        Read-Host "Press Enter to return to the menu" | Out-Null
        return
    }

    Write-Host "Asked once for the whole run: where the PFX files go, and a password they share." -ForegroundColor Cyan
    Write-Host "Then, per vendor: template, AD user, and whether that PFX uses the run password." -ForegroundColor Cyan
    Write-Host ""

    # --- output folder (once) ---
    $outDir = ([string](Read-Host "Output folder for every PFX in this run [C:\Admin\VendorPFX], or 'B' to cancel")).Trim().Trim('"')
    if ($outDir -match '^(?i)b(ack)?$') { Write-Host " Cancelled." -ForegroundColor Gray; return }
    if (-not $outDir) { $outDir = 'C:\Admin\VendorPFX' }
    if (-not (Test-Path $outDir)) { New-Item -ItemType Directory -Path $outDir -Force | Out-Null }

    # --- run password (once, confirmed) - 'B' at the first prompt cancels, same as menu 11 ---
    while ($true) {
        $pw1 = Read-Host "Password for this run's PFX files (or 'B' to cancel)" -AsSecureString
        if (([System.Net.NetworkCredential]::new('', $pw1).Password) -match '^(?i)b(ack)?$') { Write-Host " Cancelled." -ForegroundColor Gray; return }
        $pw2 = Read-Host "Confirm it" -AsSecureString
        if (Test-SecureStringMatch $pw1 $pw2) { $runPassword = $pw1; break }
        Write-Host "Passwords did not match - try again." -ForegroundColor Yellow
    }

    # --- per vendor: template -> user -> password -> submit ------------------------------------------
    $batch = New-Object System.Collections.Generic.List[object]
    $currentTemplate = $null
    while ($true) {
        Write-Host ""
        Write-Host ("=== Certificate #{0} ===" -f ($batch.Count + 1)) -ForegroundColor White
        # Cancel only while nothing is submitted yet: after that, requests are already pending on the
        # CA and the tech needs the approval pass below, so 'D' (done) is the way out.
        $pick = Read-CAVendorTemplateChoice -Templates $templates -Current $currentTemplate -CanFinish:($batch.Count -gt 0) -CanCancel:($batch.Count -eq 0)
        if ($pick -eq 'Cancel') { Write-Host " Cancelled - nothing was requested." -ForegroundColor Gray; return }
        if ($pick -eq 'Done') { break }
        $currentTemplate = $pick

        $user = $null
        while ($true) {
            $seed = ([string](Read-Host " Vendor's AD username to search for (blank to go back to the template)")).Trim()
            if (-not $seed) { break }
            $user = Find-VPNCertADUser -SearchTerm $seed -ReturnNullOnNoMatch
            if ($user) { break }
        }
        if (-not $user) { continue }
        Write-Host (" -> {0} ({1})" -f $user.SamAccountName, $user.UserPrincipalName) -ForegroundColor Gray

        $pfxPassword = Read-CAVendorPfxPassword -RunPassword $runPassword

        Write-Host (" Submitting {0} for {1}..." -f $currentTemplate.DisplayName, $user.SamAccountName) -ForegroundColor Cyan
        $request = New-VPNCertRequest -ADUser $user -TemplateName $currentTemplate.Cn
        if (-not $request) {
            Write-Host " Request failed (see above) - not added to the batch. Pick the template again to retry." -ForegroundColor Yellow
            continue
        }
        $batch.Add([pscustomobject]@{
            Template = $currentTemplate.DisplayName; TemplateCn = $currentTemplate.Cn; User = $user.SamAccountName
            Subject = $request.Request.Subject; Request = $request; Password = $pfxPassword.Password; OwnPassword = $pfxPassword.Own
            Status = 'Submitted'; PfxPath = $null; CerPath = $null; Thumbprint = $null; SerialNumber = $null
        })
        Write-Host (" Added. {0} request(s) in this batch." -f $batch.Count) -ForegroundColor Green
    }

    # --- one approval pass, then retrieve + export; unissued ones can be retried -------------------
    $stems = @{}
    foreach ($row in $batch) {
        $stem = "{0}_{1}_{2}" -f ($row.User -replace '[^A-Za-z0-9]', ''), ($row.TemplateCn -replace '[^A-Za-z0-9]', ''), (Get-Date -Format 'yyyyMMdd')
        # The same vendor twice on one template in one run would otherwise overwrite the first PFX.
        $n = 1; $unique = $stem
        while ($stems.ContainsKey($unique)) { $n++; $unique = "${stem}_$n" }
        $stems[$unique] = $true
        $row | Add-Member -NotePropertyName Stem -NotePropertyValue $unique
    }
    # .ToArray(), not @($batch): wrapping a generic List in @() throws "Argument types do not match" on PS 5.1.
    $waiting = $batch.ToArray()
    $firstPass = $true
    while ($waiting.Count) {
        Write-Host ""
        if ($firstPass) {
            Write-Host "==================== APPROVE ALL OF THESE ====================" -ForegroundColor Yellow
        } else {
            Write-Host "==================== STILL NOT ISSUED ====================" -ForegroundColor Yellow
        }
        Write-Host (" {0,-32} {1,-22} {2}" -f 'Template', 'User', 'Requested subject') -ForegroundColor Gray
        foreach ($r in $waiting) { Write-Host (" {0,-32} {1,-22} {2}" -f $r.Template, $r.User, $r.Subject) -ForegroundColor Gray }
        Write-Host ""
        Write-Host " certsrv.msc -> Pending Requests -> select all -> right-click -> All Tasks -> Issue" -ForegroundColor Yellow
        if ($firstPass) {
            Read-Host " Press Enter once ALL $($waiting.Count) request(s) are issued" | Out-Null
        } else {
            # One prompt per retry round: it is both "they're approved now" and the way out.
            $again = ([string](Read-Host " Press Enter once they're issued to retry, or S to skip them")).Trim()
            if ($again -match '^(?i)s(kip)?$') { break }
        }
        $firstPass = $false

        foreach ($row in $waiting) {
            $res = Complete-VPNCertRequest -RequestObject $row.Request -OutputDir $outDir -PfxPassword $row.Password -FileNameStem $row.Stem
            $row.Status = $res.Status; $row.PfxPath = $res.PfxPath; $row.CerPath = $res.CerPath
            $row.Thumbprint = $res.Thumbprint; $row.SerialNumber = $res.SerialNumber
        }
        # Only still-pending requests come back round; a failed retrieval or export is final (summary).
        $waiting = @($batch.ToArray() | Where-Object { -not $_.PfxPath -and $_.Status -in @('Pending', 'Submitted') })
    }

    # --- summary file (no passwords - only which one each PFX got) --------------------------------
    $company = if ($CAAnswers -and $CAAnswers.PSObject.Properties['Company_Name'] -and -not [string]::IsNullOrWhiteSpace($CAAnswers.Company_Name)) { $CAAnswers.Company_Name }
               elseif ($Status.CACommonName) { $Status.CACommonName } else { 'CA' }
    $summaryPath = Join-Path $outDir ("{0}_VendorCerts_{1}.txt" -f ($company -replace '[^A-Za-z0-9]', ''), (Get-Date -Format 'yyyyMMdd_HHmmss'))
    $lines = New-Object System.Collections.Generic.List[string]
    $lines.Add("Vendor certificates - $company")
    $lines.Add("Generated: $(Get-Date)")
    $lines.Add("Output : $outDir")
    $lines.Add("Password : 'run' = the password set at the start of this run; 'own' = set for that PFX alone.")
    $lines.Add("")
    $lines.Add(("{0,-32} {1,-22} {2,-9} {3,-8} {4}" -f 'Template', 'User', 'Status', 'Password', 'PFX'))
    $lines.Add(("-" * 110))
    foreach ($r in $batch) {
        $lines.Add(("{0,-32} {1,-22} {2,-9} {3,-8} {4}" -f $r.Template, $r.User, $r.Status, $(if ($r.OwnPassword) { 'own' } else { 'run' }), $(if ($r.PfxPath) { $r.PfxPath } else { '(not exported)' })))
        if ($r.SerialNumber) { $lines.Add(("{0,-32} {1,-22} serial {2} thumbprint {3}" -f '', '', $r.SerialNumber, $r.Thumbprint)) }
        if ($r.CerPath) { $lines.Add(("{0,-32} {1,-22} cer {2}" -f '', '', $r.CerPath)) }
    }
    Set-Content -Path $summaryPath -Value $lines -Encoding UTF8

    Write-Host ""
    Write-Host "Summary written: $summaryPath" -ForegroundColor Green
    $issued = @($batch | Where-Object { $_.PfxPath }).Count
    $color = if ($issued -eq $batch.Count) { 'Green' } elseif ($issued) { 'Yellow' } else { 'Red' }
    Write-Host ("Exported {0} / {1} vendor PFX file(s) to {2}." -f $issued, $batch.Count, $outDir) -ForegroundColor $color
    Read-Host "Press Enter to return to the menu" | Out-Null
}