From 438d2c29a938f1c4e2b5a1e916bd6205fd950c13 Mon Sep 17 00:00:00 2001 From: Nick G Date: Thu, 2 Jun 2022 03:15:51 -0400 Subject: [PATCH] Computer: Delete existing AD Computer object when joining Computer to Domain (#386) --- CHANGELOG.md | 2 + .../DSC_Computer/DSC_Computer.psm1 | 107 ++++++++- .../en-US/DSC_Computer.strings.psd1 | 1 + tests/Unit/DSC_Computer.Tests.ps1 | 218 ++++++++++++++++++ 4 files changed, 326 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7687e846..5f4b0414 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added + - Computer - Support Options Parameter for domain join - Fixes [Issue #234](https://github.com/dsccommunity/ComputerManagementDsc/issues/234). + - When joining a computer to a domain, existing AD computer objects will be deleted - Fixes [Issue #55](https://github.com/dsccommunity/ComputerManagementDsc/issues/55), [Issue #58](https://github.com/dsccommunity/ComputerManagementDsc/issues/58). ## [8.5.0] - 2021-09-13 diff --git a/source/DSCResources/DSC_Computer/DSC_Computer.psm1 b/source/DSCResources/DSC_Computer/DSC_Computer.psm1 index 0c76278b..141a91e6 100644 --- a/source/DSCResources/DSC_Computer/DSC_Computer.psm1 +++ b/source/DSCResources/DSC_Computer/DSC_Computer.psm1 @@ -263,6 +263,15 @@ function Set-TargetResource $addComputerParameters.Add("Server", $Server) } + # Check for existing computer objecst using ADSI without ActiveDirectory module + $computerObject = Get-ADSIComputer -Name $Name -DomainName $DomainName -Credential $Credential + + if ($computerObject) + { + Delete-ADSIObject -Path $computerObject.Path -Credential $Credential + Write-Verbose -Message ($script:localizedData.DeletedExistingComputerObject -f $Name, $computerObject.Path) + } + if (-not [System.String]::IsNullOrEmpty($Options)) { <# @@ -680,7 +689,100 @@ function Get-LogonServer return $logonserver } -Export-ModuleMember -Function *-TargetResource +<# + .SYNOPSIS + Returns an ADSI Computer Object. + + .PARAMETER Name + Name of the computer to search for in the given domain. + + .PARAMETER Domain + Domain to search. + + .PARAMETER Credential + Credential to search domain with. +#> +function Get-ADSIComputer +{ + [CmdletBinding()] + [OutputType([System.DirectoryServices.SearchResult])] + param + ( + [Parameter(Mandatory = $true)] + [ValidateLength(1, 15)] + [ValidateScript( { $_ -inotmatch '[\/\\:*?"<>|]' })] + [System.String] + $Name, + + [Parameter(Mandatory = $true)] + [System.String] + $DomainName, + + [Parameter(Mandatory = $true)] + [System.Management.Automation.PSCredential] + $Credential + ) + + $searcher = New-Object -TypeName System.DirectoryServices.DirectorySearcher + $searcher.Filter = "(&(objectCategory=computer)(objectClass=computer)(cn=$Name))" + if ($DomainName -notlike "LDAP://*") + { + $DomainName = "LDAP://$DomainName" + } + + $params = @{ + TypeName = 'System.DirectoryServices.DirectoryEntry' + ArgumentList = @( + $DomainName, + $Credential.UserName, + $Credential.GetNetworkCredential().password + ) + ErrorAction = 'Stop' + } + $searchRoot = New-Object @params + $searcher.SearchRoot = $searchRoot + + return $searcher.FindOne() +} + +<# + .SYNOPSIS + Deletes an ADSI DirectoryEntry Object. + + .PARAMETER Path + Path to Object to delete. + + .PARAMETER Credential + Credential to authenticate to the domain. +#> +function Delete-ADSIObject +{ + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [ValidateScript( { $_ -imatch "LDAP://*" })] + [System.String] + $Path, + + [Parameter(Mandatory = $true)] + [System.Management.Automation.PSCredential] + $Credential + ) + + $params = @{ + TypeName = 'System.DirectoryServices.DirectoryEntry' + ArgumentList = @( + $DomainName, + $Credential.UserName + $Credential.GetNetworkCredential().password + ) + ErrorAction = 'Stop' + } + $adsiObj = New-Object @params + + $adsiObj.DeleteTree() +} <# .SYNOPSIS @@ -774,5 +876,6 @@ function Assert-ResourceProperty -Message $script:localizedData.InvalidOptionCredentialUnsecuredJoinNullUsername ` -ArgumentName 'Credential' } - } + +Export-ModuleMember -Function *-TargetResource 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 83cf81f7..22903a87 100644 --- a/source/DSCResources/DSC_Computer/en-US/DSC_Computer.strings.psd1 +++ b/source/DSCResources/DSC_Computer/en-US/DSC_Computer.strings.psd1 @@ -16,6 +16,7 @@ ConvertFrom-StringData @' CheckingWorkgroupMemberMessage = Checking if the machine is a member of workgroup '{0}'. 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}'. 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/tests/Unit/DSC_Computer.Tests.ps1 b/tests/Unit/DSC_Computer.Tests.ps1 index 26c96d18..76f9862b 100644 --- a/tests/Unit/DSC_Computer.Tests.ps1 +++ b/tests/Unit/DSC_Computer.Tests.ps1 @@ -485,6 +485,8 @@ try Context 'DSC_Computer\Set-TargetResource' { Mock -CommandName Rename-Computer Mock -CommandName Set-CimInstance + Mock -CommandName Get-ADSIComputer + Mock -CommandName Delete-ADSIObject It 'Throws if both DomainName and WorkGroupName are specified' { $errorRecord = Get-InvalidOperationRecord ` @@ -531,6 +533,12 @@ try } } + 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' } @@ -547,6 +555,8 @@ try Assert-MockCalled -CommandName Rename-Computer -Exactly -Times 0 -Scope It Assert-MockCalled -CommandName Add-Computer -Exactly -Times 1 -Scope It -ParameterFilter { $DomainName -and $NewName } Assert-MockCalled -CommandName Add-Computer -Exactly -Times 0 -Scope It -ParameterFilter { $WorkGroupName } + Assert-MockCalled -CommandName Get-ADSIComputer -Exactly -Times 1 -Scope It + Assert-MockCalled -CommandName Delete-ADSIObject -Exactly -Times 1 -Scope It } It 'Changes ComputerName and changes Domain to new Domain with specified OU' { @@ -562,6 +572,12 @@ try 'contoso.com' } + Mock -CommandName Get-ADSIComputer -MockWith { + [PSCustomObject] @{ + Path = 'LDAP://Contoso.com/CN=mocked-comp,OU=Computers,DC=Contoso,DC=com'; + } + } + Mock -CommandName Add-Computer Set-TargetResource ` @@ -575,6 +591,8 @@ try Assert-MockCalled -CommandName Rename-Computer -Exactly -Times 0 -Scope It Assert-MockCalled -CommandName Add-Computer -Exactly -Times 1 -Scope It -ParameterFilter { $DomainName -and $NewName } Assert-MockCalled -CommandName Add-Computer -Exactly -Times 0 -Scope It -ParameterFilter { $WorkGroupName } + Assert-MockCalled -CommandName Get-ADSIComputer -Exactly -Times 1 -Scope It + Assert-MockCalled -CommandName Delete-ADSIObject -Exactly -Times 1 -Scope It } It 'Changes ComputerName and changes Domain to Workgroup' { @@ -612,6 +630,12 @@ try } } + Mock -CommandName Get-ADSIComputer -MockWith { + [PSCustomObject] @{ + Path = 'LDAP://Contoso.com/CN=mocked-comp,OU=Computers,DC=Contoso,DC=com'; + } + } + Mock -CommandName Get-ComputerDomain -MockWith { '' } @@ -627,6 +651,8 @@ try Assert-MockCalled -CommandName Rename-Computer -Exactly -Times 0 -Scope It Assert-MockCalled -CommandName Add-Computer -Exactly -Times 1 -Scope It -ParameterFilter { $DomainName -and $NewName } Assert-MockCalled -CommandName Add-Computer -Exactly -Times 0 -Scope It -ParameterFilter { $WorkGroupName } + Assert-MockCalled -CommandName Get-ADSIComputer -Exactly -Times 1 -Scope It + Assert-MockCalled -CommandName Delete-ADSIObject -Exactly -Times 1 -Scope It } It 'Changes ComputerName and changes Workgroup to Domain with specified Domain Controller' { @@ -638,6 +664,12 @@ try } } + Mock -CommandName Get-ADSIComputer -MockWith { + [PSCustomObject] @{ + Path = 'LDAP://Contoso.com/CN=mocked-comp,OU=Computers,DC=Contoso,DC=com'; + } + } + Mock -CommandName Get-ComputerDomain -MockWith { '' } @@ -654,6 +686,8 @@ try Assert-MockCalled -CommandName Rename-Computer -Exactly -Times 0 -Scope It Assert-MockCalled -CommandName Add-Computer -Exactly -Times 1 -Scope It -ParameterFilter { $DomainName -and $NewName -and $Server } Assert-MockCalled -CommandName Add-Computer -Exactly -Times 0 -Scope It -ParameterFilter { $WorkGroupName } + Assert-MockCalled -CommandName Get-ADSIComputer -Exactly -Times 1 -Scope It + Assert-MockCalled -CommandName Delete-ADSIObject -Exactly -Times 1 -Scope It } It 'Changes ComputerName and changes Workgroup to Domain with specified OU' { @@ -665,6 +699,12 @@ try } } + Mock -CommandName Get-ADSIComputer -MockWith { + [PSCustomObject] @{ + Path = 'LDAP://Contoso.com/CN=mocked-comp,OU=Computers,DC=Contoso,DC=com'; + } + } + Mock -CommandName Get-ComputerDomain -MockWith { '' } @@ -681,6 +721,8 @@ try Assert-MockCalled -CommandName Rename-Computer -Exactly -Times 0 -Scope It Assert-MockCalled -CommandName Add-Computer -Exactly -Times 1 -Scope It -ParameterFilter { $DomainName -and $NewName } Assert-MockCalled -CommandName Add-Computer -Exactly -Times 0 -Scope It -ParameterFilter { $WorkGroupName } + Assert-MockCalled -CommandName Get-ADSIComputer -Exactly -Times 1 -Scope It + Assert-MockCalled -CommandName Delete-ADSIObject -Exactly -Times 1 -Scope It } It 'Changes ComputerName and changes Domain to new Domain with Options passed' { @@ -726,6 +768,10 @@ try } } + Mock -CommandName Get-ADSIComputer -MockWith { + $null + } + Mock -CommandName Get-ComputerDomain -MockWith { '' } @@ -743,6 +789,8 @@ try Assert-MockCalled -CommandName Rename-Computer -Exactly -Times 1 -Scope It Assert-MockCalled -CommandName Add-Computer -Exactly -Times 1 -Scope It -ParameterFilter { $DomainName -and $NewName } Assert-MockCalled -CommandName Add-Computer -Exactly -Times 0 -Scope It -ParameterFilter { $WorkGroupName } + Assert-MockCalled -CommandName Get-ADSIComputer -Exactly -Times 1 -Scope It + Assert-MockCalled -CommandName Delete-ADSIObject -Exactly -Times 0 -Scope It } It 'Should Throw the correct error if Add-Computer errors with an unknown InvalidOperationException' { @@ -813,6 +861,8 @@ try Assert-MockCalled -CommandName Rename-Computer -Exactly -Times 0 -Scope It Assert-MockCalled -CommandName Add-Computer -Exactly -Times 1 -Scope It -ParameterFilter { $DomainName -and $NewName } Assert-MockCalled -CommandName Add-Computer -Exactly -Times 0 -Scope It -ParameterFilter { $WorkGroupName } + Assert-MockCalled -CommandName Get-ADSIComputer -Exactly -Times 1 -Scope It + Assert-MockCalled -CommandName Delete-ADSIObject -Exactly -Times 0 -Scope It } It 'Changes ComputerName and changes Workgroup to new Workgroup' { @@ -866,6 +916,8 @@ try Assert-MockCalled -CommandName Add-Computer -Exactly -Times 1 -Scope It -ParameterFilter { $DomainName } Assert-MockCalled -CommandName Add-Computer -Exactly -Times 0 -Scope It -ParameterFilter { $NewName } Assert-MockCalled -CommandName Add-Computer -Exactly -Times 0 -Scope It -ParameterFilter { $WorkGroupName } + Assert-MockCalled -CommandName Get-ADSIComputer -Exactly -Times 1 -Scope It + Assert-MockCalled -CommandName Delete-ADSIObject -Exactly -Times 0 -Scope It } It 'Changes only the Domain to new Domain when name is [localhost]' { @@ -894,6 +946,8 @@ try Assert-MockCalled -CommandName Add-Computer -Exactly -Times 1 -Scope It -ParameterFilter { $DomainName } Assert-MockCalled -CommandName Add-Computer -Exactly -Times 0 -Scope It -ParameterFilter { $NewName } Assert-MockCalled -CommandName Add-Computer -Exactly -Times 0 -Scope It -ParameterFilter { $WorkGroupName } + Assert-MockCalled -CommandName Get-ADSIComputer -Exactly -Times 1 -Scope It + Assert-MockCalled -CommandName Delete-ADSIObject -Exactly -Times 0 -Scope It } It 'Changes only the Domain to new Domain with specified OU' { @@ -923,6 +977,8 @@ try Assert-MockCalled -CommandName Add-Computer -Exactly -Times 1 -Scope It -ParameterFilter { $DomainName } Assert-MockCalled -CommandName Add-Computer -Exactly -Times 0 -Scope It -ParameterFilter { $NewName } Assert-MockCalled -CommandName Add-Computer -Exactly -Times 0 -Scope It -ParameterFilter { $WorkGroupName } + Assert-MockCalled -CommandName Get-ADSIComputer -Exactly -Times 1 -Scope It + Assert-MockCalled -CommandName Delete-ADSIObject -Exactly -Times 0 -Scope It } It 'Changes only the Domain to new Domain with specified OU when Name is [localhost]' { @@ -952,6 +1008,8 @@ try Assert-MockCalled -CommandName Add-Computer -Exactly -Times 1 -Scope It -ParameterFilter { $DomainName } Assert-MockCalled -CommandName Add-Computer -Exactly -Times 0 -Scope It -ParameterFilter { $NewName } Assert-MockCalled -CommandName Add-Computer -Exactly -Times 0 -Scope It -ParameterFilter { $WorkGroupName } + Assert-MockCalled -CommandName Get-ADSIComputer -Exactly -Times 1 -Scope It + Assert-MockCalled -CommandName Delete-ADSIObject -Exactly -Times 0 -Scope It } It 'Changes only Domain to Workgroup' { @@ -1208,6 +1266,166 @@ try } } + Context 'DSC_Computer\Get-ADSIComputer' { + class fake_adsi_directoryentry { + [string] $Domain + [string] $Username + [string] $password + } + + class fake_adsi_searcher { + [string] $SearchRoot + [string] $Filter + [hashtable] FindOne( ){ + return @{ + path = 'LDAP://contoso.com/CN=fake-computer,OU=Computers,DC=contoso,DC=com' + } + } + } + + Mock -CommandName New-Object -MockWith { + New-Object -TypeName 'fake_adsi_searcher' + } ` + -ParameterFilter { + $TypeName -and + $TypeName -eq 'System.DirectoryServices.DirectorySearcher' + } + + $message = "Cannot validate argument on parameter 'Name'. The character length of the 17 argument is too long. Shorten the character length of the argument so it is fewer than or equal to `"15`" characters, and then try the command again." + It 'Should throw the expected exception if the name is to long' { + { + $error = Get-ADSIComputer ` + -Name 'ThisNameIsTooLong' ` + -Domain 'Contoso.com' ` + -Credential $credential ` + -Verbose + $error + } | Should -Throw $message + } + + $message = "Cannot validate argument on parameter 'Name'. The `" `$_ -inotmatch '[\/\\:*?`"<>|]' `" validation script for the argument with value `"IllegalName[<`" did not return a result of True. Determine why the validation script failed, and then try the command again." + It 'Should throws if the expected exception if the name contains illegal characters' { + { + Get-ADSIComputer ` + -Name 'IllegalName[<' ` + -Domain 'Contoso.com' ` + -Credential $credential ` + -Verbose + } | Should -Throw $message + } + + It 'Returns ADSI object with ADSI path ' { + Mock -CommandName New-Object -MockWith { + New-Object -TypeName 'fake_adsi_directoryentry' + } ` + -ParameterFilter { + $TypeName -and + $TypeName -eq 'System.DirectoryServices.DirectoryEntry' + } + + $obj = Get-ADSIComputer ` + -Name 'LegalName' ` + -Domain 'LDAP://Contoso.com' ` + -Credential $credential ` + -Verbose + $obj.path | Should -Be 'LDAP://contoso.com/CN=fake-computer,OU=Computers,DC=contoso,DC=com' + Assert-MockCalled -CommandName New-Object -Exactly -Times 2 -Scope It + } + + It 'Returns ADSI object with domain name' { + + Mock -CommandName New-Object -MockWith { + New-Object -TypeName 'fake_adsi_searcher' + } ` + -ParameterFilter { + $TypeName -and + $TypeName -eq 'System.DirectoryServices.DirectorySearcher' + } + + $obj = Get-ADSIComputer ` + -Name 'LegalName' ` + -Domain 'Contoso.com' ` + -Credential $credential ` + -Verbose + $obj.Path | Should -Be 'LDAP://contoso.com/CN=fake-computer,OU=Computers,DC=contoso,DC=com' + Assert-MockCalled -CommandName New-Object -Exactly -Times 2 -Scope It + } + + It 'Should throw the expected exception if Credential is incorrect' { + Mock -CommandName New-Object -MockWith { + Write-Error -message 'Invalid Credentials' + } ` + -ParameterFilter { + $TypeName -and + $TypeName -eq 'System.DirectoryServices.DirectoryEntry' + } + + { + Get-ADSIComputer ` + -Name 'LegalName' ` + -Domain 'Contoso.com' ` + -Credential $credential ` + -Verbose + } | Should -Throw 'Invalid Credentials' + Assert-MockCalled -CommandName New-Object -Exactly -Times 2 -Scope It + } + } + + Context 'DSC_Computer\Delete-ADSIObject' { + class fake_adsi_directoryentry { + [string] $Domain + [string] $Username + [string] $password + [void] DeleteTree(){ } + } + + It 'Should delete the ADSI Object' { + Mock -CommandName New-Object -MockWith { + New-Object 'fake_adsi_directoryentry' + } ` + -ParameterFilter { + $TypeName -and + $TypeName -eq 'System.DirectoryServices.DirectoryEntry' + } + + { + Delete-ADSIObject ` + -Path 'LDAP://contoso.com/CN=fake-computer,OU=Computers,DC=contoso,DC=com' ` + -Credential $credential ` + -Verbose + } | Should -Not -Throw + Assert-MockCalled -CommandName New-Object -Exactly -Times 1 -Scope It + } + + $message = "Cannot validate argument on parameter 'Path'. The `" `$_ -imatch `"LDAP://*`" `" validation script for the argument with value `"contoso.com/CN=fake-computer,OU=Computers,DC=contoso,DC=com`" did not return a result of True. Determine why the validation script failed, and then try the command again." + It 'Should throw if path does not begin with LDAP://' { + { + Delete-ADSIObject ` + -Path 'contoso.com/CN=fake-computer,OU=Computers,DC=contoso,DC=com' ` + -Credential $credential` + -Verbose + } | Should -Throw $message + } + + It 'Should throw the expected exception if Credential is incorrect' { + Mock -CommandName New-Object -MockWith { + Write-Error -message 'Invalid Credential' + } ` + -ParameterFilter { + $TypeName -and + $TypeName -eq 'System.DirectoryServices.DirectoryEntry' + } + + { + Delete-ADSIObject ` + -Path 'LDAP://contoso.com/CN=fake-computer,OU=Computers,DC=contoso,DC=com' ` + -Credential $credential ` + -Verbose + } | Should -Throw 'Invalid Credential' + Assert-MockCalled -CommandName New-Object -Exactly -Times 1 -Scope It + } + } + Context 'DSC_Computer\Assert-ResourceProperty' { It 'Should throw if PasswordPass and UnsecuredJoin is present but credential username is not null' { $errorRecord = Get-InvalidArgumentRecord `