Public/Set-AccountSpn.ps1

<#
SPDX-License-Identifier: Apache-2.0
Copyright (c) 2025 Stefan Ploch
#>



function Set-AccountSpn {
    <#
    .SYNOPSIS
    Safely manage Service Principal Names (SPNs) for Active Directory accounts.
 
    .DESCRIPTION
    Add, remove, or list SPNs on AD accounts with built-in conflict detection, dry-run capabilities
    and automatic rollback on failure. Provides transactional behaviour to ensure consistent state even if operations fail partway through.
 
        .PARAMETER SamAccountName
        The account's sAMAccountName to modify SPNs for.
 
        .PARAMETER Add
        SPNs to add to the account.
 
        .PARAMETER Remove
        SPNs to remove from the account.
 
        .PARAMETER List
        List current SPNs on the account.
 
        .PARAMETER WhatIfOnly
        Show operation plan without making changes.
 
        .PARAMETER Domain
        Domain to target for AD operations.
 
        .PARAMETER Server
        Specific domain controller to use.
 
        .PARAMETER Credential
        Alternate credentials for AD operations.
 
        .PARAMETER Force
        Proceed even if some validations fail.
 
        .PARAMETER IgnoreConflicts
        Proceed even if SPN conflicts are detected.
 
        .PARAMETER Summary
        Write a summary file with operation results.
 
        .PARAMETER SummaryPath
        Path to write operation summary JSON. Defaults next to Output when omitted.
 
        .PARAMETER PassThru
        Return detailed operation results.
 
        .EXAMPLE
        Set-AccountSpn -SamAccountName svc-web -Add 'HTTP/web.contoso.com', 'HTTP/web' -List
 
        Adds HTTP SPNs to svc-web and shows the final SPN list.
 
        .EXAMPLE
        Set-AccountSpn -SamAccountName svc-old -Remove 'HTTP/oldserver.contoso.com' -WhatIfOnly
 
        Shows what would be removed without making changes.
 
        .EXAMPLE
        Set-AccountSpn -SamAccountName svc-app -List
 
        Lists all current SPNs for svc-app.
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact='Medium')]
    param(
        [Parameter(Mandatory, Position=0)]
        [ValidateNotNullOrEmpty()]
        [string]$SamAccountName,

        [string[]]$Add,
        [string[]]$Remove,
        [switch]$List,
        [switch]$WhatIfOnly,

        # AD Integration
        [string]$Domain,
        [string]$Server,
        [pscredential]$Credential,

        # Safety & Control
        [switch]$Force,
        [switch]$IgnoreConflicts,
        [switch]$Summary,
        [Alias('JsonSummaryPath')]
        [string]$SummaryPath,

        [switch]$PassThru
    )

    begin {
        Get-RequiredModule -Name 'ActiveDirectory'

        if (-not ($Add -or $Remove -or $List)) {
            throw "Must specifiy at least one of: -Add, -Remove, or -List"
        }

        # Normalize inputs
        $spnsToAdd = @($Add | Where-Object { $_ })
        $spnsToRemove = @($Remove | Where-Object { $_ })

        if (-not $SummaryPath) {
            $SummaryPath = Resolve-OutputPath -Directory (Get-Location).Path -BaseName "Set-AccountSpn_summary" -Extension ".json" -CreateDirectory
        }
    }

    process {
        try {
            # 1. Discover current accoutn state
            $domainFQDN = Resolve-DomainContext -Domain $domain

            $getParams = @{
                Identity   = $SamAccountName
                Properties = @('ServicePrincipalNames')
            }
            if ($Server) { $getParams.Server = $Server }
            if ($Credential) { $getParams.Credential = $Credential }

            $account = Get-ADUser @getParams
            $currentSpns = $account.ServicePrincipalNames
            if (-not $currentSpns) { $currentSpns = @() }

            if ($List) {
                Write-Host "Current SPNs for $SamAccountName`:" -ForegroundColor Cyan
                if ($currentSpns.Count -eq 0) {
                    Write-Host " (None)" -Foregroundcolor yellow
                } else {
                    $currentSpns | ForEach-Object { Write-Host " $_" -ForegroundColor White }
                }
                return $currentSpns
            }

            # 2. Plan operations
            $actualSpnsToAdd = @($spnsToAdd | Where-Object { $_ -notin $currentSpns })
            $actualSpnsToRemove = @($spnsToRemove | Where-Object { $_ -in $currentSpns })
            $finalSpns = @(($currentSpns + $actualSpnsToAdd) | Where-Object { $_ -notin $actualSpnsToRemove })

            # Report no-ops
            $skipAdd = @($spnsToAdd | Where-Object { $_ -in $currentSpns })
            $skipRemove = @($spnsToRemove | Where-Object { $_ -notin $currentSpns })

            if ($skipAdd) {
                Write-Warning "Already present (skipping add): $($skipAdd -join ', ')"
            }
            if ($skipRemove) {
                Write-Warning "Not present (skipping remove): $($skipRemove -join ', ')"
            }

            # 3. Conflict detection for SPNs being added
            $conflicts = @()
            if ($actualSpnsToAdd) {
                Write-Verbose "Checking for SPN conflicts..."

                foreach ($spn in $actualSpnsToAdd) {
                    $searchParams = @{
                        Filter = "servicePrincipalName -eq '$spn'"
                        Properties = 'SamAccountName'
                    }
                    if ($Server) { $searchParams.Server = $Server }
                    if ($Credential) { $searchParams.Credential = $Credential }

                    $existingAccounts = @(Get-ADUser @searchParams)
                    $conflictingAccount = $existingAccounts | Where-Object { $_.SamAccountName -ne $SamAccountName } | Select-Object -First 1

                    if ($conflictingAccount) {
                        $conflicts += [ordered]@{
                            SPN = $spn
                            ConflictingAccount = $conflictingAccount.SamAccountName
                            ConflictingDN = $conflictingAccount.DistinguishedName
                        }
                    }
                }
            }

            if ($conflicts -and -not $IgnoreConflicts) {
                $conflictList = ($conflicts | ForEach-Object { "$($_.SPN) (on $($_.ConflictingAccount))" }) -join ', '
                throw "SPN conflicts detected: $conflictList. Use -IgnoreConflicts to override or resolve conflicts first."
            }

            # 4. Build operation plan
            $plan = [ordered]@{
                Operation = 'Set-AccountSpn'
                SamAccountName = $SamAccountName
                Domain = $domainFqdn
                CurrentSpns = $currentSpns
                SpnsToAdd = $actualSpnsToAdd
                SpnsToRemove = $actualSpnsToRemove
                FinalSpns = $finalSpns
                Conflicts = $conflicts
                SkippedAdd = $skipAdd
                SkippedRemove = $skipRemove
                ChangeCount = $actualSpnsToAdd.Count + $actualSpnsToRemove.Count
                Timestamp = (Get-Date).ToUniversalTime().ToString('o')
                Rollback = @{
                    OriginalSpns = $currentSpns
                }
            }

            if ($WhatIfOnly) {
                Write-Host "=== SPN Operation Plan ===" -ForegroundColor Cyan
                Write-Host "Account: $SamAccountName" -ForegroundColor White
                Write-Host "Current SPNs ($($currentSpns.Count)):" -ForegroundColor Yellow
                $currentSpns | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }

                if ($actualSpnsToAdd) {
                    Write-Host "Will ADD ($($actualSpnsToAdd.Count)):" -ForegroundColor Green
                    $actualSpnsToAdd | ForEach-Object { Write-Host " + $_" -ForegroundColor Green }
                }

                if ($actualSpnsToRemove) {
                    Write-Host "Will REMOVE ($($actualSpnsToRemove.Count)):" -ForegroundColor Red
                    $actualSpnsToRemove | ForEach-Object { Write-Host " - $_" -ForegroundColor Red }
                }

                if ($conflicts) {
                    Write-Host "CONFLICTS DETECTED:" -ForegroundColor Magenta
                    $conflicts | ForEach-Object { Write-Host " ! $($_.SPN) conflicts with $($_.ConflictingAccount)" -ForegroundColor Magenta }
                }

                Write-Host "Final SPNs ($($finalSpns.Count)):" -ForegroundColor Cyan
                $finalSpns | ForEach-Object { Write-Host " $_" -ForegroundColor White }

                return $plan
            }

            if ($plan.ChangeCount -eq 0) {
                Write-Host "No changes needed for $SamAccountName" -ForegroundColor Green
                if ($PassThru) {
                    $plan.Success = $true
                    return $plan
                }
                return $currentSpns
            }

            # Bypass confirmation if Force is specified
            $shouldProceed = $Force -or $PSCmdlet.ShouldProcess($SamAccountName, "Modify SPNs (Add: $($actualSpnsToAdd.Count), Remove: $($actualSpnsToRemove.Count))")

            if ($shouldProceed) {

                # 5. Execute operations with transactional behavior
                $rollbackNeeded = $false
                $operationsCompleted = @()

                try {
                    # Remove SPNs first (safer order - removes unused before adding potentially conflicting)
                    foreach ($spn in $actualSpnsToRemove) {
                        Write-Verbose "Removing SPN: $spn"

                        $removeParams = @{
                            Identity = $account
                            Remove = @{servicePrincipalName = @($spn)}
                        }
                        if ($Server) { $removeParams.Server = $Server }
                        if ($Credential) { $removeParams.Credential = $Credential }

                        Set-ADObject @removeParams
                        $rollbackNeeded = $true
                        $operationsCompleted += "Remove: $spn"
                    }

                    # Add SPNs second
                    foreach ($spn in $actualSpnsToAdd) {
                        Write-Verbose "Adding SPN: $spn"

                        $addParams = @{
                            Identity = $account
                            Add = @{servicePrincipalName = @($spn)}
                        }
                        if ($Server) { $addParams.Server = $Server }
                        if ($Credential) { $addParams.Credential = $Credential }

                        Set-ADObject @addParams
                        $rollbackNeeded = $true
                        $operationsCompleted += "Add: $spn"
                    }

                    # 6. Verify final state
                    Write-Verbose "Verifying final SPN state..."
                    $updatedAccount = Get-ADUser @getParams
                    $actualFinalSpns = @($updatedAccount.servicePrincipalName)
                    if (-not $actualFinalSpns) { $actualFinalSpns = @() }

                    # 7. Compile results
                    $result = [ordered]@{
                        Operation = 'Set-AccountSpn'
                        SamAccountName = $SamAccountName
                        Domain = $domainFqdn
                        Success = $true
                        OriginalSpns = $currentSpns
                        FinalSpns = $actualFinalSpns
                        Added = $actualSpnsToAdd
                        Removed = $actualSpnsToRemove
                        Conflicts = $conflicts
                        OperationsCompleted = $operationsCompleted
                        Operator = [Environment]::UserName
                        Timestamp = (Get-Date).ToUniversalTime().ToString('o')
                    }

                    if ($Summary.IsPresent -and $SummaryPath) {
                        $result | ConvertTo-Json -Depth 3 | Set-Content -Path $SummaryPath -Encoding UTF8
                    }

                    Write-Host "SPN operations completed successfully for $SamAccountName" -ForegroundColor Green
                    Write-Host "Final SPN count: $($actualFinalSpns.Count)" -ForegroundColor Green

                    if ($PassThru) {
                        return $result
                    }
                    return $actualFinalSpns

                } catch {
                    # 7. Rollback on failure
                    if ($rollbackNeeded) {
                        Write-Warning "SPN operation failed: $($_.Exception.Message)"
                        Write-Warning "Attempting to rollback changes..."
                        Write-Verbose "Operations completed before failure: $($operationsCompleted -join '; ')"

                        try {
                            $rollbackParams = @{
                                Identity = $account
                                Replace = @{servicePrincipalName = $currentSpns}
                            }
                            if ($Server) { $rollbackParams.Server = $Server }
                            if ($Credential) { $rollbackParams.Credential = $Credential }

                            Set-ADObject @rollbackParams
                            Write-Warning "Rollback completed successfully. SPNs restored to original state."
                        } catch {
                            Write-Error "Rollback failed: $($_.Exception.Message). Manual intervention required to restore SPNs."
                            Write-Host "Original SPNs were: $($currentSpns -join ', ')" -ForegroundColor Yellow
                        }
                    }
                    throw
                }
            }

        } catch {
            Write-Error "SPN operation failed for ${SamAccountName}: $($_.Exception.Message)"
            throw
        }
    }
}