diff options
Diffstat (limited to 'ansible_collections/microsoft/ad/plugins/modules/user.ps1')
-rw-r--r-- | ansible_collections/microsoft/ad/plugins/modules/user.ps1 | 458 |
1 files changed, 458 insertions, 0 deletions
diff --git a/ansible_collections/microsoft/ad/plugins/modules/user.ps1 b/ansible_collections/microsoft/ad/plugins/modules/user.ps1 new file mode 100644 index 000000000..d975272c7 --- /dev/null +++ b/ansible_collections/microsoft/ad/plugins/modules/user.ps1 @@ -0,0 +1,458 @@ +#!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 |