From 6c2625694568b3adcce06c2e4bd6745f27be9764 Mon Sep 17 00:00:00 2001 From: Jordan Borean Date: Tue, 22 Jan 2019 11:04:22 +1000 Subject: [PATCH] win_user_profile - new module (#50637) * win_user_profile - new module * Fix typo * Fix 2012 CI issues * changed bool in docs and revert other test changes --- .../modules/windows/win_domain_user.py | 1 + lib/ansible/modules/windows/win_user.py | 1 + .../modules/windows/win_user_profile.ps1 | 170 ++++++++ .../modules/windows/win_user_profile.py | 113 ++++++ .../targets/win_user_profile/aliases | 1 + .../targets/win_user_profile/tasks/main.yml | 42 ++ .../targets/win_user_profile/tasks/tests.yml | 374 ++++++++++++++++++ 7 files changed, 702 insertions(+) create mode 100644 lib/ansible/modules/windows/win_user_profile.ps1 create mode 100644 lib/ansible/modules/windows/win_user_profile.py create mode 100644 test/integration/targets/win_user_profile/aliases create mode 100644 test/integration/targets/win_user_profile/tasks/main.yml create mode 100644 test/integration/targets/win_user_profile/tasks/tests.yml diff --git a/lib/ansible/modules/windows/win_domain_user.py b/lib/ansible/modules/windows/win_domain_user.py index 9010569172..de88643b7c 100644 --- a/lib/ansible/modules/windows/win_domain_user.py +++ b/lib/ansible/modules/windows/win_domain_user.py @@ -196,6 +196,7 @@ seealso: - module: win_domain_group - module: win_domain_membership - module: win_user +- module: win_user_profile author: - Nick Chandler (@nwchandler) ''' diff --git a/lib/ansible/modules/windows/win_user.py b/lib/ansible/modules/windows/win_user.py index 6b3e7efcfc..7cca668972 100644 --- a/lib/ansible/modules/windows/win_user.py +++ b/lib/ansible/modules/windows/win_user.py @@ -109,6 +109,7 @@ seealso: - module: win_domain_user - module: win_group - module: win_group_membership +- module: win_user_profile author: - Paul Durivage (@angstwad) - Chris Church (@cchurch) diff --git a/lib/ansible/modules/windows/win_user_profile.ps1 b/lib/ansible/modules/windows/win_user_profile.ps1 new file mode 100644 index 0000000000..9d3b1b2eb9 --- /dev/null +++ b/lib/ansible/modules/windows/win_user_profile.ps1 @@ -0,0 +1,170 @@ +#!powershell + +# Copyright: (c) 2019, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic + +$spec = @{ + options = @{ + name = @{ type = "str" } + remove_multiple = @{ type = "bool"; default = $false } + state = @{ type = "str"; default = "present"; choices = @("absent", "present") } + username = @{ type = "sid"; } + } + required_if = @( + @("state", "present", @("username")), + @("state", "absent", @("name", "username"), $true) + ) + supports_check_mode = $true +} + +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) +$module.Result.path = $null + +$name = $module.Params.name +$remove_multiple = $module.Params.remove_multiple +$state = $module.Params.state +$username = $module.Params.username + +Add-CSharpType -AnsibleModule $module -References @' +using System; +using System.Runtime.InteropServices; +using System.Text; + +namespace Ansible.WinUserProfile +{ + public class NativeMethods + { + [DllImport("Userenv.dll", CharSet = CharSet.Unicode)] + public static extern int CreateProfile( + [MarshalAs(UnmanagedType.LPWStr)] string pszUserSid, + [MarshalAs(UnmanagedType.LPWStr)] string pszUserName, + [Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszProfilePath, + UInt32 cchProfilePath); + + [DllImport("Userenv.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern bool DeleteProfileW( + [MarshalAs(UnmanagedType.LPWStr)] string lpSidString, + IntPtr lpProfile, + IntPtr lpComputerName); + + [DllImport("Userenv.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern bool GetProfilesDirectoryW( + [Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder lpProfileDir, + ref UInt32 lpcchSize); + } +} +'@ + +Function Get-LastWin32ExceptionMessage { + param([int]$ErrorCode) + + # Need to throw a Win32Exception with the error code to get the actual error message assigned to that code + try { + throw [System.ComponentModel.Win32Exception]$ErrorCode + } catch [System.ComponentModel.Win32Exception] { + $exp_msg = "{0} (Win32 ErrorCode {1} - 0x{1:X8})" -f $_.Exception.Message, $ErrorCode + } + + return $exp_msg +} + +Function Get-ExpectedProfilePath { + param([String]$BaseName) + + # Environment.GetFolderPath does not have an enumeration to get the base profile dir, use PInvoke instead + # and combine with the base name to return back to the user - best efforts + $raw_profile_path = New-Object -TypeName System.Text.StringBuilder -ArgumentList 0 + $profile_path_length = 0 + [Ansible.WinUserProfile.NativeMethods]::GetProfilesDirectoryW($raw_profile_path, + [ref]$profile_path_length) > $null + + $raw_profile_path.EnsureCapacity($profile_path_length) > $null + $res = [Ansible.WinUserProfile.NativeMethods]::GetProfilesDirectoryW($raw_profile_path, + [ref]$profile_path_length) + + if ($res -eq $false) { + $msg = Get-LastWin32ExceptionMessage -Error ([System.Runtime.InteropServices.Marshal]::GetLastWin32Error()) + $module.FailJson("Failed to determine profile path with the base name '$BaseName': $msg") + } + $profile_path = Join-Path -Path $raw_profile_path.ToString() -ChildPath $BaseName + + return $profile_path +} + +$profiles = Get-ChildItem -Path "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList" + +if ($state -eq "absent") { + if ($null -ne $username) { + $user_profiles = $profiles | Where-Object { $_.PSChildName -eq $username.Value } + } else { + # If the username was not provided, or we are removing a profile for a deleted user, we need to try and find + # the correct SID to delete. We just verify that the path matches based on the name passed in + $expected_profile_path = Get-ExpectedProfilePath -BaseName $name + + $user_profiles = $profiles | Where-Object { + $profile_path = (Get-ItemProperty -Path $_.PSPath -Name ProfileImagePath).ProfileImagePath + $profile_path -eq $expected_profile_path + } + + if ($user_profiles.Length -gt 1 -and -not $remove_multiple) { + $module.FailJson("Found multiple profiles matching the path '$expected_profile_path', set 'remove_multiple=True' to remove all the profiles for this match") + } + } + + foreach ($user_profile in $user_profiles) { + $profile_path = (Get-ItemProperty -Path $user_profile.PSPath -Name ProfileImagePath).ProfileImagePath + if (-not $module.CheckMode) { + $res = [Ansible.WinUserProfile.NativeMethods]::DeleteProfileW($user_profile.PSChildName, [IntPtr]::Zero, + [IntPtr]::Zero) + if ($res -eq $false) { + $msg = Get-LastWin32ExceptionMessage -Error ([System.Runtime.InteropServices.Marshal]::GetLastWin32Error()) + $module.FailJson("Failed to delete the profile for $($user_profile.PSChildName): $msg") + } + } + + # While we may have multiple profiles when the name option was used, it will always be the same path due to + # how we match name to a profile so setting it mutliple time sis fine + $module.Result.path = $profile_path + $module.Result.changed = $true + } +} elseif ($state -eq "present") { + # Now we know the SID, see if the profile already exists + $user_profile = $profiles | Where-Object { $_.PSChildName -eq $username.Value } + if ($null -eq $user_profile) { + # In case a SID was set as the username we still need to make sure the SID is mapped to a valid local account + try { + $account_name = $username.Translate([System.Security.Principal.NTAccount]) + } catch [System.Security.Principal.IdentityNotMappedException] { + $module.FailJson("Fail to map the account '$($username.Value)' to a valid user") + } + + # If the basename was not provided, determine it from the actual username + if ($null -eq $name) { + $name = $account_name.Value.Split('\', 2)[-1] + } + + if ($module.CheckMode) { + $profile_path = Get-ExpectedProfilePath -BaseName $name + } else { + $raw_profile_path = New-Object -TypeName System.Text.StringBuilder -ArgumentList 260 + $res = [Ansible.WinUserProfile.NativeMethods]::CreateProfile($username.Value, $name, $raw_profile_path, + $raw_profile_path.Capacity) + + if ($res -ne 0) { + $exp = [System.Runtime.InteropServices.Marshal]::GetExceptionForHR($res) + $module.FailJson("Failed to create profile for user '$username': $($exp.Message)") + } + $profile_path = $raw_profile_path.ToString() + } + + $module.Result.changed = $true + $module.Result.path = $profile_path + } else { + $module.Result.path = (Get-ItemProperty -Path $user_profile.PSPath -Name ProfileImagePath).ProfileImagePath + } +} + +$module.ExitJson() + diff --git a/lib/ansible/modules/windows/win_user_profile.py b/lib/ansible/modules/windows/win_user_profile.py new file mode 100644 index 0000000000..264392488c --- /dev/null +++ b/lib/ansible/modules/windows/win_user_profile.py @@ -0,0 +1,113 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2019, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} + +DOCUMENTATION = r''' +--- +module: win_user_profile +version_added: '2.8' +short_description: Manages the Windows user profiles. +description: +- Used to create or remove user profiles on a Windows host. +- This can be used to create a profile before a user logs on or delete a + profile when removing a user account. +- A profile can be created for both a local or domain account. +options: + name: + description: + - Specifies the base name for the profile path. + - When I(state) is C(present) this is used to create the profile for + I(username) at a specific path within the profile directory. + - This cannot be used to specify a path outside of the profile directory + but rather it specifies a folder(s) within this directory. + - If a profile for another user already exists at the same path, then a 3 + digit incremental number is appended by Windows automatically. + - When I(state) is C(absent) and I(username) is not set, then the module + will remove all profiles that point to the profile path derived by this + value. + - This is useful if the account no longer exists but the profile still + remains. + type: str + remove_multiple: + description: + - When I(state) is C(absent) and the value for I(name) matches multiple + profiles the module will fail. + - Set this value to C(yes) to force the module to delete all the profiles + found. + default: no + type: bool + state: + description: + - Will ensure the profile exists when set to C(present). + - When creating a profile the I(username) option must be set to a valid + account. + - Will remove the profile(s) when set to C(absent). + - When removing a profile either I(username) must be set to a valid + account, or I(name) is set to the profile's base name. + default: present + choices: + - absent + - present + type: str + username: + description: + - The account name of security identifier (SID) for the profile. + - This must be set when I(state) is C(present) and must be a valid account + or the SID of a valid account. + - When I(state) is C(absent) then this must still be a valid account number + but the SID can be a deleted user's SID. +seealso: +- module: win_user +- module: win_domain_user +author: +- Jordan Borean (@jborean93) +''' + +EXAMPLES = r''' +- name: Create a profile for an account + win_user_profile: + username: ansible-account + state: present + +- name: Create a profile for an account at C:\Users\ansible + win_user_profile: + username: ansible-account + name: ansible + state: present + +- name: Remove a profile for a still valid account + win_user_profile: + username: ansible-account + state: absent + +- name: Remove a profile for a deleted account + win_user_profile: + name: ansible + state: absent + +- name: Remove a profile for a deleted account based on the SID + win_user_profile: + username: S-1-5-21-3233007181-2234767541-1895602582-1305 + state: absent + +- name: Remove multiple profiles that exist at the basename path + win_user_profile: + name: ansible + state: absent + remove_multiple: yes +''' + +RETURN = r''' +path: + description: The full path to the profile for the account. This will be null + if C(state=absent) and no profile was deleted. + returned: always + type: str + sample: C:\Users\ansible +''' diff --git a/test/integration/targets/win_user_profile/aliases b/test/integration/targets/win_user_profile/aliases new file mode 100644 index 0000000000..4cd27b3cb2 --- /dev/null +++ b/test/integration/targets/win_user_profile/aliases @@ -0,0 +1 @@ +shippable/windows/group1 diff --git a/test/integration/targets/win_user_profile/tasks/main.yml b/test/integration/targets/win_user_profile/tasks/main.yml new file mode 100644 index 0000000000..c37d219df3 --- /dev/null +++ b/test/integration/targets/win_user_profile/tasks/main.yml @@ -0,0 +1,42 @@ +--- +- name: set custom user facts + set_fact: + test_username: ansible_test + test_password: '{{ "password123!" + lookup("password", "/dev/null chars=ascii_letters,digits length=9") }}' + +- name: create test account + win_user: + name: '{{ test_username }}' + password: '{{ test_password }}' + state: present + register: test_username_info + +- block: + - name: check if profile exists + win_stat: + path: C:\temp\{{ test_username }} + register: profile_path + + - name: assert that profile doesn't exist before the test + assert: + that: + - not profile_path.stat.exists + + - name: run tests + include_tasks: tests.yml + + always: + - name: remove test account + win_user: + name: '{{ test_username }}' + state: absent + + - name: remove test account profile + win_user_profile: + name: '{{ item }}' + state: absent + remove_multiple: True + with_items: + - '{{ test_username }}' + - '{{ test_username }}.000' + - test_username_profile diff --git a/test/integration/targets/win_user_profile/tasks/tests.yml b/test/integration/targets/win_user_profile/tasks/tests.yml new file mode 100644 index 0000000000..3c787f3217 --- /dev/null +++ b/test/integration/targets/win_user_profile/tasks/tests.yml @@ -0,0 +1,374 @@ +--- +- name: create profile (check mode) + win_user_profile: + username: '{{ test_username }}' + state: present + register: create_profile_check + check_mode: True + +- name: check if profile was created (check mode) + win_stat: + path: C:\Users\{{ test_username }} + register: create_profile_actual_check + +- name: assert create profile (check mode) + assert: + that: + - create_profile_check is changed + - create_profile_check.path|lower == "c:\\users\\" + test_username + - not create_profile_actual_check.stat.exists + +- name: create profile + win_user_profile: + username: '{{ test_username }}' + state: present + register: create_profile + +- name: check if profile was created + win_stat: + path: C:\Users\{{ test_username }} + register: create_profile_actual + +- name: assert create profile + assert: + that: + - create_profile is changed + - create_profile.path|lower == "c:\\users\\" + test_username + - create_profile_actual.stat.exists + +- name: create profile (idempotent) + win_user_profile: + username: '{{ test_username }}' + state: present + register: create_profile_again + +- name: assert create profile (idempotent) + assert: + that: + - not create_profile_again is changed + - create_profile_again.path|lower == "c:\\users\\" + test_username + +- name: remove profile (check mode) + win_user_profile: + username: '{{ test_username }}' + state: absent + register: remove_profile_check + check_mode: True + +- name: check if profile was removed (check mode) + win_stat: + path: C:\Users\{{ test_username }} + register: remove_profile_actual_check + +- name: assert remove profile (check mode) + assert: + that: + - remove_profile_check is changed + - remove_profile_check.path|lower == "c:\\users\\" + test_username + - remove_profile_actual_check.stat.exists + +- name: remove profile + win_user_profile: + username: '{{ test_username }}' + state: absent + register: remove_profile + +- name: check if profile was removed + win_stat: + path: C:\Users\{{ test_username }} + register: remove_profile_actual + +- name: assert remove profile + assert: + that: + - remove_profile is changed + - remove_profile.path|lower == "c:\\users\\" + test_username + - not remove_profile_actual.stat.exists + +- name: remove profile (idempotent) + win_user_profile: + username: '{{ test_username }}' + state: absent + register: remove_profile_again + +- name: assert remove profile (idempotent) + assert: + that: + - not remove_profile_again is changed + - remove_profile_again.path == None + +- name: create profile with specific base path + win_user_profile: + username: '{{ test_username }}' + name: test_username_profile + state: present + register: create_profile_basename + +- name: check if profile with specific base path was created + win_stat: + path: C:\Users\test_username_profile + register: create_profile_basename_actual + +- name: assert create profile with specific base path + assert: + that: + - create_profile_basename is changed + - create_profile_basename.path|lower == "c:\\users\\test_username_profile" + - create_profile_basename_actual.stat.exists + +- name: remove profile with specific base path + win_user_profile: + username: '{{ test_username }}' + state: absent + register: remove_profile_basename + +- name: check if profile with specific base path was removed + win_stat: + path: C:\Users\test_username_profile + register: remove_profile_basename_actual + +- name: assert remove profile with specific base path + assert: + that: + - remove_profile_basename is changed + - remove_profile_basename.path|lower == "c:\\users\\test_username_profile" + - not remove_profile_basename_actual.stat.exists + +- name: create dummy profile folder + win_file: + path: C:\Users\{{ test_username }} + state: directory + +- block: + - name: create profile folder with conflict (check mode) + win_user_profile: + username: '{{ test_username }}' + state: present + register: create_profile_conflict_check + check_mode: True + + - name: get result of create profile folder with conflict (check mode) + win_stat: + path: C:\Users\{{ test_username }}.000 + register: create_profile_conflict_actual_check + + - name: assert create profile folder with conflict (check mode) + assert: + that: + - create_profile_conflict_check is changed + # The check mode path calc is dumb, doesn't check for conflicts + - create_profile_conflict_check.path|lower == "c:\\users\\" + test_username + - not create_profile_conflict_actual_check.stat.exists + + - name: create profile folder with conflict + win_user_profile: + username: '{{ test_username }}' + state: present + register: create_profile_conflict + + - name: get result of create profile with conflict + win_stat: + path: C:\Users\{{ test_username }}.000 + register: create_profile_conflict_actual + + - name: assert create profile folder with conflict + assert: + that: + - create_profile_conflict is changed + - create_profile_conflict.path|lower == "c:\\users\\" + test_username + ".000" + - create_profile_conflict_actual.stat.exists + + - name: remove profile with conflict + win_user_profile: + username: '{{ test_username }}' + state: absent + register: remove_profile_conflict + + - name: get result of profile folder after remove + win_stat: + path: C:\Users\{{ test_username }}.000 + register: remove_profile_conflict_actual + + - name: get result of dummy folder after remove + win_stat: + path: C:\Users\{{ test_username }} + register: remove_profile_conflict_dummy + + - name: assert remove profile with conflict + assert: + that: + - remove_profile_conflict is changed + - remove_profile_conflict.path|lower == "c:\\users\\" + test_username + ".000" + - not remove_profile_conflict_actual.stat.exists + - remove_profile_conflict_dummy.stat.exists + + always: + - name: remove dummy profile folder + win_file: + path: C:\Users\{{ test_username }} + state: absent + +- name: create profile for deleted user by sid test + win_user_profile: + username: '{{ test_username_info.sid }}' + state: present + +- name: delete user for deleted user with sid test + win_user: + name: '{{ test_username }}' + state: absent + +- name: remove profile for remove profile by sid test + win_user_profile: + username: '{{ test_username_info.sid }}' + state: absent + register: remove_profile_deleted_sid + +- name: check if profile was deleted for deleted user using a SID + win_stat: + path: C:\Users\{{ test_username }} + register: remove_profile_deleted_sid_actual + +- name: assert remove profile for deleted user using a SID + assert: + that: + - remove_profile_deleted_sid is changed + - remove_profile_deleted_sid.path|lower == "c:\\users\\" + test_username + - not remove_profile_deleted_sid_actual.stat.exists + +- name: recreate user for deleted user by name test + win_user: + name: '{{ test_username }}' + password: '{{ test_password }}' + state: present + register: test_orphan_user1 + +- name: create profile for deleted user by name test + win_user_profile: + username: '{{ test_username }}' + state: present + +- name: delete user for remove profile by name test + win_user: + name: '{{ test_username }}' + state: absent + +- name: remove profile for deleted user using a name + win_user_profile: + name: '{{ test_username }}' + state: absent + register: remove_profile_deleted_name + +- name: check if profile was deleted for deleted user using a name + win_stat: + path: C:\Users\{{ test_username }} + register: remove_profile_deleted_name_actual + +- name: assert remove profile for deleted user using a name + assert: + that: + - remove_profile_deleted_name is changed + - remove_profile_deleted_name.path|lower == "c:\\users\\" + test_username + - not remove_profile_deleted_name_actual.stat.exists + +- name: remove profile for deleted user using a name (idempotent) + win_user_profile: + name: '{{ test_username }}' + state: absent + register: remove_profile_deleted_name_again + +- name: assert remove profile for deleted user using a name (idempotent) + assert: + that: + - not remove_profile_deleted_name_again is changed + +- name: recreate user for remove multiple user test + win_user: + name: '{{ test_username }}' + password: '{{ test_password }}' + state: present + register: test_orphan_user1 + +- name: create new profile for remove multiple user test + win_user_profile: + username: '{{ test_username }}' + state: present + register: orphan_user1_profile + +- name: remove user 1 for remove multiple user test + win_user: + name: '{{ test_username }}' + state: absent + +# win_file has issues with paths exceeding MAX_PATH, need to use rmdir instead +- name: remove profile folder for user 1 + win_shell: rmdir /S /Q {{ orphan_user1_profile.path}} + args: + executable: cmd.exe + +- name: create user 2 for remove multiple user test + win_user: + name: '{{ test_username }}' + password: '{{ test_password }}' + state: present + register: test_orphan_user2 + +- name: create new profile for orphan user 2 + win_user_profile: + username: '{{ test_username }}' + state: present + register: orphan_user2_profile + +- name: remove orphan user 2 for remove multiple user test + win_user: + name: '{{ test_username }}' + state: present + +- name: fail to remove multiple profiles without flag + win_user_profile: + name: '{{ test_username }}' + state: absent + register: fail_remove_multiple + ignore_errors: True + +- name: check if profile was removed + win_stat: + path: C:\Users\{{ test_username }} + register: fail_remove_multiple_actual + +- name: assert that profile was not actually deleted + assert: + that: + - fail_remove_multiple.msg == "Found multiple profiles matching the path 'C:\\Users\\" + test_username + "', set 'remove_multiple=True' to remove all the profiles for this match" + - fail_remove_multiple_actual.stat.exists + +- name: remove multiple profiles + win_user_profile: + name: '{{ test_username }}' + state: absent + remove_multiple: True + register: remove_multiple + +- name: get result of remove multiple profiles + win_stat: + path: C:\Users\{{ test_username }} + register: remove_multiple_actual + +- name: check that orphan user 1 reg profile has been removed + win_reg_stat: + path: HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\{{ test_orphan_user1.sid }} + register: remove_orphan1_actual + +- name: check that orphan user 2 reg profile has been removed + win_reg_stat: + path: HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\{{ test_orphan_user2.sid }} + register: remove_orphan2_actual + +- name: assert remove multiple profiles + assert: + that: + - remove_multiple is changed + - remove_multiple.path|lower == "c:\\users\\" + test_username + - not remove_multiple_actual.stat.exists + - not remove_orphan1_actual.exists + - not remove_orphan2_actual.exists