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 } } |