Public/New-SqlSpnPlan.ps1

# =============================================================================
# Script : New-SqlSpnPlan.ps1
# Author : Keith Ramsey
# =============================================================================
# Change Log
# -----------------------------------------------------------------------------
# 2026-05-09 Keith Ramsey Phase 2 release polish - DR-202 standard header applied.
# 2026-05-13 Keith Ramsey Document v1.3.0 role + scenario coverage funnel in
# .NOTES: planner ValidateSet is 12 (BTRD contract),
# CFG exposes 5, wizard hardcodes Engine; MSDTC is
# accepted but emits MSSQLSvc/ (Phase 0 gap).
# 2026-05-14 Keith Ramsey Add AccountName (sAMAccountName) to the plan. setspn
# cannot resolve an account by DistinguishedName; the
# engine must pass the sAMAccountName. AccountDn is kept
# for the permission preflight. Surfaced by lab testing.
# 2026-05-21 Keith Ramsey DR-309 v1 surface narrowing: -Role ValidateSet
# reduced to Engine,Agent. SSAS/SSRS/SSIS/FTS/PolyBase/
# Browser/ReplayClient/ReplayController/QueryStore/
# VSSWriter removed from the PUBLIC surface for v1
# (internal Get-SqlRoleMetadata table unchanged - they
# are simply not user-reachable in v1). MSDTC removed
# from the Scenario surface via Get-SqlSpnInfrastructure
# (this function's Scenario comes from infrastructure).
# =============================================================================
function New-SqlSpnPlan {
    <#
    .SYNOPSIS
        Builds an SPN registration plan from a verified account, resolved infrastructure, and a role.
    .DESCRIPTION
        Composes the canonical plan object that the rest of the module consumes:
            { PlanGuid, AccountDn, ProposedSpns, Role, Scenario, TargetDomain, CrossForest }

        The service class for each SPN comes from Get-SqlRoleMetadata. For FCI Engine
        registrations, if the supplied VerifiedAccount is not a computer object the
        function attempts to resolve the cluster Virtual Computer Object (VCO) from
        the infrastructure's virtual name; on success a warning is emitted and the
        plan targets the VCO. On lookup failure the caller is instructed to pass
        an explicit -VirtualComputerAccount.

        Returns $null for roles that do not require an SPN (e.g., Agent under most
        configurations).
    .PARAMETER VerifiedAccount
        Output of Get-SqlSpnAccount. Must carry DistinguishedName and ObjectClass.
    .PARAMETER Infrastructure
        Output of Get-SqlSpnInfrastructure. Must carry NetBIOS, FQDN, Port, Scenario,
        InstanceName, TargetDomain, CrossForest.
    .PARAMETER Role
        SQL service role being registered.
    .PARAMETER VirtualComputerAccount
        Optional explicit override for FCI: the verified Cluster Virtual Computer
        Account (output of Get-SqlSpnAccount) to register against. Skips
        auto-resolution.
    .EXAMPLE
        $acct = Get-SqlSpnAccount -SamAccountName 'svc_sql_prod'
        $infra = Get-SqlSpnInfrastructure -Scenario Standalone -TargetName SQLSRV01
        $plan = New-SqlSpnPlan -VerifiedAccount $acct -Infrastructure $infra -Role Engine
    .OUTPUTS
        PSCustomObject with PlanGuid, AccountDn, ProposedSpns, Role, Scenario,
        TargetDomain, CrossForest.
    .NOTES
        Cross-forest detection comes from Get-SqlSpnInfrastructure; this function
        propagates TargetDomain so Invoke-SqlSpnExecutionEngine can pass setspn -T.

        Role coverage (v1.4.0, DR-309 + DR-311):
          Public surface narrowed to the proven Engine core. The ValidateSet
          exposes only Engine and Agent (Agent's RequireSpn=$false; returns
          $null as a documented no-op). SSAS, SSRS, PBIRS, Browser, and the
          remaining BTRD roles are deferred as named, demand-sequenced,
          prove-before-expose post-v1 expansions. The internal
          Get-SqlRoleMetadata table retains entries; they are simply not
          user-reachable in v1.

        Scenario coverage (v1.4.0, DR-309):
          Standalone, AlwaysOn, and FCI - all three lab-proven end-to-end on
          a real domain (Waves 1-3, 2026-05-17). MSDTC removed from the
          public Scenario surface; the prior MSDTC=>MSSQLSvc/ pinning test
          is inverted to assert MSDTC is NOT publicly accepted in v1.
    #>

    [CmdletBinding()]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'New-SqlSpnPlan is a pure builder - returns a PSCustomObject describing proposed registrations. State change happens later in Add-SqlSpn / Invoke-SqlSpnExecutionEngine.')]
    param(
        [Parameter(Mandatory=$true)][PSCustomObject]$VerifiedAccount,
        [Parameter(Mandatory=$true)][PSCustomObject]$Infrastructure,
        [Parameter(Mandatory=$true)][ValidateSet('Engine','Agent')][string]$Role,
        [PSCustomObject]$VirtualComputerAccount
    )

    $meta = Get-SqlRoleMetadata -Role $Role
    if (-not $meta.RequireSpn) {
        Write-Verbose "Role $Role does not require SPN registration."
        return $null
    }

    $accountForPlan = $VerifiedAccount

    if ($Infrastructure.Scenario -eq 'FCI' -and $Role -eq 'Engine') {
        if ($VirtualComputerAccount) {
            if ($VirtualComputerAccount.ObjectClass -ne 'computer') {
                throw "VirtualComputerAccount [$($VirtualComputerAccount.Name)] must be a computer object (got ObjectClass: $($VirtualComputerAccount.ObjectClass))."
            }
            $accountForPlan = $VirtualComputerAccount
        }
        elseif ($VerifiedAccount.ObjectClass -ne 'computer') {
            $resolved = Resolve-SqlSpnFciCno -VirtualName $Infrastructure.NetBIOS
            if (-not $resolved.Found) {
                throw "FCI Engine SPNs must be assigned to the Cluster Virtual Computer Account. Auto-resolution of [$($Infrastructure.NetBIOS)] failed: $($resolved.Reason). Re-run with -VirtualComputerAccount (Get-SqlSpnAccount -SamAccountName <virtual-name>$)."
            }
            Write-Warning "Redirecting FCI Engine SPN to cluster computer account [$($resolved.SamAccountName)] (auto-resolved from virtual name)."
            $accountForPlan = [PSCustomObject]@{
                Name              = $resolved.SamAccountName
                DistinguishedName = $resolved.DistinguishedName
                ObjectClass       = 'computer'
            }
        }
    }

    $spns = @()
    $spns += "$($meta.ServiceClass)/$($Infrastructure.NetBIOS):$($Infrastructure.Port)"
    $spns += "$($meta.ServiceClass)/$($Infrastructure.FQDN):$($Infrastructure.Port)"

    if ($Infrastructure.InstanceName -eq 'MSSQLSERVER') {
        $spns += "$($meta.ServiceClass)/$($Infrastructure.NetBIOS)"
        $spns += "$($meta.ServiceClass)/$($Infrastructure.FQDN)"
    }

    return [PSCustomObject]@{
        PlanGuid     = [guid]::NewGuid()
        AccountDn    = $accountForPlan.DistinguishedName
        AccountName  = $accountForPlan.Name
        ProposedSpns = $spns | Select-Object -Unique
        Role         = $Role
        Scenario     = $Infrastructure.Scenario
        TargetDomain = $Infrastructure.TargetDomain
        CrossForest  = $Infrastructure.CrossForest
    }
}