From 58d8dfa9c4818a741552447ec85b6ce1f11102fd Mon Sep 17 00:00:00 2001 From: Svilen Date: Sat, 25 May 2019 11:24:20 +0200 Subject: [PATCH] xADUser: Adding ServicePrincipalNames property (#274) - Changes to xADUser - Added ServicePrincipalNames Property (issue #153). --- CHANGELOG.md | 3 + DSCResources/MSFT_xADUser/MSFT_xADUser.psm1 | 101 ++++++++---- .../MSFT_xADUser/MSFT_xADUser.schema.mof | 3 +- README.md | 3 +- Tests/Unit/MSFT_xADUser.Tests.ps1 | 146 +++++++++++++++--- 5 files changed, 206 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ffbdb68a..c699852a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- Changes to xADUser + - Added ServicePrincipalNames Property + - Changes to xActiveDirectory - Added new helper functions in xADCommon, see each functions comment-based help for more information. diff --git a/DSCResources/MSFT_xADUser/MSFT_xADUser.psm1 b/DSCResources/MSFT_xADUser/MSFT_xADUser.psm1 index ea9c811ea..f094ad1e3 100644 --- a/DSCResources/MSFT_xADUser/MSFT_xADUser.psm1 +++ b/DSCResources/MSFT_xADUser/MSFT_xADUser.psm1 @@ -78,6 +78,7 @@ $adPropertyMap = @( @{ Parameter = 'PasswordNeverExpires'; UseCmdletParameter = $true; } @{ Parameter = 'CannotChangePassword'; UseCmdletParameter = $true; } @{ Parameter = 'TrustedForDelegation'; UseCmdletParameter = $true; } + @{ Parameter = 'ServicePrincipalNames'; } ) function Get-TargetResource @@ -301,7 +302,7 @@ function Get-TargetResource [System.String] $HomePhone, - # Specifies the user's pager number (ldapDisplayName 'pager') + # Specifies the user's pager number (ldapDisplayName 'pager') [Parameter()] [ValidateNotNull()] [System.String] @@ -358,7 +359,7 @@ function Get-TargetResource # Specifies the authentication context type when testing user passwords #61 [Parameter()] - [ValidateSet('Default','Negotiate')] + [ValidateSet('Default', 'Negotiate')] [System.String] $PasswordAuthentication = 'Default', @@ -372,7 +373,13 @@ function Get-TargetResource [Parameter()] [ValidateNotNull()] [System.Boolean] - $RestoreFromRecycleBin + $RestoreFromRecycleBin, + + # Specifies the service principal names registered on the user account + [Parameter()] + [ValidateNotNull()] + [System.String[]] + $ServicePrincipalNames ) Assert-Module -ModuleName 'ActiveDirectory'; @@ -431,6 +438,9 @@ function Get-TargetResource $targetResource['Path'] = Get-ADObjectParentDN -DN $adUser.DistinguishedName; } } + elseif (($property.Parameter) -eq 'ServicePrincipalNames') { + $targetResource['ServicePrincipalNames'] = [System.String[]]$adUser.ServicePrincipalNames + } elseif ($property.ADProperty) { # The AD property name is different to the function parameter to use this @@ -667,7 +677,7 @@ function Test-TargetResource [System.String] $HomePhone, - # Specifies the user's pager number (ldapDisplayName 'pager') + # Specifies the user's pager number (ldapDisplayName 'pager') [Parameter()] [ValidateNotNull()] [System.String] @@ -724,7 +734,7 @@ function Test-TargetResource # Specifies the authentication context type when testing user passwords #61 [Parameter()] - [ValidateSet('Default','Negotiate')] + [ValidateSet('Default', 'Negotiate')] [System.String] $PasswordAuthentication = 'Default', @@ -738,7 +748,13 @@ function Test-TargetResource [Parameter()] [ValidateNotNull()] [System.Boolean] - $RestoreFromRecycleBin + $RestoreFromRecycleBin, + + # Specifies the service principal names registered on the user account + [Parameter()] + [ValidateNotNull()] + [System.String[]] + $ServicePrincipalNames ) Assert-Parameters @PSBoundParameters; @@ -764,9 +780,9 @@ function Test-TargetResource if ($parameter -eq 'Password' -and $PasswordNeverResets -eq $false) { $testPasswordParams = @{ - Username = $UserName; - Password = $Password; - DomainName = $DomainName; + Username = $UserName; + Password = $Password; + DomainName = $DomainName; PasswordAuthentication = $PasswordAuthentication; } if ($DomainAdministratorCredential) @@ -787,6 +803,21 @@ function Test-TargetResource { # Both values are null/empty and therefore we are compliant } + elseif ($parameter -eq 'ServicePrincipalNames') + { + $testMembersParams = @{ + ExistingMembers = $targetResource.ServicePrincipalNames -as [System.String[]]; + Members = $ServicePrincipalNames; + } + if (-not (Test-Members @testMembersParams)) + { + $existingSPNs = $testMembersParams['ExistingMembers'] -join ','; + $desiredSPNs = $ServicePrincipalNames -join ','; + Write-Verbose -Message ($LocalizedData.ADUserNotDesiredPropertyState -f ` + 'ServicePrincipalNames', $desiredSPNs, $existingSPNs); + $isCompliant = $false; + } + } elseif ($PSBoundParameters.$parameter -ne $targetResource.$parameter) { Write-Verbose -Message ($LocalizedData.ADUserNotDesiredPropertyState -f $parameter, $PSBoundParameters.$parameter, $targetResource.$parameter); @@ -1020,7 +1051,7 @@ function Set-TargetResource [System.String] $HomePhone, - # Specifies the user's pager number (ldapDisplayName 'pager') + # Specifies the user's pager number (ldapDisplayName 'pager') [Parameter()] [ValidateNotNull()] [System.String] @@ -1077,7 +1108,7 @@ function Set-TargetResource # Specifies the authentication context type when testing user passwords #61 [Parameter()] - [ValidateSet('Default','Negotiate')] + [ValidateSet('Default', 'Negotiate')] [System.String] $PasswordAuthentication = 'Default', @@ -1091,7 +1122,13 @@ function Set-TargetResource [Parameter()] [ValidateNotNull()] [System.Boolean] - $RestoreFromRecycleBin + $RestoreFromRecycleBin, + + # Specifies the service principal names registered on the user account + [Parameter()] + [ValidateNotNull()] + [System.String[]] + $ServicePrincipalNames ) Assert-Parameters @PSBoundParameters; @@ -1106,7 +1143,7 @@ function Set-TargetResource if ($targetResource.Ensure -eq 'Absent') { # Try to restore account if it exists - if($RestoreFromRecycleBin) + if ($RestoreFromRecycleBin) { Write-Verbose -Message ($LocalizedData.RestoringUser -f $UserName) $restoreParams = Get-ADCommonParameters @PSBoundParameters @@ -1134,8 +1171,8 @@ function Set-TargetResource } $setADUserParams = Get-ADCommonParameters @PSBoundParameters; - $replaceUserProperties = @{}; - $removeUserProperties = @{}; + $replaceUserProperties = @{ }; + $removeUserProperties = @{ }; foreach ($parameter in $PSBoundParameters.Keys) { # Only check/action properties specified/declared parameters that match one of the function's @@ -1172,6 +1209,12 @@ function Set-TargetResource # we will change this as it is out of compliance (it always gets set anyway) Write-Verbose -Message ($LocalizedData.UpdatingADUserProperty -f $parameter, $PSBoundParameters.$parameter); } + elseif ($parameter -eq 'ServicePrincipalNames') + { + Write-Verbose -Message ($LocalizedData.UpdatingADUserProperty -f ` + 'ServicePrincipalNames', ($ServicePrincipalNames -join ',')); + $replaceUserProperties['ServicePrincipalName'] = $ServicePrincipalNames; + } elseif ($PSBoundParameters.$parameter -ne $targetResource.$parameter) { # Find the associated AD property @@ -1273,7 +1316,7 @@ function Assert-Parameters if (($PSBoundParameters.ContainsKey('Password')) -and ($Enabled -eq $false)) { $throwInvalidArgumentErrorParams = @{ - ErrorId = 'xADUser_DisabledAccountPasswordConflict'; + ErrorId = 'xADUser_DisabledAccountPasswordConflict'; ErrorMessage = $LocalizedData.PasswordParameterConflictError -f 'Enabled', $false, 'Password'; } ThrowInvalidArgumentError @throwInvalidArgumentErrorParams; @@ -1308,7 +1351,7 @@ function Test-Password # Specifies the authentication context type when testing user passwords #61 [Parameter(Mandatory = $true)] - [ValidateSet('Default','Negotiate')] + [ValidateSet('Default', 'Negotiate')] [System.String] $PasswordAuthentication ) @@ -1319,20 +1362,20 @@ function Test-Password if ($DomainAdministratorCredential) { $principalContext = New-Object System.DirectoryServices.AccountManagement.PrincipalContext( - [System.DirectoryServices.AccountManagement.ContextType]::Domain, - $DomainName, - $DomainAdministratorCredential.UserName, - $DomainAdministratorCredential.GetNetworkCredential().Password - ); + [System.DirectoryServices.AccountManagement.ContextType]::Domain, + $DomainName, + $DomainAdministratorCredential.UserName, + $DomainAdministratorCredential.GetNetworkCredential().Password + ); } else { $principalContext = New-Object System.DirectoryServices.AccountManagement.PrincipalContext( - [System.DirectoryServices.AccountManagement.ContextType]::Domain, - $DomainName, - $null, - $null - ); + [System.DirectoryServices.AccountManagement.ContextType]::Domain, + $DomainName, + $null, + $null + ); } Write-Verbose -Message ($LocalizedData.CheckingADUserPassword -f $UserName); @@ -1342,8 +1385,8 @@ function Test-Password $UserName, $Password.GetNetworkCredential().Password, [System.DirectoryServices.AccountManagement.ContextOptions]::Negotiate -bor - [System.DirectoryServices.AccountManagement.ContextOptions]::Signing -bor - [System.DirectoryServices.AccountManagement.ContextOptions]::Sealing + [System.DirectoryServices.AccountManagement.ContextOptions]::Signing -bor + [System.DirectoryServices.AccountManagement.ContextOptions]::Sealing ); } else diff --git a/DSCResources/MSFT_xADUser/MSFT_xADUser.schema.mof b/DSCResources/MSFT_xADUser/MSFT_xADUser.schema.mof index 51a8fffdb..df8e2ac51 100644 --- a/DSCResources/MSFT_xADUser/MSFT_xADUser.schema.mof +++ b/DSCResources/MSFT_xADUser/MSFT_xADUser.schema.mof @@ -48,6 +48,7 @@ class MSFT_xADUser : OMI_BaseResource [Write, Description("Specifies the authentication context type used when testing passwords"), ValueMap{"Default","Negotiate"},Values{"Default","Negotiate"}] String PasswordAuthentication; [Write, Description("Specifies whether existing user's password should be reset (default $false)")] Boolean PasswordNeverResets; [Write, Description("Specifies whether an account is trusted for Kerberos delegation (default $false)")] Boolean TrustedForDelegation; - [Write, Description("Try to restore the organizational unit from the recycle bin before creating a new one.")] Boolean RestoreFromRecycleBin; + [Write, Description("Try to restore the user object from the recycle bin before creating a new one.")] Boolean RestoreFromRecycleBin; + [Write, Description("Specifies the service principal names for the user account.")] String ServicePrincipalNames[]; [Read, Description("Returns the X.500 path of the object")] String DistinguishedName; }; diff --git a/README.md b/README.md index 1a2cf0dda..2556fb63e 100644 --- a/README.md +++ b/README.md @@ -457,8 +457,9 @@ The xADServicePrincipalName DSC resource will manage service principal names. * The 'Negotiate' option supports NTLM authentication - which may be required when testing users' passwords when Active Directory Certificate Services (ADCS) is deployed. * **`[Boolean]` PasswordNeverResets** _(Write)_: Specifies whether existing user's password should be reset (default $false). * **`[Boolean]` TrustedForDelegation** _(Write)_: Specifies whether an account is trusted for Kerberos delegation (default $false). -* **`[Boolean]` RestoreFromRecycleBin** _(Write)_: Try to restore the organizational unit from the recycle bin before creating a new one. +* **`[Boolean]` RestoreFromRecycleBin** _(Write)_: Try to restore the user object from the recycle bin before creating a new one. * **`[String]` DistinguishedName** _(Read)_: The user distinguished name, returned with Get. +* **`[String]` ServicePrincipalNames** _(Write)_: Specifies the service principal names for the user account. ### **xWaitForADDomain** diff --git a/Tests/Unit/MSFT_xADUser.Tests.ps1 b/Tests/Unit/MSFT_xADUser.Tests.ps1 index d2f053a7f..695ca9208 100644 --- a/Tests/Unit/MSFT_xADUser.Tests.ps1 +++ b/Tests/Unit/MSFT_xADUser.Tests.ps1 @@ -1,12 +1,12 @@ -$Global:DSCModuleName = 'xActiveDirectory' -$Global:DSCResourceName = 'MSFT_xADUser' +$Global:DSCModuleName = 'xActiveDirectory' +$Global:DSCResourceName = 'MSFT_xADUser' #region HEADER [String] $moduleRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $Script:MyInvocation.MyCommand.Path)) if ( (-not (Test-Path -Path (Join-Path -Path $moduleRoot -ChildPath 'DSCResource.Tests'))) -or ` - (-not (Test-Path -Path (Join-Path -Path $moduleRoot -ChildPath 'DSCResource.Tests\TestHelper.psm1'))) ) + (-not (Test-Path -Path (Join-Path -Path $moduleRoot -ChildPath 'DSCResource.Tests\TestHelper.psm1'))) ) { - & git @('clone','https://github.com/PowerShell/DscResource.Tests.git',(Join-Path -Path $moduleRoot -ChildPath '\DSCResource.Tests\')) + & git @('clone', 'https://github.com/PowerShell/DscResource.Tests.git', (Join-Path -Path $moduleRoot -ChildPath '\DSCResource.Tests\')) } Import-Module (Join-Path -Path $moduleRoot -ChildPath 'DSCResource.Tests\TestHelper.psm1') -Force @@ -25,21 +25,22 @@ try InModuleScope $Global:DSCResourceName { $testPresentParams = @{ DomainName = 'contoso.com' - UserName = 'TestUser' - Ensure = 'Present' + UserName = 'TestUser' + Ensure = 'Present' } $testAbsentParams = $testPresentParams.Clone() $testAbsentParams['Ensure'] = 'Absent' $fakeADUser = @{ - DistinguishedName = "CN=$($testPresentParams.UserName),CN=Users,DC=contoso,DC=com" - Enabled = $true - GivenName = '' - Name = $testPresentParams.UserName - SamAccountName = $testPresentParams.UserName - Surname = '' - UserPrincipalName = '' + DistinguishedName = "CN=$($testPresentParams.UserName),CN=Users,DC=contoso,DC=com" + Enabled = $true + GivenName = '' + Name = $testPresentParams.UserName + SamAccountName = $testPresentParams.UserName + Surname = '' + UserPrincipalName = '' + ServicePrincipalNames = @('spn/a', 'spn/b') } $testDomainController = 'TESTDC' @@ -49,10 +50,10 @@ try 'UserPrincipalName', 'DisplayName', 'Path', 'GivenName', 'Initials', 'Surname', 'Description', 'StreetAddress', 'POBox', 'City', 'State', 'PostalCode', 'Country', 'Department', 'Division', 'Company', 'Office', 'JobTitle', 'EmailAddress', 'EmployeeID', 'EmployeeNumber', 'HomeDirectory', 'HomeDrive', 'HomePage', 'ProfilePath', - 'LogonScript', 'Notes', 'OfficePhone', 'MobilePhone', 'Fax', 'Pager', 'IPPhone', 'HomePhone','CommonName' + 'LogonScript', 'Notes', 'OfficePhone', 'MobilePhone', 'Fax', 'Pager', 'IPPhone', 'HomePhone', 'CommonName' ) $testBooleanProperties = @('PasswordNeverExpires', 'CannotChangePassword', 'TrustedForDelegation', 'Enabled'); - + $testArrayProperties = @('ServicePrincipalNames') #region Function Get-TargetResource Describe "$($Global:DSCResourceName)\Get-TargetResource" { It "Returns a 'System.Collections.Hashtable' object type" { @@ -94,6 +95,12 @@ try Assert-MockCalled -CommandName Get-ADUser -ParameterFilter { $Credential -eq $testCredential } -Scope It } + It "Should return correct ServicePrincipalNames" { + Mock -CommandName Get-ADUser -MockWith { return [PSCustomObject] $fakeADUser } + + $adUser = Get-TargetResource @testPresentParams -DomainAdministratorCredential $testCredential + $adUser.ServicePrincipalNames | Should -Be $fakeADUser.ServicePrincipalNames + } } #endregion @@ -185,7 +192,7 @@ try $validADUser = $testPresentParams.Clone() $invalidADUser = $testPresentParams.Clone() Mock -CommandName Get-TargetResource -MockWith { - $invalidADUser[$testParameter] = $testParameterValue.Substring(0, ([System.Int32] $testParameterValue.Length/2)) + $invalidADUser[$testParameter] = $testParameterValue.Substring(0, ([System.Int32] $testParameterValue.Length / 2)) return $invalidADUser } @@ -277,6 +284,98 @@ try } } #end foreach test boolean property + foreach ($testParameter in $testArrayProperties) + { + It "Passes when user account '$testParameter' matches empty AD account property" { + $testParameterValue = @() + $testValidPresentParams = $testPresentParams.Clone() + $testValidPresentParams[$testParameter] = $testParameterValue + $validADUser = $testPresentParams.Clone() + Mock -CommandName Get-TargetResource -MockWith { + $validADUser[$testParameter] = $testParameterValue + return $validADUser + } + + Test-TargetResource @testValidPresentParams | Should Be $true + } + + It "Passes when user account '$testParameter' matches single AD account property" { + $testParameterValue = @('Entry1') + $testValidPresentParams = $testPresentParams.Clone() + $testValidPresentParams[$testParameter] = $testParameterValue + $validADUser = $testPresentParams.Clone() + Mock -CommandName Get-TargetResource -MockWith { + $validADUser[$testParameter] = $testParameterValue + return $validADUser + } + + Test-TargetResource @testValidPresentParams | Should Be $true + } + It "Passes when user account '$testParameter' matches multiple AD account property" { + $testParameterValue = @('Entry1', 'Entry2') + $testValidPresentParams = $testPresentParams.Clone() + $testValidPresentParams[$testParameter] = $testParameterValue + $validADUser = $testPresentParams.Clone() + Mock -CommandName Get-TargetResource -MockWith { + $validADUser[$testParameter] = $testParameterValue + return $validADUser + } + + Test-TargetResource @testValidPresentParams | Should Be $true + } + It "Fails when user account '$testParameter' does not match AD account property count" { + $testParameterValue = @('Entry1', 'Entry2') + $testValidPresentParams = $testPresentParams.Clone() + $testValidPresentParams[$testParameter] = $testParameterValue + $validADUser = $testPresentParams.Clone() + Mock -CommandName Get-TargetResource -MockWith { + $validADUser[$testParameter] = @('Entry1') + return $validADUser + } + + Test-TargetResource @testValidPresentParams | Should Be $false + } + + It "Fails when user account '$testParameter' does not match AD account property name" { + $testParameterValue = @('Entry1') + $testValidPresentParams = $testPresentParams.Clone() + $testValidPresentParams[$testParameter] = $testParameterValue + $validADUser = $testPresentParams.Clone() + Mock -CommandName Get-TargetResource -MockWith { + $validADUser[$testParameter] = @('Entry2') + return $validADUser + } + + Test-TargetResource @testValidPresentParams | Should Be $false + } + + It "Fails when user account '$testParameter' does not match empty AD account property" { + $testParameterValue = @('Entry1') + $testValidPresentParams = $testPresentParams.Clone() + $testValidPresentParams[$testParameter] = $testParameterValue + $validADUser = $testPresentParams.Clone() + Mock -CommandName Get-TargetResource -MockWith { + $validADUser[$testParameter] = @() + return $validADUser + } + + Test-TargetResource @testValidPresentParams | Should Be $false + } + + It "Fails when empty user account '$testParameter' does not match AD account property" { + $testParameterValue = @() + $testValidPresentParams = $testPresentParams.Clone() + $testValidPresentParams[$testParameter] = $testParameterValue + $validADUser = $testPresentParams.Clone() + Mock -CommandName Get-TargetResource -MockWith { + $validADUser[$testParameter] = @('ExtraEntry1') + return $validADUser + } + + Test-TargetResource @testValidPresentParams | Should Be $false + } + + }#end foreach test array property } #endregion @@ -426,6 +525,15 @@ try Assert-MockCalled -CommandName Set-ADUser -ParameterFilter { $Remove.ContainsKey($testADPropertyName) } -Scope It -Exactly 1 } + It "Calls 'Set-ADUser' with 'ServicePrincipalNames' when specified" { + $testSPNs = @('spn/a', 'spn/b') + Mock -CommandName Get-ADUser -MockWith { return $fakeADUser } + Mock -CommandName Set-ADUser -ParameterFilter { $Replace.ContainsKey('ServicePrincipalName') } + + Set-TargetResource @testPresentParams -ServicePrincipalNames $testSPNs + + Assert-MockCalled -CommandName Set-ADUser -ParameterFilter { $Replace.ContainsKey('ServicePrincipalName') } -Scope It -Exactly 1 + } It "Calls 'Remove-ADUser' when 'Ensure' is 'Absent' and user account exists" { Mock -CommandName Get-ADUser -MockWith { return [PSCustomObject] $fakeADUser } @@ -467,8 +575,8 @@ try $script:mockCounter = 0 Mock -CommandName Restore-ADCommonObject -MockWith { return [PSCustomObject]@{ - ObjectClass = 'computer' - }} + ObjectClass = 'user' + } } Set-TargetResource @restoreParam @@ -497,7 +605,7 @@ try $script:mockCounter = 0 - Mock -CommandName Restore-ADCommonObject -MockWith {throw (New-Object -TypeName System.InvalidOperationException)} + Mock -CommandName Restore-ADCommonObject -MockWith { throw (New-Object -TypeName System.InvalidOperationException) } { Set-TargetResource @restoreParam } | Should -Throw