From a568bbed3cac44b678195bf7dd94d916da0bbae0 Mon Sep 17 00:00:00 2001 From: Jordan Borean Date: Thu, 22 Nov 2018 08:12:41 +1000 Subject: [PATCH] win_mapped_drive - refactor module and docs (#48642) * win_mapped_drive - refactor module and docs * Updated code to work with become and split tokens * use win_credential_manager instead of cmdkey * updated credential manager module name * harden the system token impersonation process --- .../fragments/win_mapped_drive-fixes.yaml | 2 + .../modules/windows/win_mapped_drive.ps1 | 654 +++++++++++++++--- .../modules/windows/win_mapped_drive.py | 80 ++- .../targets/win_mapped_drive/tasks/main.yml | 37 + .../targets/win_mapped_drive/tasks/tests.yml | 78 ++- 5 files changed, 751 insertions(+), 100 deletions(-) create mode 100644 changelogs/fragments/win_mapped_drive-fixes.yaml diff --git a/changelogs/fragments/win_mapped_drive-fixes.yaml b/changelogs/fragments/win_mapped_drive-fixes.yaml new file mode 100644 index 00000000000..c69a394f1b0 --- /dev/null +++ b/changelogs/fragments/win_mapped_drive-fixes.yaml @@ -0,0 +1,2 @@ +bugfixes: +- win_mapped_drive - Updated win_mapped_drive to use the proper Win32 APIs and updated documentation for proper usage diff --git a/lib/ansible/modules/windows/win_mapped_drive.ps1 b/lib/ansible/modules/windows/win_mapped_drive.ps1 index fbdcc135f84..7bbfb193e3b 100644 --- a/lib/ansible/modules/windows/win_mapped_drive.ps1 +++ b/lib/ansible/modules/windows/win_mapped_drive.ps1 @@ -3,117 +3,617 @@ # Copyright: (c) 2017, Ansible Project # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) -#Requires -Module Ansible.ModuleUtils.Legacy +#AnsibleRequires -CSharpUtil Ansible.Basic +#Requires -Module Ansible.ModuleUtils.AddType -$ErrorActionPreference = 'Stop' +$spec = @{ + options = @{ + letter = @{ type = "str"; required = $true } + path = @{ type = "path"; } + state = @{ type = "str"; default = "present"; choices = @("absent", "present") } + username = @{ type = "str" } + password = @{ type = "str"; no_log = $true } + } + required_if = @( + ,@("state", "present", @("path")) + ) + supports_check_mode = $true +} -$params = Parse-Args $args -supports_check_mode $true -$check_mode = Get-AnsibleParam -obj $params -name "_ansible_check_mode" -type "bool" -default $false -$diff_mode = Get-AnsibleParam -obj $params -name "_ansible_diff" -type "bool" -default $false +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) -$letter = Get-AnsibleParam -obj $params -name "letter" -type "str" -failifempty $true -$path = Get-AnsibleParam -obj $params -name "path" -type "path" -$state = Get-AnsibleParam -obj $params -name "state" -type "str" -default "present" -validateset "absent","present" -$username = Get-AnsibleParam -obj $params -name "username" -type "str" -$password = Get-AnsibleParam -obj $params -name "password" -type "str" +$letter = $module.Params.letter +$path = $module.Params.path +$state = $module.Params.state +$username = $module.Params.username +$password = $module.Params.password -$result = @{ - changed = $false +if ($letter -notmatch "^[a-zA-z]{1}$") { + $module.FailJson("letter must be a single letter from A-Z, was: $letter") } +$letter_root = "$($letter):" -if ($diff_mode) { - $result.diff = @{} -} +$module.Diff.before = "" +$module.Diff.after = "" -if ($letter -notmatch "^[a-zA-z]{1}$") { - Fail-Json $result "letter must be a single letter from A-Z, was: $letter" -} +Add-CSharpType -AnsibleModule $module -References @' +using Microsoft.Win32.SafeHandles; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.ConstrainedExecution; +using System.Runtime.InteropServices; +using System.Security.Principal; +using System.Text; + +namespace Ansible.MappedDrive +{ + internal class NativeHelpers + { + public enum ResourceScope : uint + { + Connected = 0x00000001, + GlobalNet = 0x00000002, + Remembered = 0x00000003, + Recent = 0x00000004, + Context = 0x00000005, + } + + [Flags] + public enum ResourceType : uint + { + Any = 0x0000000, + Disk = 0x00000001, + Print = 0x00000002, + Reserved = 0x00000008, + Unknown = 0xFFFFFFFF, + } + + public enum CloseFlags : uint + { + None = 0x00000000, + UpdateProfile = 0x00000001, + } + + [Flags] + public enum AddFlags : uint + { + UpdateProfile = 0x00000001, + UpdateRecent = 0x00000002, + Temporary = 0x00000004, + Interactive = 0x00000008, + Prompt = 0x00000010, + Redirect = 0x00000080, + CurrentMedia = 0x00000200, + CommandLine = 0x00000800, + CmdSaveCred = 0x00001000, + CredReset = 0x00002000, + } + + public enum TokenElevationType + { + TokenElevationTypeDefault = 1, + TokenElevationTypeFull, + TokenElevationTypeLimited + } + + public enum TokenInformationClass + { + TokenUser = 1, + TokenPrivileges = 3, + TokenElevationType = 18, + TokenLinkedToken = 19, + } + + [StructLayout(LayoutKind.Sequential)] + public struct LUID + { + public UInt32 LowPart; + public Int32 HighPart; + + public static explicit operator UInt64(LUID l) + { + return (UInt64)((UInt64)l.HighPart << 32) | (UInt64)l.LowPart; + } + } + + [StructLayout(LayoutKind.Sequential)] + public struct LUID_AND_ATTRIBUTES + { + public LUID Luid; + public UInt32 Attributes; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct NETRESOURCEW + { + public ResourceScope dwScope; + public ResourceType dwType; + public UInt32 dwDisplayType; + public UInt32 dwUsage; + [MarshalAs(UnmanagedType.LPWStr)] public string lpLocalName; + [MarshalAs(UnmanagedType.LPWStr)] public string lpRemoteName; + [MarshalAs(UnmanagedType.LPWStr)] public string lpComment; + [MarshalAs(UnmanagedType.LPWStr)] public string lpProvider; + } + + [StructLayout(LayoutKind.Sequential)] + public struct SID_AND_ATTRIBUTES + { + public IntPtr Sid; + public UInt32 Attributes; + } + + [StructLayout(LayoutKind.Sequential)] + public struct TOKEN_PRIVILEGES + { + public UInt32 PrivilegeCount; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 1)] + public LUID_AND_ATTRIBUTES[] Privileges; + } -Function Get-MappedDriveTarget($letter) { - # Get-PSDrive and Get-CimInstance doesn't work through WinRM - $target = $null - if (Test-Path -Path HKCU:\Network\$letter) { - $target = (Get-ItemProperty -Path HKCU:\Network\$letter -Name RemotePath).RemotePath + [StructLayout(LayoutKind.Sequential)] + public struct TOKEN_USER + { + public SID_AND_ATTRIBUTES User; + } } - return $target -} + internal class NativeMethods + { + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool CloseHandle( + IntPtr hObject); + + [DllImport("advapi32.dll", SetLastError = true)] + public static extern bool GetTokenInformation( + SafeNativeHandle TokenHandle, + NativeHelpers.TokenInformationClass TokenInformationClass, + SafeMemoryBuffer TokenInformation, + UInt32 TokenInformationLength, + out UInt32 ReturnLength); + + [DllImport("advapi32.dll", SetLastError = true)] + public static extern bool ImpersonateLoggedOnUser( + SafeNativeHandle hToken); + + [DllImport("kernel32.dll")] + public static extern SafeNativeHandle GetCurrentProcess(); + + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern bool LookupPrivilegeNameW( + string lpSystemName, + ref NativeHelpers.LUID lpLuid, + StringBuilder lpName, + ref UInt32 cchName); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern SafeNativeHandle OpenProcess( + UInt32 dwDesiredAccess, + bool bInheritHandle, + UInt32 dwProcessId); + + [DllImport("advapi32.dll", SetLastError = true)] + public static extern bool OpenProcessToken( + SafeNativeHandle ProcessHandle, + TokenAccessLevels DesiredAccess, + out SafeNativeHandle TokenHandle); + + [DllImport("advapi32.dll", SetLastError = true)] + public static extern bool RevertToSelf(); + + [DllImport("Mpr.dll", CharSet = CharSet.Unicode)] + public static extern UInt32 WNetAddConnection2W( + NativeHelpers.NETRESOURCEW lpNetResource, + [MarshalAs(UnmanagedType.LPWStr)] string lpPassword, + [MarshalAs(UnmanagedType.LPWStr)] string lpUserName, + NativeHelpers.AddFlags dwFlags); + + [DllImport("Mpr.dll", CharSet = CharSet.Unicode)] + public static extern UInt32 WNetCancelConnection2W( + [MarshalAs(UnmanagedType.LPWStr)] string lpName, + NativeHelpers.CloseFlags dwFlags, + bool fForce); + + [DllImport("Mpr.dll")] + public static extern UInt32 WNetCloseEnum( + IntPtr hEnum); + + [DllImport("Mpr.dll", CharSet = CharSet.Unicode)] + public static extern UInt32 WNetEnumResourceW( + IntPtr hEnum, + ref Int32 lpcCount, + SafeMemoryBuffer lpBuffer, + ref UInt32 lpBufferSize); + + [DllImport("Mpr.dll", CharSet = CharSet.Unicode)] + public static extern UInt32 WNetOpenEnumW( + NativeHelpers.ResourceScope dwScope, + NativeHelpers.ResourceType dwType, + UInt32 dwUsage, + IntPtr lpNetResource, + out IntPtr lphEnum); + } -Function Remove-MappedDrive($letter) { - # Remove-PSDrive doesn't work through WinRM as it cannot view the mapped drives for the user - if (-not $check_mode) { - try { - &cmd.exe /c net use "$($letter):" /delete - } catch { - Fail-Json $result "failed to removed mapped drive $($letter): $($_.Exception.Message)" + internal class SafeMemoryBuffer : SafeHandleZeroOrMinusOneIsInvalid + { + public SafeMemoryBuffer() : base(true) { } + public SafeMemoryBuffer(int cb) : base(true) + { + base.SetHandle(Marshal.AllocHGlobal(cb)); + } + public SafeMemoryBuffer(IntPtr handle) : base(true) + { + base.SetHandle(handle); + } + + [ReliabilityContract(Consistency.WillNotCorruptState, Cer.MayFail)] + protected override bool ReleaseHandle() + { + Marshal.FreeHGlobal(handle); + return true; } } -} -$existing_target = Get-MappedDriveTarget -letter $letter + internal class Impersonation : IDisposable + { + private SafeNativeHandle hToken = null; -if ($state -eq "absent") { - if ($existing_target -ne $null) { - if ($path -ne $null) { - if ($existing_target -eq $path) { - Remove-MappedDrive -letter $letter - } else { - Fail-Json $result "did not delete mapped drive $letter, the target path is pointing to a different location at $existing_target" - } - } else { - Remove-MappedDrive -letter $letter + public Impersonation(SafeNativeHandle token) + { + hToken = token; + if (token != null) + if (!NativeMethods.ImpersonateLoggedOnUser(hToken)) + throw new Win32Exception("Failed to impersonate token with ImpersonateLoggedOnUser()"); } - $result.changed = $true - if ($diff_mode) { - $result.diff.prepared = "-$($letter): $existing_target" + public void Dispose() + { + if (hToken != null) + NativeMethods.RevertToSelf(); + GC.SuppressFinalize(this); } + ~Impersonation() { Dispose(); } } -} else { - if ($path -eq $null) { - Fail-Json $result "path must be set when creating a mapped drive" + + public class DriveInfo + { + public string Drive; + public string Path; } - $extra_args = @{} - if ($username -ne $null) { - $sec_password = ConvertTo-SecureString -String $password -AsPlainText -Force - $credential = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList $username, $sec_password - $extra_args.Credential = $credential + public class SafeNativeHandle : SafeHandleZeroOrMinusOneIsInvalid + { + public SafeNativeHandle() : base(true) { } + public SafeNativeHandle(IntPtr handle) : base(true) { this.handle = handle; } + + [ReliabilityContract(Consistency.WillNotCorruptState, Cer.MayFail)] + protected override bool ReleaseHandle() + { + return NativeMethods.CloseHandle(handle); + } } - $physical_drives = Get-PSDrive -PSProvider "FileSystem" - if ($letter -in $physical_drives.Name) { - Fail-Json $result "failed to create mapped drive $letter, this letter is in use and is pointing to a non UNC path" + public class Win32Exception : System.ComponentModel.Win32Exception + { + private string _msg; + public Win32Exception(string message) : this(Marshal.GetLastWin32Error(), message) { } + public Win32Exception(int errorCode, string message) : base(errorCode) + { + _msg = String.Format("{0} ({1}, Win32ErrorCode {2})", message, base.Message, errorCode); + } + public override string Message { get { return _msg; } } + public static explicit operator Win32Exception(string message) { return new Win32Exception(message); } } - if ($existing_target -ne $null) { - if ($existing_target -ne $path -or ($username -ne $null)) { - # the source path doesn't match or we are putting in a credential - Remove-MappedDrive -letter $letter - $result.changed = $true + public class Utils + { + private const TokenAccessLevels IMPERSONATE_ACCESS = TokenAccessLevels.Query | TokenAccessLevels.Duplicate; + private const UInt32 ERROR_SUCCESS = 0x00000000; + private const UInt32 ERROR_NO_MORE_ITEMS = 0x0000103; + + public static void AddMappedDrive(string drive, string path, SafeNativeHandle iToken, string username = null, string password = null) + { + NativeHelpers.NETRESOURCEW resource = new NativeHelpers.NETRESOURCEW + { + dwType = NativeHelpers.ResourceType.Disk, + lpLocalName = drive, + lpRemoteName = path, + }; + NativeHelpers.AddFlags dwFlags = NativeHelpers.AddFlags.UpdateProfile; + // While WNetAddConnection2W supports user/pass, this is only used for the first connection and the + // password is not remembered. We will delete the username mapping afterwards as it interferes with + // the implicit credential cache used in Windows + using (Impersonation imp = new Impersonation(iToken)) + { + UInt32 res = NativeMethods.WNetAddConnection2W(resource, password, username, dwFlags); + if (res != ERROR_SUCCESS) + throw new Win32Exception((int)res, String.Format("Failed to map {0} to '{1}' with WNetAddConnection2W()", drive, path)); + } + } + + public static List GetMappedDrives(SafeNativeHandle iToken) + { + using (Impersonation imp = new Impersonation(iToken)) + { + IntPtr enumPtr = IntPtr.Zero; + UInt32 res = NativeMethods.WNetOpenEnumW(NativeHelpers.ResourceScope.Remembered, NativeHelpers.ResourceType.Disk, + 0, IntPtr.Zero, out enumPtr); + if (res != ERROR_SUCCESS) + throw new Win32Exception((int)res, "WNetOpenEnumW()"); + + List resources = new List(); + try + { + // MS recommend a buffer size of 16 KiB + UInt32 bufferSize = 16384; + int lpcCount = -1; - try { - New-PSDrive -Name $letter -PSProvider "FileSystem" -root $path -Persist -WhatIf:$check_mode @extra_args | Out-Null - } catch { - Fail-Json $result "failed to create mapped drive $letter pointed to $($path): $($_.Exception.Message)" + // keep iterating the enum until ERROR_NO_MORE_ITEMS is returned + do + { + using (SafeMemoryBuffer buffer = new SafeMemoryBuffer((int)bufferSize)) + { + res = NativeMethods.WNetEnumResourceW(enumPtr, ref lpcCount, buffer, ref bufferSize); + if (res == ERROR_NO_MORE_ITEMS) + continue; + else if (res != ERROR_SUCCESS) + throw new Win32Exception((int)res, "WNetEnumResourceW()"); + lpcCount = lpcCount < 0 ? 0 : lpcCount; + + NativeHelpers.NETRESOURCEW[] rawResources = new NativeHelpers.NETRESOURCEW[lpcCount]; + PtrToStructureArray(rawResources, buffer.DangerousGetHandle()); + foreach (NativeHelpers.NETRESOURCEW resource in rawResources) + { + DriveInfo currentDrive = new DriveInfo + { + Drive = resource.lpLocalName, + Path = resource.lpRemoteName, + }; + resources.Add(currentDrive); + } + } + } + while (res != ERROR_NO_MORE_ITEMS); + } + finally + { + NativeMethods.WNetCloseEnum(enumPtr); + } + + return resources; + } + } + + public static void RemoveMappedDrive(string drive, SafeNativeHandle iToken) + { + using (Impersonation imp = new Impersonation(iToken)) + { + UInt32 res = NativeMethods.WNetCancelConnection2W(drive, NativeHelpers.CloseFlags.UpdateProfile, true); + if (res != ERROR_SUCCESS) + throw new Win32Exception((int)res, String.Format("Failed to remove mapped drive {0} with WNetCancelConnection2W()", drive)); + } + } + + public static SafeNativeHandle GetLimitedToken() + { + SafeNativeHandle hToken = null; + if (!NativeMethods.OpenProcessToken(NativeMethods.GetCurrentProcess(), IMPERSONATE_ACCESS, out hToken)) + throw new Win32Exception("Failed to open current process token with OpenProcessToken()"); + + using (hToken) + { + // Check the elevation type of the current token, only need to impersonate if it's a Full token + using (SafeMemoryBuffer tokenInfo = GetTokenInformation(hToken, NativeHelpers.TokenInformationClass.TokenElevationType)) + { + NativeHelpers.TokenElevationType tet = (NativeHelpers.TokenElevationType)Marshal.ReadInt32(tokenInfo.DangerousGetHandle()); + + // If we don't have a Full token, we don't need to get the limited one to set a mapped drive + if (tet != NativeHelpers.TokenElevationType.TokenElevationTypeFull) + return null; + } + + // We have a full token, need to get the TokenLinkedToken, this requires the SeTcbPrivilege privilege + // and we can get that from impersonating a SYSTEM account token. Without this privilege we only get + // an SecurityIdentification token which won't work for what we need + using (SafeNativeHandle systemToken = GetSystemToken()) + using (Impersonation systemImpersonation = new Impersonation(systemToken)) + using (SafeMemoryBuffer tokenInfo = GetTokenInformation(hToken, NativeHelpers.TokenInformationClass.TokenLinkedToken)) + return new SafeNativeHandle(Marshal.ReadIntPtr(tokenInfo.DangerousGetHandle())); + } + } + + private static SafeNativeHandle GetSystemToken() + { + foreach (System.Diagnostics.Process process in System.Diagnostics.Process.GetProcesses()) + { + using (process) + { + // 0x00000400 == PROCESS_QUERY_INFORMATION + using (SafeNativeHandle hProcess = NativeMethods.OpenProcess(0x00000400, false, (UInt32)process.Id)) + { + if (hProcess.IsInvalid) + continue; + + SafeNativeHandle hToken; + NativeMethods.OpenProcessToken(hProcess, IMPERSONATE_ACCESS, out hToken); + if (hToken.IsInvalid) + continue; + + if ("S-1-5-18" == GetTokenUserSID(hToken)) + { + // To get the TokenLinkedToken we need the SeTcbPrivilege, not all SYSTEM tokens have this + // assigned so we check before trying again + List actualPrivileges = GetTokenPrivileges(hToken); + if (actualPrivileges.Contains("SeTcbPrivilege")) + return hToken; + } + + hToken.Dispose(); + } + } + } + throw new InvalidOperationException("Failed to get a copy of the SYSTEM token required to de-elevate the current user's token"); + } + + private static List GetTokenPrivileges(SafeNativeHandle hToken) + { + using (SafeMemoryBuffer tokenInfo = GetTokenInformation(hToken, NativeHelpers.TokenInformationClass.TokenPrivileges)) + { + NativeHelpers.TOKEN_PRIVILEGES tokenPrivileges = (NativeHelpers.TOKEN_PRIVILEGES)Marshal.PtrToStructure( + tokenInfo.DangerousGetHandle(), typeof(NativeHelpers.TOKEN_PRIVILEGES)); + + NativeHelpers.LUID_AND_ATTRIBUTES[] luidAndAttributes = new NativeHelpers.LUID_AND_ATTRIBUTES[tokenPrivileges.PrivilegeCount]; + PtrToStructureArray(luidAndAttributes, IntPtr.Add(tokenInfo.DangerousGetHandle(), Marshal.SizeOf(tokenPrivileges.PrivilegeCount))); + + return luidAndAttributes.Select(x => GetPrivilegeName(x.Luid)).ToList(); } + } - if ($diff_mode) { - $result.diff.prepared = "-$($letter): $existing_target`n+$($letter): $path" + private static string GetTokenUserSID(SafeNativeHandle hToken) + { + using (SafeMemoryBuffer tokenInfo = GetTokenInformation(hToken, NativeHelpers.TokenInformationClass.TokenUser)) + { + NativeHelpers.TOKEN_USER tokenUser = (NativeHelpers.TOKEN_USER)Marshal.PtrToStructure(tokenInfo.DangerousGetHandle(), + typeof(NativeHelpers.TOKEN_USER)); + return new SecurityIdentifier(tokenUser.User.Sid).Value; } } + + private static SafeMemoryBuffer GetTokenInformation(SafeNativeHandle hToken, NativeHelpers.TokenInformationClass tokenClass) + { + UInt32 tokenLength; + bool res = NativeMethods.GetTokenInformation(hToken, tokenClass, new SafeMemoryBuffer(IntPtr.Zero), 0, out tokenLength); + if (!res && tokenLength == 0) // res will be false due to insufficient buffer size, we ignore if we got the buffer length + throw new Win32Exception(String.Format("GetTokenInformation({0}) failed to get buffer length", tokenClass.ToString())); + + SafeMemoryBuffer tokenInfo = new SafeMemoryBuffer((int)tokenLength); + if (!NativeMethods.GetTokenInformation(hToken, tokenClass, tokenInfo, tokenLength, out tokenLength)) + throw new Win32Exception(String.Format("GetTokenInformation({0}) failed", tokenClass.ToString())); + + return tokenInfo; + } + + private static string GetPrivilegeName(NativeHelpers.LUID luid) + { + UInt32 nameLen = 0; + NativeMethods.LookupPrivilegeNameW(null, ref luid, null, ref nameLen); + + StringBuilder name = new StringBuilder((int)(nameLen + 1)); + if (!NativeMethods.LookupPrivilegeNameW(null, ref luid, name, ref nameLen)) + throw new Win32Exception("LookupPrivilegeNameW() failed"); + + return name.ToString(); + } + + private static void PtrToStructureArray(T[] array, IntPtr ptr) + { + IntPtr ptrOffset = ptr; + for (int i = 0; i < array.Length; i++, ptrOffset = IntPtr.Add(ptrOffset, Marshal.SizeOf(typeof(T)))) + array[i] = (T)Marshal.PtrToStructure(ptrOffset, typeof(T)); + } + } +} +'@ + +<# +When we run with become and UAC is enabled, the become process will most likely be the Admin/Full token. This is +an issue with the WNetConnection APIs as the Full token is unable to add/enumerate/remove connections due to +Windows storing the connection details on each token session ID. Unless EnabledLinkedConnections (reg key) is +set to 1, the Full token is unable to manage connections in a persisted way whereas the Limited token is. This +is similar to running 'net use' normally and an admin process is unable to see those and vice versa. + +To overcome this problem, we attempt to get a handle on the Limited token for the current logon and impersonate +that before making any WNetConnection calls. If the token is not split, or we are already running on the Limited +token then no impersonatoin is used/required. This allows the module to run with become (required to access the +credential store) but still be able to manage the mapped connections. + +These are the following scenarios we have to handle; + + 1. Run without become + A network logon is usually not split so GetLimitedToken() will return $null and no impersonation is needed + 2. Run with become on admin user with admin priv + We will have a Full token, GetLimitedToken() will return the limited token and impersonation is used + 3. Run with become on admin user without admin priv + We are already running with a Limited token, GetLimitedToken() return $nul and no impersonation is needed + 4. Run with become on standard user + There's no split token, GetLimitedToken() will return $null and no impersonation is needed +#> +$impersonation_token = [Ansible.MappedDrive.Utils]::GetLimitedToken() + +try { + $existing_targets = [Ansible.MappedDrive.Utils]::GetMappedDrives($impersonation_token) + $existing_target = $existing_targets | Where-Object { $_.Drive -eq $letter_root } + + if ($existing_target) { + $module.Diff.before = @{ + letter = $letter + path = $existing_target.Path + } + } + + if ($state -eq "absent") { + if ($existing_target -ne $null) { + if ($null -ne $path -and $existing_target.Path -ne $path) { + $module.FailJson("did not delete mapped drive $letter, the target path is pointing to a different location at $( $existing_target.Path )") + } + if (-not $module.CheckMode) { + [Ansible.MappedDrive.Utils]::RemoveMappedDrive($letter_root, $impersonation_token) + } + + $module.Result.changed = $true + } } else { - try { - New-PSDrive -Name $letter -PSProvider "FileSystem" -Root $path -Persist -WhatIf:$check_mode @extra_args | Out-Null - } catch { - Fail-Json $result "failed to create mapped drive $letter pointed to $($path): $($_.Exception.Message)" + $physical_drives = Get-PSDrive -PSProvider "FileSystem" + if ($letter -in $physical_drives.Name) { + $module.FailJson("failed to create mapped drive $letter, this letter is in use and is pointing to a non UNC path") + } + + # PowerShell converts a $null value to "" when crossing the .NET marshaler, we need to convert the input + # to a missing value so it uses the defaults. We also need to Invoke it with MethodInfo.Invoke so the defaults + # are still used + $input_username = $username + if ($null -eq $username) { + $input_username = [Type]::Missing + } + $input_password = $password + if ($null -eq $password) { + $input_password = [Type]::Missing + } + $add_method = [Ansible.MappedDrive.Utils].GetMethod("AddMappedDrive") + + if ($null -ne $existing_target) { + if ($existing_target.Path -ne $path) { + if (-not $module.CheckMode) { + [Ansible.MappedDrive.Utils]::RemoveMappedDrive($letter_root, $impersonation_token) + $add_method.Invoke($null, [Object[]]@($letter_root, $path, $impersonation_token, $input_username, $input_password)) + } + $module.Result.changed = $true + } + } else { + if (-not $module.CheckMode) { + $add_method.Invoke($null, [Object[]]@($letter_root, $path, $impersonation_token, $input_username, $input_password)) + } + + $module.Result.changed = $true } - $result.changed = $true - if ($diff_mode) { - $result.diff.prepared = "+$($letter): $path" + # If username was set and we made a change, remove the UserName value so Windows will continue to use the cred + # cache. If we don't do this then the drive will fail to map in the future as WNetAddConnection does not cache + # the password and relies on the credential store. + if ($null -ne $username -and $module.Result.changed -and -not $module.CheckMode) { + Set-ItemProperty -Path HKCU:\Network\$letter -Name UserName -Value "" -WhatIf:$module.CheckMode } + + $module.Diff.after = @{ + letter = $letter + path = $path + } + } +} finally { + if ($null -ne $impersonation_token) { + $impersonation_token.Dispose() } } -Exit-Json $result +$module.ExitJson() diff --git a/lib/ansible/modules/windows/win_mapped_drive.py b/lib/ansible/modules/windows/win_mapped_drive.py index 48c4f028fc3..0d16fe401f6 100644 --- a/lib/ansible/modules/windows/win_mapped_drive.py +++ b/lib/ansible/modules/windows/win_mapped_drive.py @@ -20,12 +20,20 @@ short_description: Map network drives for users description: - Allows you to modify mapped network drives for individual users. notes: -- This can only map a network drive for the current executing user and does not - allow you to set a default drive for all users of a system. Use other - Microsoft tools like GPOs to achieve this goal. - You cannot use this module to access a mapped drive in another Ansible task, drives mapped with this module are only accessible when logging in interactively with the user through the console or RDP. +- It is recommend to run this module with become or CredSSP when the remote + path requires authentication. +- When using become or CredSSP, the task will have access to any local + credentials stored in the user's vault. +- If become or CredSSP is not available, the I(username) and I(password) + options can be used for the initial authentication but these are not + persisted. +- To create a system wide mount, use become with the U(SYSTEM) account. +- A system wide mount will always show as disconnected in Windows Explorer and + still relies on the user's credentials or credential vault for further + authentication if needed. options: letter: description: @@ -34,15 +42,18 @@ options: required: yes password: description: - - The password for C(username). + - The password for C(username) that is used when testing the initial + connection. + - This is never saved with a mapped drive, use the M(win_credential) module + to persist a username and password for a host. path: description: - The UNC path to map the drive to. - This is required if C(state=present). - - If C(state=absent) and path is not set, the module will delete the mapped - drive regardless of the target. - - If C(state=absent) and the path is set, the module will throw an error if - path does not match the target of the mapped drive. + - If C(state=absent) and I(path) is not set, the module will delete the + mapped drive regardless of the target. + - If C(state=absent) and the I(path) is set, the module will throw an error + if path does not match the target of the mapped drive. type: path state: description: @@ -52,9 +63,13 @@ options: default: present username: description: - - Credentials to map the drive with. - - The username MUST include the domain or servername like SERVER\user, see - the example for more information. + - The username that is used when testing the initial connection. + - This is never saved with a mapped drive, the the M(win_credential) module + to persist a username and password for a host. + - This is required if the mapped drive requires authentication with + custom credentials and become, or CredSSP cannot be used. + - If become or CredSSP is used, any credentials saved with + M(win_credential) will automatically be used instead. author: - Jordan Borean (@jborean93) ''' @@ -76,19 +91,44 @@ EXAMPLES = r''' path: \\domain\appdata\accounting state: absent -- name: Create mapped drive with local credentials +- name: Create mapped drive with credentials and save the username and password + block: + - name: Save the network credentials required for the mapped drive + win_credential: + name: server + type: domain_password + username: username@DOMAIN + secret: Password01 + state: present + + - name: Create a mapped drive that requires authentication + win_mapped_drive: + letter: M + path: \\SERVER\C$ + state: present + vars: + # become is required to save and retrieve the credentials in the tasks + ansible_become: yes + ansible_become_method: runas + ansible_become_user: '{{ ansible_user }}' + ansible_become_pass: '{{ ansible_password }}' + +- name: Create mapped drive with credentials that do not persist on the next logon win_mapped_drive: letter: M - path: \\SERVER\c$ - username: SERVER\Administrator - password: Password + path: \\SERVER\C$ + state: present + username: '{{ ansible_user }}' + password: '{{ ansible_password }}' -- name: Create mapped drive with domain credentials +- name: Create a system wide mapped drive win_mapped_drive: - letter: M - path: \\domain\appdata\it - username: DOMAIN\IT - password: Password + letter: S + path: \\SERVER\C$ + state: present + become: yes + become_method: runas + become_user: SYSTEM ''' RETURN = r''' diff --git a/test/integration/targets/win_mapped_drive/tasks/main.yml b/test/integration/targets/win_mapped_drive/tasks/main.yml index 09eb7f8f883..3e3455bef9b 100644 --- a/test/integration/targets/win_mapped_drive/tasks/main.yml +++ b/test/integration/targets/win_mapped_drive/tasks/main.yml @@ -2,6 +2,7 @@ # test setup - name: gather facts required by the tests setup: + gather_subset: platform - name: ensure mapped drive is deleted before test win_mapped_drive: @@ -31,12 +32,36 @@ - { name: '{{test_win_mapped_drive_path}}', path: '{{test_win_mapped_drive_local_path}}' } - { name: '{{test_win_mapped_drive_path2}}', path: '{{test_win_mapped_drive_local_path2}}' } +# This ensures we test out the split token/become behaviour +- name: ensure builtin Administrator has a split token + win_regedit: + path: HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System + name: FilterAdministratorToken + data: 1 + type: dword + register: admin_uac + +- name: reboot to apply Admin approval mode setting + win_reboot: + when: admin_uac is changed + - block: # tests - include_tasks: tests.yml # test cleanup always: + - name: remove stored credential + win_credential: + name: '{{ ansible_hostname }}' + type: domain_password + state: absent + vars: + ansible_become: yes + ansible_become_method: runas + ansible_become_user: '{{ ansible_user }}' + ansible_become_pass: '{{ ansible_password }}' + - name: ensure mapped drive is deleted at the end of the test win_mapped_drive: letter: '{{test_win_mapped_drive_letter}}' @@ -60,3 +85,15 @@ win_user: name: '{{test_win_mapped_drive_temp_user}}' state: absent + + - name: disable Admin approval mode if changed in test + win_regedit: + path: HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System + name: FilterAdministratorToken + data: 0 + type: dword + when: admin_uac is changed + + - name: reboot to apply Admin approval mode setting + win_reboot: + when: admin_uac is changed diff --git a/test/integration/targets/win_mapped_drive/tasks/tests.yml b/test/integration/targets/win_mapped_drive/tasks/tests.yml index 8dcc710d787..c11b5348690 100644 --- a/test/integration/targets/win_mapped_drive/tasks/tests.yml +++ b/test/integration/targets/win_mapped_drive/tasks/tests.yml @@ -2,6 +2,7 @@ - name: fail with invalid path win_mapped_drive: letter: invalid + state: absent register: fail_invalid_letter failed_when: "fail_invalid_letter.msg != 'letter must be a single letter from A-Z, was: invalid'" @@ -10,7 +11,7 @@ letter: '{{test_win_mapped_drive_letter}}' state: present register: fail_path_missing - failed_when: fail_path_missing.msg != 'path must be set when creating a mapped drive' + failed_when: "fail_path_missing.msg != 'state is present but all of the following are missing: path'" - name: fail when specifying letter with existing physical path win_mapped_drive: @@ -210,7 +211,7 @@ that: - map_with_credentials is changed - map_with_credentials_actual.rc == 0 - - map_with_credential_actual_username.value == '{{ansible_hostname}}\\{{test_win_mapped_drive_temp_user}}' + - map_with_credential_actual_username.value == '' # we explicitly remove the username part in the module - name: map drive with current credentials again win_mapped_drive: @@ -224,7 +225,7 @@ - name: assert map drive with current credentials again assert: that: - - map_with_credentials_again is changed # we expect a change as it will just delete and recreate if credentials are passed + - not map_with_credentials_again is changed - name: delete mapped drive without path check win_mapped_drive: @@ -270,3 +271,74 @@ assert: that: - delete_without_path_again is not changed + +- name: store credential for test network account + win_credential: + name: '{{ ansible_hostname }}' + type: domain_password + username: '{{ test_win_mapped_drive_temp_user }}' + secret: '{{ test_win_mapped_drive_temp_password }}' + state: present + vars: &become_vars + ansible_become: yes + ansible_become_method: runas + ansible_become_user: '{{ ansible_user }}' + ansible_become_pass: '{{ ansible_password }}' + +- name: map drive with stored cred (check mode) + win_mapped_drive: + letter: '{{test_win_mapped_drive_letter}}' + path: \\{{ansible_hostname}}\{{test_win_mapped_drive_path}} + state: present + check_mode: yes + vars: *become_vars + register: map_with_stored_cred_check + +- name: get actual of map drive with stored cred (check mode) + win_command: 'net use {{test_win_mapped_drive_letter}}:' + register: map_with_stored_cred_actual_check + failed_when: False + +- name: assert map drive with stored cred (check mode) + assert: + that: + - map_with_stored_cred_check is changed + - map_with_stored_cred_actual_check.rc == 2 + +- name: map drive with stored cred + win_mapped_drive: + letter: '{{test_win_mapped_drive_letter}}' + path: \\{{ansible_hostname}}\{{test_win_mapped_drive_path}} + state: present + vars: *become_vars + register: map_with_stored_cred + +- name: get actual of map drive with stored cred + win_command: 'net use {{test_win_mapped_drive_letter}}:' + register: map_with_stored_cred_actual + +- name: get username of mapped network drive with stored cred + win_reg_stat: + path: HKCU:\Network\{{test_win_mapped_drive_letter}} + name: UserName + register: map_with_stored_cred_actual_username + +- name: assert map drive with stored cred + assert: + that: + - map_with_stored_cred is changed + - map_with_stored_cred_actual.rc == 0 + - map_with_stored_cred_actual_username.value == '' + +- name: map drive with stored cred again + win_mapped_drive: + letter: '{{test_win_mapped_drive_letter}}' + path: \\{{ansible_hostname}}\{{test_win_mapped_drive_path}} + state: present + vars: *become_vars + register: map_with_stored_cred_again + +- name: assert map drive with stored cred again + assert: + that: + - not map_with_stored_cred_again is changed