Public/Sync-PWSHCertutilCASchema.ps1

function Sync-PWSHCertutilCASchema {
    <#
    .SYNOPSIS
        Discovers the CA database schema from CAs in a profile and optionally updates the configuration.
    .DESCRIPTION
        Connects to each CA defined in the profile via WinRM and runs certutil -schema to retrieve
        all available database column names. Returns one object per CA with Profile, CAServer, and
        AvailableFields properties.

        When -UpdateConfig is specified, the cmdlet computes the intersection of fields available
        across ALL queried CAs, validates the profile's certutilView.out arrays against that set,
        removes any field names that do not exist in the schema, and writes the corrected
        configuration back to Posh-Certutil.json. This is the correct first step to take after
        pointing the module at a new CA environment to ensure the configured field names match the
        actual CA database schema. Supports -WhatIf.

        When multiple CAs are queried and their schemas differ (e.g. due to CA version drift or
        differing configurations), a warning is emitted for each conflicting field identifying
        which CAs have it and which do not. The SchemaConflicts property on each result object
        maps every such field to the list of CAs that expose it.
    .PARAMETER Profile
        The configuration profile to use.
    .PARAMETER CAFqdn
        Optional. Queries only this CA instead of all CAs in the profile. When -UpdateConfig is
        also specified, validation uses only this CA's schema.
    .PARAMETER UpdateConfig
        When present, validates the profile's certutilView.out field lists against the discovered
        schema and writes corrections to Posh-Certutil.json.
    .PARAMETER Credential
        Optional PSCredential for WinRM. Defaults to current user.
    .EXAMPLE
        Sync-PWSHCertutilCASchema -Profile 'prod-pki'
        Discovers and returns the schema from every CA in 'prod-pki' without modifying the config.
    .EXAMPLE
        Sync-PWSHCertutilCASchema -Profile 'prod-pki' -UpdateConfig
        Discovers the schema and removes any out field names that do not exist in the CA database.
    .EXAMPLE
        Sync-PWSHCertutilCASchema -Profile 'prod-pki' -UpdateConfig -WhatIf
        Shows what changes would be made to the config without writing them.
    .OUTPUTS
        PSCustomObject[]. One object per CA with Profile, CAServer, AvailableFields, FieldCount,
        SchemaConflicts, ValidatedOut, RemovedFields, and ConfigUpdated properties.
        SchemaConflicts is a PSCustomObject whose properties are field names that are not present
        on every queried CA; each property value is the array of CA FQDNs that expose that field.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory, Position = 0)]
        [string] $Profile,

        [Parameter()]
        [string] $CAFqdn,

        [Parameter()]
        [switch] $UpdateConfig,

        [Parameter()]
        [pscredential] $Credential
    )

    $config        = Read-ConfigFile
    $profileConfig = Get-ProfileConfig -Config $config -ProfileName $Profile

    $cas = if ($PSBoundParameters.ContainsKey('CAFqdn')) {
        $found = $profileConfig.cas | Where-Object { $_.fqdn -eq $CAFqdn }
        if (-not $found) { throw "CA '$CAFqdn' is not defined in profile '$Profile'." }
        $found
    } else { $profileConfig.cas }

    # Collect schema from each CA
    $schemaPerCA = [System.Collections.Generic.List[PSCustomObject]]::new()

    foreach ($ca in $cas) {
        try {
            $sessionArgs = @{ CAFqdn = $ca.fqdn; RemotingConfig = $profileConfig.remoting }
            if ($PSBoundParameters.ContainsKey('Credential')) { $sessionArgs['Credential'] = $Credential }
            $session = Get-CASession @sessionArgs

            $rawOutput = Invoke-CertutilSchema -Session $session
            $fields    = @(ConvertFrom-CertutilSchema -RawOutput $rawOutput)

            $schemaPerCA.Add([PSCustomObject]@{ CAFqdn = $ca.fqdn; Fields = $fields })
            Write-Verbose "CA '$($ca.fqdn)': $($fields.Count) schema fields discovered."
        } catch {
            Write-Error "Failed to retrieve schema from '$($ca.fqdn)': $_"
        }
    }

    if ($schemaPerCA.Count -eq 0) {
        Write-Error "No schema data retrieved from any CA in profile '$Profile'."
        return
    }

    # Intersection of fields available on ALL queried CAs ensures config works across all of them
    $intersection = $schemaPerCA[0].Fields
    foreach ($entry in @($schemaPerCA)[1..($schemaPerCA.Count - 1)]) {
        $intersection = @($intersection | Where-Object { $entry.Fields -contains $_ })
    }

    # Detect fields that exist on some CAs but not all — these indicate schema version drift
    $conflictMap = @{}
    if ($schemaPerCA.Count -gt 1) {
        $allFields = @($schemaPerCA | ForEach-Object { $_.Fields } | Sort-Object -Unique)
        foreach ($field in $allFields) {
            $casWithField = @($schemaPerCA |
                Where-Object { $_.Fields -contains $field } |
                ForEach-Object { $_.CAFqdn })
            if ($casWithField.Count -lt $schemaPerCA.Count) {
                $conflictMap[$field] = $casWithField
            }
        }
        foreach ($field in ($conflictMap.Keys | Sort-Object)) {
            $hasIt   = $conflictMap[$field] -join ', '
            $missing = ($schemaPerCA |
                Where-Object { $_.Fields -notcontains $field } |
                ForEach-Object { $_.CAFqdn }) -join ', '
            Write-Warning "Schema mismatch: field '$field' exists on [$hasIt] but not on [$missing]. It will be excluded from the validated field set for this profile."
        }
    }

    # Validate current out arrays against the intersection
    $operations    = @('issuedCerts', 'revokedCerts', 'expiringCerts', 'search')
    $validatedOut  = [ordered]@{}
    $removedFields = [ordered]@{}

    foreach ($op in $operations) {
        $currentFields = $profileConfig.certutilView.out.$op
        if ($null -eq $currentFields) {
            $validatedOut[$op]  = @()
            $removedFields[$op] = @()
            continue
        }
        $validatedOut[$op]  = @($currentFields | Where-Object { $intersection -contains $_ })
        $removedFields[$op] = @($currentFields | Where-Object { $intersection -notcontains $_ })
    }

    $configUpdated = $false

    if ($UpdateConfig) {
        $target = "certutilView.out field lists for profile '$Profile'"
        if ($PSCmdlet.ShouldProcess($target, 'Remove schema-invalid fields')) {
            foreach ($op in $operations) {
                if ($null -ne $profileConfig.certutilView.out.$op) {
                    $profileConfig.certutilView.out |
                        Add-Member -MemberType NoteProperty -Name $op -Value $validatedOut[$op] -Force
                }
            }

            # Build field name map from a probe query against the first CA
            try {
                $probeCA   = $schemaPerCA[0].CAFqdn
                $probeArgs = @{ CAFqdn = $probeCA; RemotingConfig = $profileConfig.remoting }
                if ($PSBoundParameters.ContainsKey('Credential')) { $probeArgs['Credential'] = $Credential }
                $probeSession = Get-CASession @probeArgs

                $allCanonical = [System.Collections.Generic.List[string]]::new()
                foreach ($op in $operations) {
                    $fields = $profileConfig.certutilView.out.$op
                    if ($fields) {
                        foreach ($f in $fields) {
                            if (-not $allCanonical.Contains($f)) { $allCanonical.Add($f) }
                        }
                    }
                }
                # Filter to intersection — every field we probe must exist on the CA
                $validCanonical = @($allCanonical | Where-Object { $intersection -contains $_ })

                $fieldMap  = Get-CertutilFieldNameMap -Session $probeSession -CanonicalFieldNames $validCanonical
                $syncState = [PSCustomObject]@{
                    lastSync     = [datetime]::UtcNow.ToString('o')
                    fieldNameMap = [PSCustomObject]$fieldMap
                }
                $profileConfig | Add-Member -MemberType NoteProperty -Name 'syncState' -Value $syncState -Force
                Write-Verbose "Field name map built from probe query against '$probeCA'."
            } catch {
                Write-Warning "Could not build field name map for profile '$Profile': $_"
            }

            $config | ConvertTo-Json -Depth 10 | Set-Content -Path $script:ConfigPath -Encoding UTF8
            $configUpdated = $true
            Write-Verbose "Profile '$Profile' out fields updated in $script:ConfigPath"
        }
    }

    foreach ($entry in $schemaPerCA) {
        [PSCustomObject]@{
            Profile         = $Profile
            CAServer        = $entry.CAFqdn
            AvailableFields = $entry.Fields
            FieldCount      = $entry.Fields.Count
            SchemaConflicts = [PSCustomObject]$conflictMap
            ValidatedOut    = [PSCustomObject]$validatedOut
            RemovedFields   = [PSCustomObject]$removedFields
            ConfigUpdated   = $configUpdated
        }
    }
}