#!powershell # Copyright: (c) 2023, Ansible Project # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) #AnsibleRequires -CSharpUtil Ansible.AccessToken #AnsibleRequires -CSharpUtil Ansible.Basic #AnsibleRequires -PowerShell ..module_utils._ADObject Function Test-Credential { param( [String]$Username, [String]$Password, [String]$Domain = $null ) if (($Username.ToCharArray()) -contains [char]'@') { # UserPrincipalName $Domain = $null # force $Domain to be null, to prevent undefined behaviour, as a domain name is already included in the username } elseif (($Username.ToCharArray()) -contains [char]'\') { # Pre Win2k Account Name $Domain = ($Username -split '\\')[0] $Username = ($Username -split '\\', 2)[-1] } # If no domain provided, so maybe local user, or domain specified separately. try { ([Ansible.AccessToken.TokenUtil]::LogonUser($Username, $Domain, $Password, "Network", "Default")).Dispose() return $true } catch [Ansible.AccessToken.Win32Exception] { # following errors indicate the creds are correct but the user was # unable to log on for other reasons, which we don't care about $success_codes = @( 0x0000052F, # ERROR_ACCOUNT_RESTRICTION 0x00000530, # ERROR_INVALID_LOGON_HOURS 0x00000531, # ERROR_INVALID_WORKSTATION 0x00000569 # ERROR_LOGON_TYPE_GRANTED ) $failed_codes = @( 0x0000052E, # ERROR_LOGON_FAILURE 0x00000532, # ERROR_PASSWORD_EXPIRED 0x00000773, # ERROR_PASSWORD_MUST_CHANGE 0x00000533 # ERROR_ACCOUNT_DISABLED ) if ($_.Exception.NativeErrorCode -in $failed_codes) { return $false } elseif ($_.Exception.NativeErrorCode -in $success_codes) { return $true } else { # an unknown failure, reraise exception throw $_ } } } $setParams = @{ PropertyInfo = @( [PSCustomObject]@{ Name = 'account_locked' Option = @{ choices = @(, $false) type = 'bool' } Attribute = 'LockedOut' Set = { param($Module, $ADParams, $SetParams, $ADObject) if ($ADObject.LockedOut) { Unlock-ADAccount @ADParams -Identity $ADObject.ObjectGUID -WhatIf:$Module.CheckMode $Module.Result.changed = $true } $Module.Diff.after.account_locked = $false } } [PSCustomObject]@{ Name = 'city' Option = @{ type = 'str' } Attribute = 'City' } [PSCustomObject]@{ Name = 'company' Option = @{ type = 'str' } Attribute = 'company' } [PSCustomObject]@{ Name = 'country' Option = @{ type = 'str' } Attribute = 'Country' } [PSCustomObject]@{ Name = 'delegates' Option = @{ aliases = 'principals_allowed_to_delegate' type = 'dict' options = @{ add = @{ type = 'list'; elements = 'str' } remove = @{ type = 'list'; elements = 'str' } set = @{ type = 'list'; elements = 'str' } } } Attribute = 'PrincipalsAllowedToDelegateToAccount' CaseInsensitive = $true } [PSCustomObject]@{ Name = 'email' Option = @{ type = 'str' } Attribute = 'EmailAddress' } [PSCustomObject]@{ Name = 'enabled' Option = @{ type = 'bool' } Attribute = 'Enabled' } [PSCustomObject]@{ Name = 'firstname' Option = @{ type = 'str' } Attribute = 'givenName' } [PSCustomObject]@{ Name = 'groups' Option = @{ type = 'dict' options = @{ add = @{ type = 'list'; elements = 'str' } remove = @{ type = 'list'; elements = 'str' } set = @{ type = 'list'; elements = 'str' } missing_behaviour = @{ choices = 'fail', 'ignore', 'warn' default = 'fail' type = 'str' } } } } [PSCustomObject]@{ Name = 'password' Option = @{ no_log = $true type = 'str' } New = { param($Module, $ADParams, $NewParams) $NewParams.AccountPassword = (ConvertTo-SecureString -AsPlainText -Force -String $module.Params.password) $Module.Diff.after.password = 'VALUE_SPECIFIED_IN_NO_LOG_PARAMETER' } Set = { param($Module, $ADParams, $SetParams, $ADObject) $Module.Diff.before.password = 'VALUE_SPECIFIED_IN_NO_LOG_PARAMETER' $changed = switch ($Module.Params.update_password) { always { $true } on_create { $false } when_changed { # Try and use the UPN but fallback to msDS-PrincipalName if none is defined $username = $ADObject.UserPrincipalName if (-not $username) { $username = $ADObject['msDS-PrincipalName'] } -not (Test-Credential -Username $username -Password $module.Params.password) } } if (-not $changed) { $Module.Diff.after.password = 'VALUE_SPECIFIED_IN_NO_LOG_PARAMETER' return } # -WhatIf was broken until Server 2016 and will set the # password. Just avoid calling this in check mode. if (-not $Module.CheckMode) { $setParams = @{ Identity = $ADObject.ObjectGUID Reset = $true Confirm = $false NewPassword = (ConvertTo-SecureString -AsPlainText -Force -String $module.Params.password) } Set-ADAccountPassword @setParams @ADParams } $Module.Diff.after.password = 'VALUE_SPECIFIED_IN_NO_LOG_PARAMETER - changed' $Module.Result.changed = $true } } [PSCustomObject]@{ Name = 'password_expired' Option = @{ type = 'bool' } Attribute = 'PasswordExpired' New = { param($Module, $ADParams, $NewParams) $NewParams.ChangePasswordAtLogon = $module.Params.password_expired $Module.Diff.after.password_expired = $module.Params.password_expired } Set = { param($Module, $ADParams, $SetParams, $ADObject) if ($ADObject.PasswordExpired -ne $Module.Params.password_expired) { $SetParams.ChangePasswordAtLogon = $Module.Params.password_expired } $Module.Diff.after.password_expired = $Module.Params.password_expired } } [PSCustomObject]@{ Name = 'password_never_expires' Option = @{ type = 'bool' } Attribute = 'PasswordNeverExpires' } [PSCustomObject]@{ Name = 'postal_code' Option = @{ type = 'str' } Attribute = 'PostalCode' } [PSCustomObject]@{ Name = 'sam_account_name' Option = @{ type = 'str' } Attribute = 'sAMAccountName' } [PSCustomObject]@{ Name = 'spn' Option = @{ aliases = 'spns' type = 'dict' options = @{ add = @{ type = 'list'; elements = 'str' } remove = @{ type = 'list'; elements = 'str' } set = @{ type = 'list'; elements = 'str' } } } Attribute = 'ServicePrincipalNames' New = { param($Module, $ADParams, $NewParams) $spns = @( $Module.Params.spn.add $Module.Params.spn.set ) | Select-Object -Unique $NewParams.ServicePrincipalNames = $spns $Module.Diff.after.spn = $spns } Set = { param($Module, $ADParams, $SetParams, $ADObject) $desired = $Module.Params.spn $compareParams = @{ Existing = $ADObject.ServicePrincipalNames CaseInsensitive = $true } $res = Compare-AnsibleADIdempotentList @compareParams @desired if ($res.Changed) { $SetParams.ServicePrincipalNames = @{} if ($res.ToAdd) { $SetParams.ServicePrincipalNames.Add = $res.ToAdd } if ($res.ToRemove) { $SetParams.ServicePrincipalNames.Remove = $res.ToRemove } } $module.Diff.after.kerberos_encryption_types = @($res.Value | Sort-Object) } } [PSCustomObject]@{ Name = 'state_province' Option = @{ type = 'str' } Attribute = 'State' } [PSCustomObject]@{ Name = 'street' Option = @{ type = 'str' } Attribute = 'StreetAddress' } [PSCustomObject]@{ Name = 'surname' Option = @{ aliases = 'lastname' type = 'str' } Attribute = 'Surname' } [PSCustomObject]@{ Name = 'update_password' Option = @{ choices = 'always', 'on_create', 'when_changed' default = 'always' type = 'str' } } [PSCustomObject]@{ Name = 'upn' Option = @{ type = 'str' } Attribute = 'userPrincipalName' } [PSCustomObject]@{ Name = 'user_cannot_change_password' Option = @{ type = 'bool' } Attribute = 'CannotChangePassword' } ) ModuleNoun = 'ADUser' DefaultPath = { param($Module, $ADParams) $GUID_USERS_CONTAINER_W = 'A9D1CA15768811D1ADED00C04FD8D5CD' $defaultNamingContext = (Get-ADRootDSE @ADParams -Properties defaultNamingContext).defaultNamingContext Get-ADObject @ADParams -Identity $defaultNamingContext -Properties wellKnownObjects | Select-Object -ExpandProperty wellKnownObjects | Where-Object { $_.StartsWith("B:32:$($GUID_USERS_CONTAINER_W):") } | ForEach-Object Substring 38 } ExtraProperties = @( # Used for password when checking if the password is valid 'msDS-PrincipalName' ) PreAction = { param ($Module, $ADParams, $ADObject) if ( $Module.Params.state -eq 'present' -and $null -eq $ADObject -and $null -eq $Module.Params.enabled ) { $Module.Params.enabled = -not ([String]::IsNullOrWhiteSpace($Module.Params.password)) } } PostAction = { param($Module, $ADParams, $ADObject) if ($ADObject) { $Module.Result.sid = $ADObject.SID.Value } elseif ($Module.Params.state -eq 'present') { # Use dummy value for check mode when creating a new user $Module.Result.sid = 'S-1-5-0000' } if ($null -eq $Module.Params.groups -or $Module.Params.groups.Count -eq 0 -or $Module.Params.state -eq 'absent') { return } $groupMissingBehaviour = $Module.Params.groups.missing_behaviour $lookupGroup = { try { (Get-ADGroup -Identity $args[0] @ADParams).DistinguishedName } catch { if ($groupMissingBehaviour -eq "fail") { $module.FailJson("Failed to locate group $($args[0]): $($_.Exception.Message)", $_) } elseif ($groupMissingBehaviour -eq "warn") { $module.Warn("Failed to locate group $($args[0]) but continuing on: $($_.Exception.Message)") } } } [string[]]$existingGroups = @( # In check mode the ADObject won't be given if ($ADObject) { try { Get-ADPrincipalGroupMembership -Identity $ADObject.ObjectGUID @ADParams -ErrorAction Stop | Select-Object -ExpandProperty DistinguishedName } catch { $module.Warn("Failed to enumerate user groups but continuing on: $($_.Exception.Message)") } } ) if ($Module.Diff.before) { $Module.Diff.before.groups = @($existingGroups | Sort-Object) } $compareParams = @{ CaseInsensitive = $true Existing = $existingGroups } 'add', 'remove', 'set' | ForEach-Object -Process { if ($null -ne $Module.Params.groups[$_]) { $compareParams[$_] = @( foreach ($group in $Module.Params.groups[$_]) { & $lookupGroup $group } ) } } $res = Compare-AnsibleADIdempotentList @compareParams $Module.Diff.after.groups = $res.Value if ($res.Changed) { $commonParams = @{ Confirm = $false WhatIf = $Module.CheckMode } foreach ($member in $res.ToAdd) { if ($ADObject) { Add-ADGroupMember -Identity $member -Members $ADObject.ObjectGUID @ADParams @commonParams } $Module.Result.changed = $true } foreach ($member in $res.ToRemove) { if ($ADObject) { try { Remove-ADGroupMember -Identity $member -Members $ADObject.ObjectGUID @ADParams @commonParams } catch [Microsoft.ActiveDirectory.Management.ADException] { if ($_.Exception.ErrorCode -eq 0x0000055E) { # ERROR_MEMBERS_PRIMARY_GROUP - win_domain_user didn't # fail in this scenario. To preserve compatibility just # display a warning. The warning isn't added if set # was an empty list. if ($null -eq $Module.Params.groups -or $Module.Params.groups.set.Length -ne 0) { $Module.Warn("Cannot remove group '$member' as it's the primary group of the user, skipping: $($_.Exception.Message)") } $Module.Diff.after.groups = @($Module.Diff.after.groups; $member) } else { throw } } } $Module.Result.changed = $true } } # Ensure it's in alphabetical order to match before state as much as possible $Module.Diff.after.groups = @($res.Value | Sort-Object) } } Invoke-AnsibleADObject @setParams