diff --git a/lib/ansible/modules/windows/win_domain_object_info.ps1 b/lib/ansible/modules/windows/win_domain_object_info.ps1 new file mode 100644 index 00000000000..f8f6f6457bd --- /dev/null +++ b/lib/ansible/modules/windows/win_domain_object_info.ps1 @@ -0,0 +1,271 @@ +#!powershell + +# Copyright: (c) 2020, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic +#Requires -Module Ansible.ModuleUtils.AddType + +$spec = @{ + options = @{ + domain_password = @{ type = 'str'; no_log = $true } + domain_server = @{ type = 'str' } + domain_username = @{ type = 'str' } + filter = @{ type = 'str' } + identity = @{ type = 'str' } + include_deleted = @{ type = 'bool'; default = $false } + ldap_filter = @{ type = 'str' } + properties = @{ type = 'list'; elements = 'str' } + search_base = @{ type = 'str' } + search_scope = @{ type = 'str'; choices = @('base', 'one_level', 'subtree') } + } + supports_check_mode = $true + mutually_exclusive = @( + @('filter', 'identity', 'ldap_filter'), + @('identity', 'search_base'), + @('identity', 'search_scope') + ) + required_one_of = @( + ,@('filter', 'identity', 'ldap_filter') + ) + required_together = @(,@('domain_username', 'domain_password')) +} + +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +$module.Result.objects = @() # Always ensure this is returned even in a failure. + +$domainServer = $module.Params.domain_server +$domainPassword = $module.Params.domain_password +$domainUsername = $module.Params.domain_username +$filter = $module.Params.filter +$identity = $module.Params.identity +$includeDeleted = $module.Params.include_deleted +$ldapFilter = $module.Params.ldap_filter +$properties = $module.Params.properties +$searchBase = $module.Params.search_base +$searchScope = $module.Params.search_scope + +$credential = $null +if ($domainUsername) { + $credential = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList @( + $domainUsername, + (ConvertTo-SecureString -AsPlainText -Force -String $domainPassword) + ) +} + +Add-CSharpType -References @' +using System; + +namespace Ansible.WinDomainObjectInfo +{ + [Flags] + public enum UserAccountControl : int + { + ADS_UF_SCRIPT = 0x00000001, + ADS_UF_ACCOUNTDISABLE = 0x00000002, + ADS_UF_HOMEDIR_REQUIRED = 0x00000008, + ADS_UF_LOCKOUT = 0x00000010, + ADS_UF_PASSWD_NOTREQD = 0x00000020, + ADS_UF_PASSWD_CANT_CHANGE = 0x00000040, + ADS_UF_ENCRYPTED_TEXT_PASSWORD_ALLOWED = 0x00000080, + ADS_UF_TEMP_DUPLICATE_ACCOUNT = 0x00000100, + ADS_UF_NORMAL_ACCOUNT = 0x00000200, + ADS_UF_INTERDOMAIN_TRUST_ACCOUNT = 0x00000800, + ADS_UF_WORKSTATION_TRUST_ACCOUNT = 0x00001000, + ADS_UF_SERVER_TRUST_ACCOUNT = 0x00002000, + ADS_UF_DONT_EXPIRE_PASSWD = 0x00010000, + ADS_UF_MNS_LOGON_ACCOUNT = 0x00020000, + ADS_UF_SMARTCARD_REQUIRED = 0x00040000, + ADS_UF_TRUSTED_FOR_DELEGATION = 0x00080000, + ADS_UF_NOT_DELEGATED = 0x00100000, + ADS_UF_USE_DES_KEY_ONLY = 0x00200000, + ADS_UF_DONT_REQUIRE_PREAUTH = 0x00400000, + ADS_UF_PASSWORD_EXPIRED = 0x00800000, + ADS_UF_TRUSTED_TO_AUTHENTICATE_FOR_DELEGATION = 0x01000000, + } + + public enum sAMAccountType : int + { + SAM_DOMAIN_OBJECT = 0x00000000, + SAM_GROUP_OBJECT = 0x10000000, + SAM_NON_SECURITY_GROUP_OBJECT = 0x10000001, + SAM_ALIAS_OBJECT = 0x20000000, + SAM_NON_SECURITY_ALIAS_OBJECT = 0x20000001, + SAM_USER_OBJECT = 0x30000000, + SAM_NORMAL_USER_ACCOUNT = 0x30000000, + SAM_MACHINE_ACCOUNT = 0x30000001, + SAM_TRUST_ACCOUNT = 0x30000002, + SAM_APP_BASIC_GROUP = 0x40000000, + SAM_APP_QUERY_GROUP = 0x40000001, + SAM_ACCOUNT_TYPE_MAX = 0x7fffffff, + } +} +'@ + +Function ConvertTo-OutputValue { + [CmdletBinding()] + Param ( + [Parameter(Mandatory=$true)] + [AllowNull()] + [Object] + $InputObject + ) + + if ($InputObject -is [System.Security.Principal.SecurityIdentifier]) { + # Syntax: SID - Only serialize the SID as a string and not the other metadata properties. + $sidInfo = @{ + Sid = $InputObject.Value + } + + # Try and map the SID to the account name, this may fail if the SID is invalid or not mappable. + try { + $sidInfo.Name = $InputObject.Translate([System.Security.Principal.NTAccount]).Value + } catch [System.Security.Principal.IdentityNotMappedException] { + $sidInfo.Name = $null + } + + $sidInfo + } elseif ($InputObject -is [Byte[]]) { + # Syntax: Octet String - By default will serialize as a list of decimal values per byte, instead return a + # Base64 string as Ansible can easily parse that. + [System.Convert]::ToBase64String($InputObject) + } elseif ($InputObject -is [DateTime]) { + # Syntax: UTC Coded Time - .NET DateTimes serialized as in the form "Date(FILETIME)" which isn't easily + # parsable by Ansible, instead return as an ISO 8601 string in the UTC timezone. + [TimeZoneInfo]::ConvertTimeToUtc($InputObject).ToString("o") + } elseif ($InputObject -is [System.Security.AccessControl.ObjectSecurity]) { + # Complex object which isn't easily serializable. Instead we should just return the SDDL string. If a user + # needs to parse this then they really need to reprocess the SDDL string and process their results on another + # win_shell task. + $InputObject.GetSecurityDescriptorSddlForm(([System.Security.AccessControl.AccessControlSections]::All)) + } else { + # Syntax: (All Others) - The default serialization handling of other syntaxes are fine, don't do anything. + $InputObject + } +} + +<# +Calling Get-ADObject that returns multiple objects with -Properties * will only return the properties that were set on +the first found object. To counter this problem we will first call Get-ADObject to list all the objects that match the +filter specified then get the properties on each object. +#> + +$commonParams = @{ + IncludeDeletedObjects = $includeDeleted +} + +if ($credential) { + $commonParams.Credential = $credential +} + +if ($domainServer) { + $commonParams.Server = $domainServer +} + +# First get the IDs for all the AD objects that match the filter specified. +$getParams = @{ + Properties = @('DistinguishedName', 'ObjectGUID') +} + +if ($filter) { + $getParams.Filter = $filter +} elseif ($identity) { + $getParams.Identity = $identity +} elseif ($ldapFilter) { + $getParams.LDAPFilter = $ldapFilter +} + +# Explicit check on $null as an empty string is different from not being set. +if ($null -ne $searchBase) { + $getParams.SearchBase = $searchbase +} + +if ($searchScope) { + $getParams.SearchScope = switch($searchScope) { + base { 'Base' } + one_level { 'OneLevel' } + subtree { 'Subtree' } + } +} + +try { + # We run this in a custom PowerShell pipeline so that users of this module can't use any of the variables defined + # above in their filter. While the cmdlet won't execute sub expressions we don't want anyone implicitly relying on + # a defined variable in this module in case we ever change the name or remove it. + $ps = [PowerShell]::Create() + $null = $ps.AddCommand('Get-ADObject').AddParameters($commonParams).AddParameters($getParams) + $null = $ps.AddCommand('Select-Object').AddParameter('Property', @('DistinguishedName', 'ObjectGUID')) + + $foundGuids = @($ps.Invoke()) +} catch { + # Because we ran in a pipeline we can't catch ADIdentityNotFoundException. Instead just get the base exception and + # do the error checking on that. + if ($_.Exception.GetBaseException() -is [Microsoft.ActiveDirectory.Management.ADIdentityNotFoundException]) { + $foundGuids = @() + } else { + # The exception is from the .Invoke() call, compare on the InnerException which was what was actually raised by + # the pipeline. + $innerException = $_.Exception.InnerException.InnerException + if ($innerException -is [Microsoft.ActiveDirectory.Management.ADServerDownException]) { + # Point users in the direction of the double hop problem as that is what is typically the cause of this. + $msg = "Failed to contact the AD server, this could be caused by the double hop problem over WinRM. " + $msg += "Try using the module with auth as Kerberos with credential delegation or CredSSP, become, or " + $msg += "defining the domain_username and domain_password module parameters." + $module.FailJson($msg, $innerException) + } else { + throw $innerException + } + } +} + +$getParams = @{} +if ($properties) { + $getParams.Properties = $properties +} +$module.Result.objects = @(foreach ($adId in $foundGuids) { + try { + $adObject = Get-ADObject @commonParams @getParams -Identity $adId.ObjectGUID + } catch { + $msg = "Failed to retrieve properties for AD Object '$($adId.DistinguishedName)': $($_.Exception.Message)" + $module.Warn($msg) + continue + } + + $propertyNames = $adObject.PropertyNames + $propertyNames += ($properties | Where-Object { $_ -ne '*' }) + + # Now process each property to an easy to represent string + $filteredObject = [Ordered]@{} + foreach ($name in ($propertyNames | Sort-Object)) { + # In the case of explicit properties that were asked for but weren't set, Get-ADObject won't actually return + # the property so this is a defensive check against that scenario. + if (-not $adObject.PSObject.Properties.Name.Contains($name)) { + $filteredObject.$name = $null + continue + } + + $value = $adObject.$name + if ($value -is [Microsoft.ActiveDirectory.Management.ADPropertyValueCollection]) { + $value = foreach ($v in $value) { + ConvertTo-OutputValue -InputObject $v + } + } else { + $value = ConvertTo-OutputValue -InputObject $value + } + $filteredObject.$name = $value + + # For these 2 properties, add an _AnsibleFlags attribute which contains the enum strings that are set. + if ($name -eq 'sAMAccountType') { + $enumValue = [Ansible.WinDomainObjectInfo.sAMAccountType]$value + $filteredObject.'sAMAccountType_AnsibleFlags' = $enumValue.ToString() -split ', ' + } elseif ($name -eq 'userAccountControl') { + $enumValue = [Ansible.WinDomainObjectInfo.UserAccountControl]$value + $filteredObject.'userAccountControl_AnsibleFlags' = $enumValue.ToString() -split ', ' + } + } + + $filteredObject +}) + +$module.ExitJson() diff --git a/lib/ansible/modules/windows/win_domain_object_info.py b/lib/ansible/modules/windows/win_domain_object_info.py new file mode 100644 index 00000000000..008b141b1bd --- /dev/null +++ b/lib/ansible/modules/windows/win_domain_object_info.py @@ -0,0 +1,162 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2020, 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_domain_object_info +version_added: '2.10' +short_description: Gather information an Active Directory object +description: +- Gather information about multiple Active Directory object(s). +options: + domain_password: + description: + - The password for C(domain_username). + type: str + domain_server: + description: + - Specified the Active Directory Domain Services instance to connect to. + - Can be in the form of an FQDN or NetBIOS name. + - If not specified then the value is based on the default domain of the computer running PowerShell. + type: str + domain_username: + description: + - The username to use when interacting with AD. + - If this is not set then the user that is used for authentication will be the connection user. + - Ansible will be unable to use the connection user unless auth is Kerberos with credential delegation or CredSSP, + or become is used on the task. + type: str + filter: + description: + - Specifies a query string using the PowerShell Expression Language syntax. + - This follows the same rules and formatting as the C(-Filter) parameter for the PowerShell AD cmdlets exception + there is no variable substitutions. + - This is mutually exclusive with I(identity) and I(ldap_filter). + type: str + identity: + description: + - Specifies a single Active Directory object by its distinguished name or its object GUID. + - This is mutually exclusive with I(filter) and I(ldap_filter). + - This cannot be used with either the I(search_base) or I(search_scope) options. + type: str + include_deleted: + description: + - Also search for deleted Active Directory objects. + default: no + type: bool + ldap_filter: + description: + - Like I(filter) but this is a tradiitional LDAP query string to filter the objects to return. + - This is mutually exclusive with I(filter) and I(identity). + type: str + properties: + description: + - A list of properties to return. + - If a property is C(*), all properties that have a set value on the AD object will be returned. + - If a property is valid on the object but not set, it is only returned if defined explicitly in this option list. + - The properties C(DistinguishedName), C(Name), C(ObjectClass), and C(ObjectGUID) are always returned. + - Specifying multiple properties can have a performance impact, it is best to only return what is needed. + - If an invalid property is specified then the module will display a warning for each object it is invalid on. + type: list + elements: str + search_base: + description: + - Specify the Active Directory path to search for objects in. + - This cannot be set with I(identity). + - By default the search base is the default naming context of the target AD instance which is the DN returned by + "(Get-ADRootDSE).defaultNamingContext". + type: str + search_scope: + description: + - Specify the scope of when searching for an object in the C(search_base). + - C(base) will limit the search to the base object so the maximum number of objects returned is always one. This + will not search any objects inside a container.. + - C(one_level) will search the current path and any immediate objects in that path. + - C(subtree) will search the current path and all objects of that path recursively. + - This cannot be set with I(identity). + choices: + - base + - one_level + - subtree + type: str +notes: +- The C(sAMAccountType_AnsibleFlags) and C(userAccountControl_AnsibleFlags) return property is something set by the + module itself as an easy way to view what those flags represent. These properties cannot be used as part of the + I(filter) or I(ldap_filter) and are automatically added if those properties were requested. +author: +- Jordan Borean (@jborean93) +''' + +EXAMPLES = r''' +- name: Get all properties for the specified account using its DistinguishedName + win_domain_object_info: + identity: CN=Username,CN=Users,DC=domain,DC=com + properties: '*' + +- name: Get the SID for all user accounts as a filter + win_domain_object_info: + filter: ObjectClass -eq 'user' -and objectCategory -eq 'Person' + properties: + - objectSid + +- name: Get the SID for all user accounts as a LDAP filter + win_domain_object_info: + ldap_filter: (&(objectClass=user)(objectCategory=Person)) + properties: + - objectSid + +- name: Search all computer accounts in a specific path that were added after February 1st + win_domain_object_info: + filter: objectClass -eq 'computer' -and whenCreated -gt '20200201000000.0Z' + properties: '*' + search_scope: one_level + search_base: CN=Computers,DC=domain,DC=com +''' + +RETURN = r''' +objects: + description: + - A list of dictionaries that are the Active Directory objects found and the properties requested. + - The dict's keys are the property name and the value is the value for the property. + - All date properties are return in the ISO 8601 format in the UTC timezone. + - All SID properties are returned as a dict with the keys C(Sid) as the SID string and C(Name) as the translated SID + account name. + - All byte properties are returned as a base64 string. + - All security descriptor properties are returned as the SDDL string of that descriptor. + - The properties C(DistinguishedName), C(Name), C(ObjectClass), and C(ObjectGUID) are always returned. + returned: always + type: list + elements: dict + sample: | + [{ + "accountExpires": 0, + "adminCount": 1, + "CanonicalName": "domain.com/Users/Administrator", + "CN": "Administrator", + "Created": "2020-01-13T09:03:22.0000000Z", + "Description": "Built-in account for administering computer/domain", + "DisplayName": null, + "DistinguishedName": "CN=Administrator,CN=Users,DC=domain,DC=com", + "memberOf": [ + "CN=Group Policy Creator Owners,CN=Users,DC=domain,DC=com", + "CN=Domain Admins",CN=Users,DC=domain,DC=com" + ], + "Name": "Administrator", + "nTSecurityDescriptor": "O:DAG:DAD:PAI(A;;LCRPLORC;;;AU)(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;SY)(A;;CCDCLCSWRPWPLOCRSDRCWDWO;;;BA)", + "ObjectCategory": "CN=Person,CN=Schema,CN=Configuration,DC=domain,DC=com", + "ObjectClass": "user", + "ObjectGUID": "c8c6569e-4688-4f3c-8462-afc4ff60817b", + "objectSid": { + "Sid": "S-1-5-21-2959096244-3298113601-420842770-500", + "Name": "DOMAIN\Administrator" + }, + "sAMAccountName": "Administrator", + }] +''' diff --git a/test/integration/targets/win_domain_object_info/aliases b/test/integration/targets/win_domain_object_info/aliases new file mode 100644 index 00000000000..ad7ccf7ada2 --- /dev/null +++ b/test/integration/targets/win_domain_object_info/aliases @@ -0,0 +1 @@ +unsupported diff --git a/test/integration/targets/win_domain_object_info/handlers/main.yml b/test/integration/targets/win_domain_object_info/handlers/main.yml new file mode 100644 index 00000000000..76a2a0f7627 --- /dev/null +++ b/test/integration/targets/win_domain_object_info/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: remove test domain user + win_domain_user: + name: '{{ test_user.distinguished_name }}' + state: absent diff --git a/test/integration/targets/win_domain_object_info/tasks/main.yml b/test/integration/targets/win_domain_object_info/tasks/main.yml new file mode 100644 index 00000000000..54ea126aa9c --- /dev/null +++ b/test/integration/targets/win_domain_object_info/tasks/main.yml @@ -0,0 +1,125 @@ +# These tests can't run in CI, this is really just a basic smoke tests for local runs. +--- +- name: assert better error message on auth failure + win_domain_object_info: + identity: id + register: fail_auth + failed_when: '"Failed to contact the AD server, this could be caused by the double hop problem" not in fail_auth.msg' + vars: + ansible_winrm_transport: ntlm + ansible_psrp_auth: ntlm + +- name: create test ad user + win_domain_user: + name: Ansible Test + firstname: Ansible + surname: Test + company: Contoso R Us + password: Password01 + state: present + password_never_expires: yes + groups: + - Domain Users + enabled: false + register: test_user + notify: remove test domain user + +- name: set a binary attribute and return other useful info missing from above + win_shell: | + Set-ADUser -Identity '{{ test_user.sid }}' -Replace @{ audio = @([byte[]]@(1, 2, 3, 4), [byte[]]@(5, 6, 7, 8)) } + + $user = Get-ADUser -Identity '{{ test_user.sid }}' -Properties modifyTimestamp, ObjectGUID + + [TimeZoneInfo]::ConvertTimeToUtc($user.modifyTimestamp).ToString('o') + $user.ObjectGUID.ToString() + ([System.Security.Principal.SecurityIdentifier]'{{ test_user.sid }}').Translate([System.Security.Principal.NTAccount]).Value + register: test_user_extras + +- name: set other test info for easier access + set_fact: + test_user_mod_date: '{{ test_user_extras.stdout_lines[0] }}' + test_user_id: '{{ test_user_extras.stdout_lines[1] }}' + test_user_name: '{{ test_user_extras.stdout_lines[2] }}' + +- name: get properties for single user by DN + win_domain_object_info: + identity: '{{ test_user.distinguished_name }}' + register: by_identity + check_mode: yes # Just verifies it runs in check mode + +- name: assert get properties for single user by DN + assert: + that: + - not by_identity is changed + - by_identity.objects | length == 1 + - by_identity.objects[0].keys() | list | length == 4 + - by_identity.objects[0].DistinguishedName == test_user.distinguished_name + - by_identity.objects[0].Name == 'Ansible Test' + - by_identity.objects[0].ObjectClass == 'user' + - by_identity.objects[0].ObjectGUID == test_user_id + +- name: get specific properties by GUID + win_domain_object_info: + identity: '{{ test_user_id }}' + properties: + - audio # byte[] + - company # string + - department # not set + - logonCount # int + - modifyTimestamp # DateTime + - nTSecurityDescriptor # SecurityDescriptor as SDDL + - objectSID # SID + - ProtectedFromAccidentalDeletion # bool + - sAMAccountType # Test out the enum string attribute that we add + - userAccountControl # Test ou the enum string attribute that we add + register: by_guid_custom_props + +- name: assert get specific properties by GUID + assert: + that: + - not by_guid_custom_props is changed + - by_guid_custom_props.objects | length == 1 + - by_guid_custom_props.objects[0].DistinguishedName == test_user.distinguished_name + - by_guid_custom_props.objects[0].Name == 'Ansible Test' + - by_guid_custom_props.objects[0].ObjectClass == 'user' + - by_guid_custom_props.objects[0].ObjectGUID == test_user_id + - not by_guid_custom_props.objects[0].ProtectedFromAccidentalDeletion + - by_guid_custom_props.objects[0].audio == ['BQYHCA==', 'AQIDBA=='] + - by_guid_custom_props.objects[0].company == 'Contoso R Us' + - by_guid_custom_props.objects[0].department == None + - by_guid_custom_props.objects[0].logonCount == 0 + - by_guid_custom_props.objects[0].modifyTimestamp == test_user_mod_date + - by_guid_custom_props.objects[0].nTSecurityDescriptor.startswith('O:DAG:DAD:AI(') + - by_guid_custom_props.objects[0].objectSID.Name == test_user_name + - by_guid_custom_props.objects[0].objectSID.Sid == test_user.sid + - by_guid_custom_props.objects[0].sAMAccountType == 805306368 + - by_guid_custom_props.objects[0].sAMAccountType_AnsibleFlags == ['SAM_USER_OBJECT'] + - by_guid_custom_props.objects[0].userAccountControl == 66050 + - by_guid_custom_props.objects[0].userAccountControl_AnsibleFlags == ['ADS_UF_ACCOUNTDISABLE', 'ADS_UF_NORMAL_ACCOUNT', 'ADS_UF_DONT_EXPIRE_PASSWD'] + +- name: get invalid property + win_domain_object_info: + filter: sAMAccountName -eq 'Ansible Test' + properties: + - FakeProperty + register: invalid_prop_warning + +- name: assert get invalid property + assert: + that: + - not invalid_prop_warning is changed + - invalid_prop_warning.objects | length == 0 + - invalid_prop_warning.warnings | length == 1 + - '"Failed to retrieve properties for AD object" not in invalid_prop_warning.warnings[0]' + +- name: get by ldap filter returning multiple + win_domain_object_info: + ldap_filter: (&(objectClass=computer)(objectCategory=computer)) + properties: '*' + register: multiple_ldap + +- name: assert get by ldap filter returning multiple + assert: + that: + - not multiple_ldap is changed + - multiple_ldap.objects | length > 1