diff --git a/CHANGELOG.md b/CHANGELOG.md index b1c5e898..9266b243 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Computer + - Added `ReuseExistingComputerAccount` parameter to allow reusing an existing + AD computer account when joining a domain instead of deleting and recreating + it. When set to `$true`, the existing account is preserved (SID, group + memberships, GPO links). If no existing account is found, a new one is + created. Default is `$false` - + Fixes [Issue #457](https://github.com/dsccommunity/ComputerManagementDsc/issues/457). + ### Changed - `azure-pipelines.yml` diff --git a/source/DSCResources/DSC_Computer/DSC_Computer.psm1 b/source/DSCResources/DSC_Computer/DSC_Computer.psm1 index c08a58e5..efc09838 100644 --- a/source/DSCResources/DSC_Computer/DSC_Computer.psm1 +++ b/source/DSCResources/DSC_Computer/DSC_Computer.psm1 @@ -47,6 +47,12 @@ $FailToRenameAfterJoinDomainErrorId = 'FailToRenameAfterJoinDomain,Microsoft.Pow .PARAMETER Options Specifies advanced options for the Add-Computer join operation. + + .PARAMETER ReuseExistingComputerAccount + If $true, the existing computer account in the domain will be reused + instead of being deleted and recreated. This preserves the machine + SID, group memberships, and GPO links. If not specified or $false, + the existing account is deleted and recreated. #> function Get-TargetResource { @@ -91,7 +97,11 @@ function Get-TargetResource [Parameter()] [ValidateSet('AccountCreate', 'Win9XUpgrade', 'UnsecuredJoin', 'PasswordPass', 'JoinWithNewName', 'JoinReadOnly', 'InstallInvoke')] [System.String[]] - $Options + $Options, + + [Parameter()] + [System.Boolean] + $ReuseExistingComputerAccount ) Write-Verbose -Message ($script:localizedData.GettingComputerStateMessage -f $Name) @@ -115,15 +125,16 @@ function Get-TargetResource -ClientOnly $returnValue = @{ - Name = $env:COMPUTERNAME - DomainName = Get-ComputerDomain - JoinOU = $JoinOU - CurrentOU = Get-ComputerOU - Credential = [ciminstance] $convertToCimCredential - UnjoinCredential = [ciminstance] $convertToCimUnjoinCredential - WorkGroupName = (Get-CimInstance -Class 'Win32_ComputerSystem').Workgroup - Description = (Get-CimInstance -Class 'Win32_OperatingSystem').Description - Server = Get-LogonServer + Name = $env:COMPUTERNAME + DomainName = Get-ComputerDomain + JoinOU = $JoinOU + CurrentOU = Get-ComputerOU + Credential = [ciminstance] $convertToCimCredential + UnjoinCredential = [ciminstance] $convertToCimUnjoinCredential + WorkGroupName = (Get-CimInstance -Class 'Win32_ComputerSystem').Workgroup + Description = (Get-CimInstance -Class 'Win32_OperatingSystem').Description + Server = Get-LogonServer + ReuseExistingComputerAccount = $ReuseExistingComputerAccount } return $returnValue @@ -160,6 +171,12 @@ function Get-TargetResource .PARAMETER Options Specifies advanced options for the Add-Computer join operation. + + .PARAMETER ReuseExistingComputerAccount + If $true, the existing computer account in the domain will be reused + instead of being deleted and recreated. This preserves the machine + SID, group memberships, and GPO links. If not specified or $false, + the existing account is deleted and recreated. #> function Set-TargetResource { @@ -203,7 +220,11 @@ function Set-TargetResource [Parameter()] [ValidateSet('AccountCreate', 'Win9XUpgrade', 'UnsecuredJoin', 'PasswordPass', 'JoinWithNewName', 'JoinReadOnly', 'InstallInvoke')] [System.String[]] - $Options + $Options, + + [Parameter()] + [System.Boolean] + $ReuseExistingComputerAccount ) Write-Verbose -Message ($script:localizedData.SettingComputerStateMessage -f $Name) @@ -262,13 +283,27 @@ function Set-TargetResource $addComputerParameters.Add("Server", $Server) } - # Check for existing computer objecst using ADSI without ActiveDirectory module + # Check for existing computer objects using ADSI without ActiveDirectory module $computerObject = Get-ADSIComputer -Name $Name -DomainName $DomainName -Credential $Credential - if ($computerObject) + if ($ReuseExistingComputerAccount) + { + if ($computerObject) + { + Write-Verbose -Message ($script:localizedData.ReusingExistingComputerAccount -f $Name, $computerObject.Path) + } + else + { + Write-Verbose -Message ($script:localizedData.NoExistingComputerAccountFound -f $Name) + } + } + else { - Remove-ADSIObject -Path $computerObject.Path -Credential $Credential - Write-Verbose -Message ($script:localizedData.DeletedExistingComputerObject -f $Name, $computerObject.Path) + if ($computerObject) + { + Remove-ADSIObject -Path $computerObject.Path -Credential $Credential + Write-Verbose -Message ($script:localizedData.DeletedExistingComputerObject -f $Name, $computerObject.Path) + } } if (-not [System.String]::IsNullOrEmpty($Options)) @@ -458,6 +493,12 @@ function Set-TargetResource .PARAMETER Options Specifies advanced options for the Add-Computer join operation. + + .PARAMETER ReuseExistingComputerAccount + If $true, the existing computer account in the domain will be reused + instead of being deleted and recreated. This preserves the machine + SID, group memberships, and GPO links. If not specified or $false, + the existing account is deleted and recreated. #> function Test-TargetResource { @@ -502,7 +543,11 @@ function Test-TargetResource [Parameter()] [ValidateSet('AccountCreate', 'Win9XUpgrade', 'UnsecuredJoin', 'PasswordPass', 'JoinWithNewName', 'JoinReadOnly', 'InstallInvoke')] [System.String[]] - $Options + $Options, + + [Parameter()] + [System.Boolean] + $ReuseExistingComputerAccount ) Write-Verbose -Message ($script:localizedData.TestingComputerStateMessage -f $Name) @@ -812,6 +857,12 @@ function Remove-ADSIObject .PARAMETER Options Specifies advanced options for the Add-Computer join operation. + + .PARAMETER ReuseExistingComputerAccount + If $true, the existing computer account in the domain will be reused + instead of being deleted and recreated. This preserves the machine + SID, group memberships, and GPO links. If not specified or $false, + the existing account is deleted and recreated. #> function Assert-ResourceProperty { @@ -855,7 +906,11 @@ function Assert-ResourceProperty [Parameter()] [ValidateSet('AccountCreate', 'Win9XUpgrade', 'UnsecuredJoin', 'PasswordPass', 'JoinWithNewName', 'JoinReadOnly', 'InstallInvoke')] [System.String[]] - $Options + $Options, + + [Parameter()] + [System.Boolean] + $ReuseExistingComputerAccount ) if ($options -contains 'PasswordPass' -and diff --git a/source/DSCResources/DSC_Computer/DSC_Computer.schema.mof b/source/DSCResources/DSC_Computer/DSC_Computer.schema.mof index 3c24d254..f0389b25 100644 --- a/source/DSCResources/DSC_Computer/DSC_Computer.schema.mof +++ b/source/DSCResources/DSC_Computer/DSC_Computer.schema.mof @@ -10,6 +10,7 @@ class DSC_Computer : OMI_BaseResource [Write, Description("The value assigned here will be set as the local computer description.")] String Description; [Write, Description("The Active Directory Domain Controller to use to join the domain")] String Server; [Write, Description("Specifies advanced options for the Add-Computer join operation"), ValueMap{"AccountCreate","Win9XUpgrade","UnsecuredJoin","PasswordPass","JoinWithNewName","JoinReadOnly","InstallInvoke"}, Values{"AccountCreate","Win9XUpgrade","UnsecuredJoin","PasswordPass","JoinWithNewName","JoinReadOnly","InstallInvoke"}] String Options[]; + [Write, Description("If true, the existing computer account in the domain will be reused instead of being deleted and recreated. This preserves the machine SID, group memberships, and GPO links. Default is false.")] Boolean ReuseExistingComputerAccount; [Read, Description("A read-only property that specifies the organizational unit that the computer account is currently in.")] String CurrentOU; }; diff --git a/source/DSCResources/DSC_Computer/README.md b/source/DSCResources/DSC_Computer/README.md index d23b215a..f284a5ad 100644 --- a/source/DSCResources/DSC_Computer/README.md +++ b/source/DSCResources/DSC_Computer/README.md @@ -2,3 +2,15 @@ The resource allows you to configure a computer by changing its name and description and modifying its Active Directory domain or workgroup membership. + +## Parameters + +### ReuseExistingComputerAccount + +When joining a domain, if a computer account with the same name already exists: + +- **Not specified or `$false`** (default): The existing computer account is + deleted and recreated. +- **`$true`**: The existing computer account is reused. This preserves the + machine SID, group memberships, GPO links, and other AD attributes. If no + existing account is found, a new one is created. diff --git a/source/DSCResources/DSC_Computer/en-US/DSC_Computer.strings.psd1 b/source/DSCResources/DSC_Computer/en-US/DSC_Computer.strings.psd1 index 22903a87..6b77c78a 100644 --- a/source/DSCResources/DSC_Computer/en-US/DSC_Computer.strings.psd1 +++ b/source/DSCResources/DSC_Computer/en-US/DSC_Computer.strings.psd1 @@ -17,6 +17,8 @@ ConvertFrom-StringData @' DomainNameAndWorkgroupNameError = Only DomainName or WorkGroupName can be specified at once. ComputerNotInDomainMessage = This machine is not a domain member. DeletedExistingComputerObject = Deleted existing computer object with name '{0}' at path '{1}'. + ReusingExistingComputerAccount = Reusing existing computer account for '{0}' at path '{1}'. + NoExistingComputerAccountFound = No existing computer account found for '{0}'. A new account will be created during join. InvalidOptionPasswordPassUnsecuredJoin = Domain Join option 'PasswordPass' may not be specified if 'UnsecuredJoin' is specified. InvalidOptionCredentialUnsecuredJoinNullUsername = 'Credential' username must be null if 'UnsecuredJoin' is specified. '@ diff --git a/source/Examples/Resources/Computer/8-Computer_JoinDomainKeepExistingAccount_Config.ps1 b/source/Examples/Resources/Computer/8-Computer_JoinDomainKeepExistingAccount_Config.ps1 new file mode 100644 index 00000000..1bc49a09 --- /dev/null +++ b/source/Examples/Resources/Computer/8-Computer_JoinDomainKeepExistingAccount_Config.ps1 @@ -0,0 +1,50 @@ +<#PSScriptInfo +.VERSION 1.0.0 +.GUID 3b2e8a4c-7f1d-4e2a-9c6b-8d5f3a1e7b9d +.AUTHOR DSC Community +.COMPANYNAME DSC Community +.COPYRIGHT Copyright the DSC Community contributors. All rights reserved. +.TAGS DSCConfiguration +.LICENSEURI https://github.com/dsccommunity/ComputerManagementDsc/blob/main/LICENSE +.PROJECTURI https://github.com/dsccommunity/ComputerManagementDsc +.ICONURI +.EXTERNALMODULEDEPENDENCIES +.REQUIREDSCRIPTS +.EXTERNALSCRIPTDEPENDENCIES +.RELEASENOTES First version. +.PRIVATEDATA 2016-Datacenter,2016-Datacenter-Server-Core +#> + +#Requires -module ComputerManagementDsc + +<# + .DESCRIPTION + This configuration sets the machine name to 'Server01' and + joins the 'Contoso' domain while reusing any existing computer + account in Active Directory. This preserves the machine SID, + group memberships, and GPO links. + Note: this requires an AD credential to join the domain. +#> +Configuration Computer_JoinDomainKeepExistingAccount_Config +{ + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNullorEmpty()] + [System.Management.Automation.PSCredential] + $Credential + ) + + Import-DscResource -Module ComputerManagementDsc + + Node localhost + { + Computer JoinDomain + { + Name = 'Server01' + DomainName = 'Contoso' + Credential = $Credential # Credential to join to domain + ReuseExistingComputerAccount = $true # Reuse existing AD computer account + } + } +} diff --git a/tests/Unit/DSC_Computer.Tests.ps1 b/tests/Unit/DSC_Computer.Tests.ps1 index 0b5ca585..f8c1c514 100644 --- a/tests/Unit/DSC_Computer.Tests.ps1 +++ b/tests/Unit/DSC_Computer.Tests.ps1 @@ -723,7 +723,7 @@ Describe 'DSC_Computer\Get-TargetResource' { $result = Get-TargetResource @getTargetParams $result.GetType().Fullname | Should -Be 'System.Collections.Hashtable' - $result.Keys | Sort-Object | Should -Be @('Credential', 'CurrentOU', 'Description', 'DomainName', 'JoinOU', 'Name', 'Server', 'UnjoinCredential', 'WorkGroupName') + $result.Keys | Sort-Object | Should -Be @('Credential', 'CurrentOU', 'Description', 'DomainName', 'JoinOU', 'Name', 'ReuseExistingComputerAccount', 'Server', 'UnjoinCredential', 'WorkGroupName') } } } @@ -862,6 +862,91 @@ Describe 'DSC_Computer\Set-TargetResource' { } } + Context 'When ComputerName changes and Domain changes to new Domain with ReuseExistingComputerAccount' { + BeforeAll { + Mock -CommandName Get-WMIObject -MockWith { + [PSCustomObject] @{ + Domain = 'Contoso.com'; + Workgroup = 'Contoso.com'; + PartOfDomain = $true + } + } + + Mock -CommandName Get-ADSIComputer -MockWith { + [PSCustomObject] @{ + Path = 'LDAP://Contoso.com/CN=mocked-comp,OU=Computers,DC=Contoso,DC=com'; + } + } + + Mock -CommandName Get-ComputerDomain -MockWith { + 'contoso.com' + } + + Mock -CommandName Add-Computer + } + + It 'Should not delete the existing computer account' { + InModuleScope -ScriptBlock { + Set-StrictMode -Version 1.0 + + $setTargetParams = @{ + Name = 'othername' + DomainName = 'adventure-works.com' + Credential = $credential + UnjoinCredential = $credential + ReuseExistingComputerAccount = $true + } + + Set-TargetResource @setTargetParams | Should -BeNullOrEmpty + } + + Should -Invoke -CommandName Rename-Computer -Exactly -Times 0 -Scope It + Should -Invoke -CommandName Add-Computer -Exactly -Times 1 -Scope It -ParameterFilter { $DomainName -and $NewName } + Should -Invoke -CommandName Add-Computer -Exactly -Times 0 -Scope It -ParameterFilter { $WorkGroupName } + Should -Invoke -CommandName Get-ADSIComputer -Exactly -Times 1 -Scope It + Should -Invoke -CommandName Remove-ADSIObject -Exactly -Times 0 -Scope It + } + } + + Context 'When ComputerName changes and Domain changes with ReuseExistingComputerAccount but no existing account' { + BeforeAll { + Mock -CommandName Get-WMIObject -MockWith { + [PSCustomObject] @{ + Domain = 'Contoso.com'; + Workgroup = 'Contoso.com'; + PartOfDomain = $true + } + } + + Mock -CommandName Get-ADSIComputer + Mock -CommandName Get-ComputerDomain -MockWith { + 'contoso.com' + } + + Mock -CommandName Add-Computer + } + + It 'Should not call Remove-ADSIObject' { + InModuleScope -ScriptBlock { + Set-StrictMode -Version 1.0 + + $setTargetParams = @{ + Name = 'othername' + DomainName = 'adventure-works.com' + Credential = $credential + UnjoinCredential = $credential + ReuseExistingComputerAccount = $true + } + + Set-TargetResource @setTargetParams | Should -BeNullOrEmpty + } + + Should -Invoke -CommandName Add-Computer -Exactly -Times 1 -Scope It + Should -Invoke -CommandName Get-ADSIComputer -Exactly -Times 1 -Scope It + Should -Invoke -CommandName Remove-ADSIObject -Exactly -Times 0 -Scope It + } + } + Context 'When ComputerName changes and Domain changes to new Domain with specified OU' { BeforeAll { Mock -CommandName Get-WMIObject -MockWith { @@ -988,6 +1073,51 @@ Describe 'DSC_Computer\Set-TargetResource' { } } + Context 'When ComputerName changes and Workgroup changes to Domain with ReuseExistingComputerAccount' { + BeforeAll { + Mock -CommandName Get-WMIObject -MockWith { + [PSCustomObject] @{ + Domain = 'Contoso'; + Workgroup = 'Contoso'; + PartOfDomain = $false + } + } + + Mock -CommandName Get-ADSIComputer -MockWith { + [PSCustomObject] @{ + Path = 'LDAP://Contoso.com/CN=mocked-comp,OU=Computers,DC=Contoso,DC=com'; + } + } + + Mock -CommandName Get-ComputerDomain -MockWith { + '' + } + + Mock -CommandName Add-Computer + } + + It 'Should not delete the existing computer account' { + InModuleScope -ScriptBlock { + Set-StrictMode -Version 1.0 + + $setTargetParams = @{ + Name = 'othername' + DomainName = 'Contoso.com' + Credential = $credential + ReuseExistingComputerAccount = $true + } + + Set-TargetResource @setTargetParams | Should -BeNullOrEmpty + } + + Should -Invoke -CommandName Rename-Computer -Exactly -Times 0 -Scope It + Should -Invoke -CommandName Add-Computer -Exactly -Times 1 -Scope It -ParameterFilter { $DomainName -and $NewName } + Should -Invoke -CommandName Add-Computer -Exactly -Times 0 -Scope It -ParameterFilter { $WorkGroupName } + Should -Invoke -CommandName Get-ADSIComputer -Exactly -Times 1 -Scope It + Should -Invoke -CommandName Remove-ADSIObject -Exactly -Times 0 -Scope It + } + } + Context 'When ComputerName changes and Workgroup changes to Domain with specified Domain Controller' { BeforeAll { Mock -CommandName Get-WMIObject -MockWith {