Public/Copy-AGTemplate.ps1

function Copy-AGTemplate {
    <#
    .SYNOPSIS
        Clones a built-in certificate template to a new ADCSGoat-owned template.

    .DESCRIPTION
        Implements the universal clone rules settled in ADCSGoat issue #22 and
        the collision contract from issue #23.

        Clone rules:
        - Identity/system attributes are never copied (cn, name, objectClass,
          objectGUID, distinguishedName, instanceType, whenCreated/whenChanged,
          uSNCreated/uSNChanged, dSCorePropagationData, nTSecurityDescriptor,
          objectCategory, showInAdvancedViewOnly, displayName,
          msPKI-Cert-Template-OID). cn/name/displayName are set to the
          destination identity; every other attribute is copied verbatim from
          the live source (read-and-preserve).
        - A fresh OID is minted under the forest base arc with a companion
          ms-PKI-Enterprise-Oid object (see New-AGTemplateOid).
        - GUI-authentic schema upgrade: a schema-v1 source produces a schema-v2
          clone; v2+ sources copy verbatim. revision and
          msPKI-Template-Minor-Revision are set to fresh initial values.
        - The source's whole security descriptor (owner, group, DACL, SACL,
          protected bit) is applied atomically via ObjectSecurity.
        - The clone's description carries the 'Generated by ADCSGoat'
          ownership marker.

        Collision contract:
        - Owned collision (existing object with the ownership marker):
          interactive runs prompt Replace/Cancel with Cancel as default;
          non-interactive runs abort unless -Force is passed.
        - Unowned collision (existing object without the marker): the object
          is never touched; the clone lands at the deterministic fallback name
          '<DestinationName> Generated by ADCSGoat'.
        - Replace is delete + recreate. A failure after the delete aborts
          loudly, naming the deleted object and the failed step; no rollback
          machinery (rerun to recreate).

    .PARAMETER SourceName
        The cn of the built-in source template (e.g. 'WebServer', 'SubCA').

    .PARAMETER DestinationName
        The desired cn/displayName of the clone.

    .PARAMETER Server
        The domain controller to write to. Defaults to the logon server.

    .PARAMETER Force
        Replaces an owned collision without prompting. Required for
        non-interactive replacement.

    .OUTPUTS
        System.Management.Automation.PSCustomObject. The resolved clone
        identity: DestinationName (actual, post-fallback), Oid, and
        CompanionOidObjectDN.

    .EXAMPLE
        Copy-AGTemplate -SourceName 'WebServer' -DestinationName 'Copy of Web Server'

    .EXAMPLE
        Copy-AGTemplate -SourceName 'SubCA' -DestinationName 'VMware 6.x' -Force
    #>

    # SupportsShouldProcess is deliberately not used: $PSCmdlet.ShouldProcess
    # cannot bind when the function is invoked from a dot-sourced definition
    # (the module's own Pester pattern), which is why the existing module
    # cmdlets (e.g. New-AGBlankTemplateObject) also omit it. The destructive
    # step (owned-object replace) is gated by the collision prompt / -Force
    # contract, which is the safety mechanism this design specifies (#23).
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Collision prompt + -Force is the specified safety gate; ShouldProcess cannot bind under the module dot-source test pattern.')]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$SourceName,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$DestinationName,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]$Server,

        [Parameter()]
        [switch]$Force
    )

    begin {
        Add-Type -AssemblyName System.DirectoryServices

        if ([string]::IsNullOrEmpty($Server)) {
            $Server = [System.Net.Dns]::GetHostEntry($env:LOGONSERVER.TrimStart('\')).HostName
        }

        $rootDSE = New-Object System.DirectoryServices.DirectoryEntry("LDAP://$Server/RootDSE")
        $configurationPartition = $rootDSE.configurationNamingContext
        $templatesContainerDN = "CN=Certificate Templates,CN=Public Key Services,CN=Services,$configurationPartition"

        $skipList = @(
            'cn', 'name', 'objectClass', 'objectGUID', 'distinguishedName',
            'instanceType', 'whenCreated', 'whenChanged', 'uSNCreated',
            'uSNChanged', 'dSCorePropagationData', 'nTSecurityDescriptor',
            'objectCategory', 'showInAdvancedViewOnly', 'displayName',
            'msPKI-Cert-Template-OID'
        )

        function Test-IsOwned {
            param([System.DirectoryServices.DirectoryEntry]$Entry)
            "$($Entry.Properties['description'].Value)" -match 'Generated by ADCSGoat'
        }

        function Get-ReplaceAuthorization {
            param(
                [string]$Name,
                [switch]$Force
            )
            if ($Force.IsPresent) { return $true }
            $interactive = [System.Environment]::UserInteractive -and -not [System.Console]::IsInputRedirected
            if (-not $interactive) { return $false }
            $title = "Template '$Name' already exists and is ADCSGoat-owned."
            $message = 'Replace it (delete + recreate)?'
            $choiceReplace = New-Object System.Management.Automation.Host.ChoiceDescription '&Replace', 'Delete the owned object and recreate it from the source.'
            $choiceCancel = New-Object System.Management.Automation.Host.ChoiceDescription '&Cancel', 'Abort without changes.'
            $decision = $Host.UI.PromptForChoice($title, $message, @($choiceReplace, $choiceCancel), 1)
            return ($decision -eq 0)
        }

    }

    process {
        $templatesContainer = New-Object System.DirectoryServices.DirectoryEntry("LDAP://$Server/$templatesContainerDN")

        # Resolve the source template.
        $sourcePath = "LDAP://$Server/CN=$SourceName,$templatesContainerDN"
        if (-not [System.DirectoryServices.DirectoryEntry]::Exists($sourcePath)) {
            $templatesContainer.Dispose()
            $exception = New-Object System.InvalidOperationException("Source template '$SourceName' not found at 'CN=$SourceName,$templatesContainerDN'.")
            $errorRecord = New-Object System.Management.Automation.ErrorRecord($exception, 'SourceTemplateNotFound', [System.Management.Automation.ErrorCategory]::ObjectNotFound, $SourceName)
            $PSCmdlet.ThrowTerminatingError($errorRecord)
        }
        $source = New-Object System.DirectoryServices.DirectoryEntry($sourcePath)
        $source.RefreshCache()

        # Collision resolution: decide the actual destination name and whether
        # an owned object is authorized for replacement. No AD writes happen in
        # this phase; the delete is deferred until after OID minting so that a
        # minting failure can never leave an already-deleted slot behind.
        $resolvedName = $DestinationName
        $replaceAuthorized = $false
        $destinationPath = "LDAP://$Server/CN=$resolvedName,$templatesContainerDN"

        if ([System.DirectoryServices.DirectoryEntry]::Exists($destinationPath)) {
            $existing = New-Object System.DirectoryServices.DirectoryEntry($destinationPath)
            $owned = Test-IsOwned -Entry $existing
            $existing.Dispose()

            if ($owned) {
                $replaceAuthorized = Get-ReplaceAuthorization -Name $resolvedName -Force:$Force
                if (-not $replaceAuthorized) {
                    $templatesContainer.Dispose()
                    $source.Dispose()
                    $exception = New-Object System.InvalidOperationException("Collision: '$resolvedName' exists and is ADCSGoat-owned; replacement was not confirmed. Rerun interactively or pass -Force.")
                    $errorRecord = New-Object System.Management.Automation.ErrorRecord($exception, 'CollisionReplacementNotConfirmed', [System.Management.Automation.ErrorCategory]::ResourceExists, $resolvedName)
                    $PSCmdlet.ThrowTerminatingError($errorRecord)
                }
            } else {
                # Unowned collision: never touch; fall back to the marked name.
                $resolvedName = "$DestinationName Generated by ADCSGoat"
                Write-Warning "'$DestinationName' exists and is not ADCSGoat-owned; it will not be modified. Using fallback name '$resolvedName'."
                $destinationPath = "LDAP://$Server/CN=$resolvedName,$templatesContainerDN"
                if ([System.DirectoryServices.DirectoryEntry]::Exists($destinationPath)) {
                    $fallbackEntry = New-Object System.DirectoryServices.DirectoryEntry($destinationPath)
                    $fallbackOwned = Test-IsOwned -Entry $fallbackEntry
                    $fallbackEntry.Dispose()
                    if ($fallbackOwned) {
                        $replaceAuthorized = Get-ReplaceAuthorization -Name $resolvedName -Force:$Force
                        if (-not $replaceAuthorized) {
                            $templatesContainer.Dispose()
                            $source.Dispose()
                            $exception = New-Object System.InvalidOperationException("Collision: '$resolvedName' exists and is ADCSGoat-owned; replacement was not confirmed. Rerun interactively or pass -Force.")
                            $errorRecord = New-Object System.Management.Automation.ErrorRecord($exception, 'CollisionReplacementNotConfirmed', [System.Management.Automation.ErrorCategory]::ResourceExists, $resolvedName)
                            $PSCmdlet.ThrowTerminatingError($errorRecord)
                        }
                    } else {
                        $templatesContainer.Dispose()
                        $source.Dispose()
                        $exception = New-Object System.InvalidOperationException("Collision: both '$DestinationName' and fallback '$resolvedName' exist and are not ADCSGoat-owned. Resolve the foreign objects manually.")
                        $errorRecord = New-Object System.Management.Automation.ErrorRecord($exception, 'ForeignCollisionUnresolvable', [System.Management.Automation.ErrorCategory]::ResourceExists, $resolvedName)
                        $PSCmdlet.ThrowTerminatingError($errorRecord)
                    }
                }
            }
        }

        # Mint the OID + companion object after the name is final but before any
        # delete: the last step that can fail while the slot is still intact.
        $oidInfo = New-AGTemplateOid -TemplateName $resolvedName -Server $Server
        $templateOid = $oidInfo.Oid

        # Owned replace: delete the existing object only now; recreate below.
        if ($replaceAuthorized) {
            try {
                Remove-AGTemplate -Name $resolvedName -Server $Server
            } catch {
                $templatesContainer.Dispose()
                $source.Dispose()
                $exception = New-Object System.InvalidOperationException("Replace of owned template '$resolvedName' failed during delete: $($_.Exception.Message)")
                $errorRecord = New-Object System.Management.Automation.ErrorRecord($exception, 'ReplaceDeleteFailed', [System.Management.Automation.ErrorCategory]::NotSpecified, $resolvedName)
                $PSCmdlet.ThrowTerminatingError($errorRecord)
            }
        }

            $clone = $null
            try {
                $clone = $templatesContainer.Children.Add("CN=$resolvedName", 'pKICertificateTemplate')

                # Verbatim copy of every non-excluded attribute.
                foreach ($propertyName in $source.Properties.PropertyNames) {
                    if ($skipList -contains $propertyName) { continue }
                    $sourceValues = $source.Properties[$propertyName]
                    if ($null -eq $sourceValues -or $sourceValues.Count -eq 0) { continue }
                    foreach ($value in $sourceValues) {
                        $clone.Properties[$propertyName].Add($value) | Out-Null
                    }
                }

                # Destination identity + ownership marker.
                $clone.Properties['displayName'].Value = $resolvedName
                $clone.Properties['description'].Value = 'Generated by ADCSGoat'

                # GUI-authentic schema upgrade: v1 sources become v2 clones.
                $sourceSchema = 0
                if ($source.Properties['msPKI-Template-Schema-Version'].Count -gt 0) {
                    $sourceSchema = [int]$source.Properties['msPKI-Template-Schema-Version'].Value
                }
                if ($sourceSchema -lt 2) {
                    $clone.Properties['msPKI-Template-Schema-Version'].Value = 2
                }

                # Fresh revision counters.
                $clone.Properties['revision'].Value = 100
                $clone.Properties['msPKI-Template-Minor-Revision'].Value = 0

                # Fresh OID.
                $clone.Properties['msPKI-Cert-Template-OID'].Value = $templateOid

                $clone.CommitChanges()

                # Whole source security descriptor, one atomic write.
                $clone.ObjectSecurity = $source.ObjectSecurity
                $clone.CommitChanges()
            } catch {
                $failedName = $resolvedName
                if ($null -ne $clone) { $clone.Dispose() }
                $templatesContainer.Dispose()
                $source.Dispose()
                $exception = New-Object System.InvalidOperationException("Clone of '$SourceName' to '$failedName' failed during recreate after any owned delete: $($_.Exception.Message). Rerun the deploy to recreate.")
                $errorRecord = New-Object System.Management.Automation.ErrorRecord($exception, 'CloneRecreateFailed', [System.Management.Automation.ErrorCategory]::NotSpecified, $failedName)
                $PSCmdlet.ThrowTerminatingError($errorRecord)
            }

            $clone.Dispose()

        $templatesContainer.Dispose()
        $source.Dispose()

        Write-Output ([pscustomobject]@{
            SourceName           = $SourceName
            DestinationName      = $resolvedName
            RequestedName        = $DestinationName
            Oid                  = $templateOid
            CompanionOidObjectDN = $oidInfo.CompanionObjectDN
        })
    }
}