Public/Invoke-SqlSpnExecutionEngine.ps1

# =============================================================================
# Script : Invoke-SqlSpnExecutionEngine.ps1
# Author : Keith Ramsey
# =============================================================================
# Change Log
# -----------------------------------------------------------------------------
# 2026-05-09 Keith Ramsey Phase 2 release polish - DR-202 standard header applied.
# 2026-05-14 Keith Ramsey Pass setspn args as arrays (paired with the
# Invoke-SqlSpnNativeCall array-contract fix). The old
# single-string form meant -Q/-S never executed; both
# the duplicate check and registration were inert.
# 2026-05-14 Keith Ramsey Register against AccountName (sAMAccountName), not
# AccountDn - setspn can't resolve a DN. Detect real
# setspn failure (exit code + error signatures) instead
# of trusting try/catch; the engine was reporting
# SUCCESS on registrations that actually failed.
# 2026-05-15 Keith Ramsey Locale-independent conflict detection: match the
# echoed SPN from setspn -Q, not the English
# "Existing SPN found!" banner (would not fire on a
# non-English Windows). Banner kept as secondary only.
# 2026-05-15 Keith Ramsey Success verdict moved to Private\
# Invoke-SqlSpnSetspnAction (shared with Add/Remove
# so the false-success guard cannot drift between
# three copies).
# 2026-05-17 Keith Ramsey DR-308: opt-in -PassThru returns a structured
# SqlSpn.ExecutionResult (per-SPN Action + OverallStatus)
# for programmatic consumers (SqlInstanceForge). Purely
# additive - default void + console + audit-log
# behaviour is unchanged.
# =============================================================================
function Invoke-SqlSpnExecutionEngine {
    <#
    .SYNOPSIS
        Executes an SPN plan: optional permission preflight, forest-wide duplicate check,
        then registration, with audit logging.
    .DESCRIPTION
        For the supplied plan:
          1. (Per DR-108) Unless -SkipPreflight is set, runs Test-SqlAdPermission on
             Plan.AccountDn before registration. If the caller is missing the rights
             needed to write SPNs, emits a structured warning and continues � setspn
             will surface the actual error if the static check missed delegation.
          2. For each SPN, runs setspn -Q (with -T <TargetDomain> when CrossForest is
             set) to detect an existing registration anywhere in the forest.
          3. Skips and logs a WARN entry on conflict.
          4. Otherwise runs setspn -S (with -T when CrossForest) to register the SPN
             against the plan's AccountDn.

        Honors ShouldProcess so callers can use -WhatIf and -Confirm.
    .PARAMETER Plan
        Output of New-SqlSpnPlan. Required fields: AccountDn, ProposedSpns,
        TargetDomain, CrossForest.
    .PARAMETER SkipPreflight
        Skip the Test-SqlAdPermission check. Use when the static ACL inspection
        is known to misfire in your environment (delegation patterns it can't see)
        or when you want to attempt registration regardless and let setspn surface
        any real error.
    .PARAMETER PassThru
        Opt-in. Also return a structured SqlSpn.ExecutionResult object describing
        the per-SPN outcome and an overall status. Off by default so existing
        callers (the wizard, human operators) see no behaviour change. Added per
        DR-308 for programmatic consumers (e.g. SqlInstanceForge) that need a
        machine-readable result instead of scraping the audit log. The console
        output and audit log are written exactly as before, in addition.
    .EXAMPLE
        $plan | Invoke-SqlSpnExecutionEngine -WhatIf
    .EXAMPLE
        $plan | Invoke-SqlSpnExecutionEngine -SkipPreflight
    .EXAMPLE
        $result = $plan | Invoke-SqlSpnExecutionEngine -PassThru
        $result.OverallStatus # e.g. AllRegistered | Completed | PartialFailure
        $result.Spns | Where-Object Action -eq 'Failed'
    .OUTPUTS
        Without -PassThru: none. Side effects only (setspn registrations + audit
        log entries).

        With -PassThru: one SqlSpn.ExecutionResult object (in addition to the
        unchanged side effects):

          SqlSpn.ExecutionResult {
            OverallStatus : 'AllRegistered' | 'Completed' | 'AllConflict'
                          | 'PartialFailure' | 'AllFailed' | 'WhatIf' | 'NothingToDo'
            Spns : @( {
               Spn : <string>
               Action : 'Registered' | 'SkippedConflict' | 'Failed' | 'SkippedWhatIf'
               Reason : <string> # value-free; no secrets, no host-name leak
               TargetAccount : <string> # AccountName the SPN was/would be registered against
            } )
          }

        OverallStatus derivation (total): no SPNs -> NothingToDo; all failed ->
        AllFailed; any failed alongside any non-failure -> PartialFailure; with
        no failures: all registered -> AllRegistered, all already-present ->
        AllConflict, all simulated -> WhatIf, any other no-failure mixture
        (e.g. some registered + some already-present) -> Completed.
        Field names and enum values are LOCKED by DR-308 (a consumer binds to them).
    .NOTES
        Pester mocks Invoke-SqlSpnNativeCall to substitute setspn.exe in tests.
        The preflight check is best-effort and warn-only; per DR-108 it never blocks
        registration on its own decision.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory=$true, ValueFromPipeline=$true)][PSCustomObject]$Plan,
        [switch]$SkipPreflight,
        [switch]$PassThru
    )

    begin {
        # Per-SPN outcome accumulator. Populated on every path that produces
        # information so the -PassThru contract is total (DR-308). Records are
        # collected even when -PassThru is off (cost is trivial) so the code
        # path is identical with or without the switch; only the final emit is
        # gated.
        $spnResults = [System.Collections.Generic.List[object]]::new()
    }

    process {
        if (-not $SkipPreflight -and $Plan.AccountDn) {
            $preflight = Test-SqlAdPermission -DistinguishedName $Plan.AccountDn
            if (-not $preflight.Allowed) {
                $msg = "Preflight: caller may lack SPN-write rights on $($Plan.AccountDn). $($preflight.Reason) (method: $($preflight.Method)). Proceeding anyway; setspn will surface the actual error if the static check missed something. Use -SkipPreflight to suppress this warning."
                Write-Warning $msg
                Write-SqlSpnLog -Message $msg -Level WARN
            }
        }

        $tArgs = if ($Plan.CrossForest) { @('-T', $Plan.TargetDomain) } else { @() }

        foreach ($spn in $Plan.ProposedSpns) {
            $check = & Invoke-SqlSpnNativeCall ($tArgs + @('-Q', $spn))
            # setspn -Q echoes the queried SPN verbatim (as stored in AD, any
            # case) on its own indented line under the holding account's DN
            # when - and only when - it already exists; when it does not,
            # setspn prints a "no such SPN" message and never echoes it.
            # Detect on the echoed SPN (case-insensitive), not the English
            # "Existing SPN found!" banner, so this works on any OS UI
            # language - SPN strings and DNs are never localized. The banner
            # match is kept only as a harmless secondary signal.
            $spnEchoed = (@($check) | ForEach-Object { "$_".Trim() }) -contains $spn
            if ($spnEchoed -or ($check -match 'Existing SPN found!')) {
                $msg = "CONFLICT: $spn already exists. Skipping."
                Write-Warning $msg
                Write-SqlSpnLog -Message $msg -Level WARN
                $spnResults.Add([PSCustomObject]@{
                    Spn           = $spn
                    Action        = 'SkippedConflict'
                    Reason        = 'Already registered somewhere in the forest; left as-is.'
                    TargetAccount = $Plan.AccountName
                })
                continue
            }

            if ($PSCmdlet.ShouldProcess("$spn -> $($Plan.AccountName)", 'Register SPN')) {
                $r = Invoke-SqlSpnSetspnAction -ArgumentList ($tArgs + @('-S', $spn, $Plan.AccountName))
                if ($r.Failed) {
                    $msg = "FAILED to register $spn on $($Plan.AccountName). setspn: $($r.Output)"
                    Write-Error $msg
                    Write-SqlSpnLog -Message $msg -Level ERROR
                    $spnResults.Add([PSCustomObject]@{
                        Spn           = $spn
                        Action        = 'Failed'
                        Reason        = "setspn did not confirm registration: $($r.Output)"
                        TargetAccount = $Plan.AccountName
                    })
                }
                else {
                    $msg = "Registered $spn on $($Plan.AccountName)"
                    Write-Host $msg -ForegroundColor Green
                    Write-SqlSpnLog -Message $msg -Level SUCCESS
                    $spnResults.Add([PSCustomObject]@{
                        Spn           = $spn
                        Action        = 'Registered'
                        Reason        = 'Registered by this run.'
                        TargetAccount = $Plan.AccountName
                    })
                }
            }
            else {
                # ShouldProcess returned false: -WhatIf, or the operator
                # answered "no" to a -Confirm prompt. The SPN was deliberately
                # not acted on - record it so the contract covers every path.
                $spnResults.Add([PSCustomObject]@{
                    Spn           = $spn
                    Action        = 'SkippedWhatIf'
                    Reason        = 'Simulation only (-WhatIf or declined -Confirm); not registered.'
                    TargetAccount = $Plan.AccountName
                })
            }
        }
    }

    end {
        if (-not $PassThru) { return }

        # Derive OverallStatus from the accumulated per-SPN actions. Total by
        # construction: every per-SPN branch above adds exactly one record, so
        # these counts always sum to $spnResults.Count. Enum values are LOCKED
        # by DR-308 - a consumer (SqlInstanceForge) binds to these strings.
        $n = $spnResults.Count
        $failed     = @($spnResults | Where-Object Action -eq 'Failed').Count
        $registered = @($spnResults | Where-Object Action -eq 'Registered').Count
        $conflict   = @($spnResults | Where-Object Action -eq 'SkippedConflict').Count
        $whatIf     = @($spnResults | Where-Object Action -eq 'SkippedWhatIf').Count

        $overall =
            if     ($n -eq 0)            { 'NothingToDo' }
            elseif ($failed -eq $n)      { 'AllFailed' }
            elseif ($failed -gt 0)       { 'PartialFailure' }   # some failed, some not
            elseif ($registered -eq $n)  { 'AllRegistered' }
            elseif ($conflict   -eq $n)  { 'AllConflict' }
            elseif ($whatIf     -eq $n)  { 'WhatIf' }
            else                         { 'Completed' }         # no failures, mixed (e.g. some registered + some already present)

        $result = [PSCustomObject]@{
            OverallStatus = $overall
            Spns          = $spnResults.ToArray()
        }
        # Stable discriminator so a programmatic consumer can detect this
        # contract by type rather than duck-typing (DR-308). No provenance
        # fields - matches the module's plain-object convention.
        $result.PSObject.TypeNames.Insert(0, 'SqlSpn.ExecutionResult')
        $result
    }
}