Public/Remove-SqlSpn.ps1

# =============================================================================
# Script : Remove-SqlSpn.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 Array args; target AccountName (setspn cannot
# resolve a DN); log SUCCESS only when setspn -D
# actually succeeded.
# 2026-05-15 Keith Ramsey Success verdict delegated to Private\
# Invoke-SqlSpnSetspnAction with -IgnoreDuplicate
# (a duplicate is not a removal failure).
# =============================================================================
function Remove-SqlSpn {
    <#
    .SYNOPSIS
        Deregisters each SPN in a plan from the plan's AccountDn (primitive).
    .DESCRIPTION
        Iterates the plan's ProposedSpns and calls setspn -D for each one, then writes
        a SUCCESS entry to the audit log. This is the deregistration counterpart to
        Add-SqlSpn. Used to clean up stale SPNs after a service identity change or
        a server decommission.

        Honors ShouldProcess, so -WhatIf and -Confirm work. Use -WhatIf first when
        decommissioning to confirm the SPN list before pulling.
    .PARAMETER SpnPlan
        Plan object describing which SPNs to remove and from which account.
        Required fields: AccountName, ProposedSpns. The same New-SqlSpnPlan
        output used to register can be piped here to unregister.
    .EXAMPLE
        $plan | Remove-SqlSpn -WhatIf
    .EXAMPLE
        $plan | Remove-SqlSpn -Confirm:$false
    .OUTPUTS
        None. Side effects: setspn -D calls and audit log entries.
    .NOTES
        For compatibility with Add-SqlSpn, this command does not run a forest-wide
        existence check before attempting removal. setspn -D is a no-op (with a
        warning) if the SPN doesn't exist.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param([Parameter(Mandatory=$true, ValueFromPipeline=$true)][PSCustomObject]$SpnPlan)
    process {
        foreach ($Spn in $SpnPlan.ProposedSpns) {
            if ($PSCmdlet.ShouldProcess("REMOVING $Spn from $($SpnPlan.AccountName)")) {
                # -IgnoreDuplicate: removing one of a duplicate pair is success,
                # not failure (unlike registration). This asymmetry is the bug
                # the shared helper makes explicit instead of a silent regex diff.
                $r = Invoke-SqlSpnSetspnAction -ArgumentList @('-D', $Spn, $SpnPlan.AccountName) -IgnoreDuplicate
                if ($r.Failed) {
                    Write-Error "FAILED to remove $Spn from $($SpnPlan.AccountName). setspn: $($r.Output)"
                    Write-SqlSpnLog -Message "FAILED to remove $Spn from $($SpnPlan.AccountName). setspn: $($r.Output)" -Level ERROR
                }
                else {
                    Write-SqlSpnLog -Message "DECOMMISSIONED: $Spn from $($SpnPlan.AccountName)" -Level SUCCESS
                }
            }
        }
    }
}