win_domain_object_info: new module (#67450)

* win_domain_object_info: new module

* Added basic integration tests
pull/67376/head
Jordan Borean 4 years ago committed by GitHub
parent be26f4916f
commit 38f26ffcc7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -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()

@ -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",
}]
'''

@ -0,0 +1,5 @@
---
- name: remove test domain user
win_domain_user:
name: '{{ test_user.distinguished_name }}'
state: absent

@ -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
Loading…
Cancel
Save