Private/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 }) } } |