1 #!powershell
2 
3 # Copyright: (c) 2020, Ansible Project
4 # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
5 
6 #AnsibleRequires -CSharpUtil Ansible.Basic
7 #Requires -Module Ansible.ModuleUtils.AddType
8 
9 $spec = @{
10     options = @{
11         domain_password = @{ type = 'str'; no_log = $true }
12         domain_server = @{ type = 'str' }
13         domain_username = @{ type = 'str' }
=()14         filter = @{ type = 'str' }
15         identity = @{ type = 'str' }
16         include_deleted = @{ type = 'bool'; default = $false }
17         ldap_filter = @{ type = 'str' }
18         properties = @{ type = 'list'; elements = 'str' }
19         search_base = @{ type = 'str' }
20         search_scope = @{ type = 'str'; choices = @('base', 'one_level', 'subtree') }
21     }
22     supports_check_mode = $true
23     mutually_exclusive = @(
24         @('filter', 'identity', 'ldap_filter'),
25         @('identity', 'search_base'),
26         @('identity', 'search_scope')
27     )
28     required_one_of = @(
29         ,@('filter', 'identity', 'ldap_filter')
30     )
31     required_together = @(,@('domain_username', 'domain_password'))
32 }
33 
34 $module = [Ansible.Basic.AnsibleModule]::Create($args, $spec)
35 
36 $module.Result.objects = @()  # Always ensure this is returned even in a failure.
37 
38 $domainServer = $module.Params.domain_server
39 $domainPassword = $module.Params.domain_password
40 $domainUsername = $module.Params.domain_username
41 $filter = $module.Params.filter
42 $identity = $module.Params.identity
43 $includeDeleted = $module.Params.include_deleted
44 $ldapFilter = $module.Params.ldap_filter
45 $properties = $module.Params.properties
46 $searchBase = $module.Params.search_base
47 $searchScope = $module.Params.search_scope
48 
49 $credential = $null
50 if ($domainUsername) {
51     $credential = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList @(
52         $domainUsername,
53         (ConvertTo-SecureString -AsPlainText -Force -String $domainPassword)
54     )
55 }
56 
57 Add-CSharpType -References @'
58 using System;
59 
60 namespace Ansible.WinDomainObjectInfo
61 {
62     [Flags]
63     public enum UserAccountControl : int
64     {
65         ADS_UF_SCRIPT = 0x00000001,
66         ADS_UF_ACCOUNTDISABLE = 0x00000002,
67         ADS_UF_HOMEDIR_REQUIRED = 0x00000008,
68         ADS_UF_LOCKOUT = 0x00000010,
69         ADS_UF_PASSWD_NOTREQD = 0x00000020,
70         ADS_UF_PASSWD_CANT_CHANGE = 0x00000040,
71         ADS_UF_ENCRYPTED_TEXT_PASSWORD_ALLOWED = 0x00000080,
72         ADS_UF_TEMP_DUPLICATE_ACCOUNT = 0x00000100,
73         ADS_UF_NORMAL_ACCOUNT = 0x00000200,
74         ADS_UF_INTERDOMAIN_TRUST_ACCOUNT = 0x00000800,
75         ADS_UF_WORKSTATION_TRUST_ACCOUNT = 0x00001000,
76         ADS_UF_SERVER_TRUST_ACCOUNT = 0x00002000,
77         ADS_UF_DONT_EXPIRE_PASSWD = 0x00010000,
78         ADS_UF_MNS_LOGON_ACCOUNT = 0x00020000,
79         ADS_UF_SMARTCARD_REQUIRED = 0x00040000,
80         ADS_UF_TRUSTED_FOR_DELEGATION = 0x00080000,
81         ADS_UF_NOT_DELEGATED = 0x00100000,
82         ADS_UF_USE_DES_KEY_ONLY = 0x00200000,
83         ADS_UF_DONT_REQUIRE_PREAUTH = 0x00400000,
84         ADS_UF_PASSWORD_EXPIRED = 0x00800000,
85         ADS_UF_TRUSTED_TO_AUTHENTICATE_FOR_DELEGATION = 0x01000000,
86     }
87 
88     public enum sAMAccountType : int
89     {
90         SAM_DOMAIN_OBJECT = 0x00000000,
91         SAM_GROUP_OBJECT = 0x10000000,
92         SAM_NON_SECURITY_GROUP_OBJECT = 0x10000001,
93         SAM_ALIAS_OBJECT = 0x20000000,
94         SAM_NON_SECURITY_ALIAS_OBJECT = 0x20000001,
95         SAM_USER_OBJECT = 0x30000000,
96         SAM_NORMAL_USER_ACCOUNT = 0x30000000,
97         SAM_MACHINE_ACCOUNT = 0x30000001,
98         SAM_TRUST_ACCOUNT = 0x30000002,
99         SAM_APP_BASIC_GROUP = 0x40000000,
100         SAM_APP_QUERY_GROUP = 0x40000001,
101         SAM_ACCOUNT_TYPE_MAX = 0x7fffffff,
102     }
103 }
104 '@
105 
ConvertTo-OutputValuenull106 Function ConvertTo-OutputValue {
107     [CmdletBinding()]
108     Param (
109         [Parameter(Mandatory=$true)]
110         [AllowNull()]
111         [Object]
112         $InputObject
113     )
114 
115     if ($InputObject -is [System.Security.Principal.SecurityIdentifier]) {
116         # Syntax: SID - Only serialize the SID as a string and not the other metadata properties.
117         $sidInfo = @{
118             Sid = $InputObject.Value
119         }
120 
121         # Try and map the SID to the account name, this may fail if the SID is invalid or not mappable.
122         try {
123             $sidInfo.Name = $InputObject.Translate([System.Security.Principal.NTAccount]).Value
124         } catch [System.Security.Principal.IdentityNotMappedException] {
125             $sidInfo.Name = $null
126         }
127 
128         $sidInfo
129     } elseif ($InputObject -is [Byte[]]) {
130         # Syntax: Octet String - By default will serialize as a list of decimal values per byte, instead return a
131         # Base64 string as Ansible can easily parse that.
132         [System.Convert]::ToBase64String($InputObject)
133     } elseif ($InputObject -is [DateTime]) {
134         # Syntax: UTC Coded Time - .NET DateTimes serialized as in the form "Date(FILETIME)" which isn't easily
135         # parsable by Ansible, instead return as an ISO 8601 string in the UTC timezone.
136         [TimeZoneInfo]::ConvertTimeToUtc($InputObject).ToString("o")
137     } elseif ($InputObject -is [System.Security.AccessControl.ObjectSecurity]) {
138         # Complex object which isn't easily serializable. Instead we should just return the SDDL string. If a user
139         # needs to parse this then they really need to reprocess the SDDL string and process their results on another
140         # win_shell task.
141         $InputObject.GetSecurityDescriptorSddlForm(([System.Security.AccessControl.AccessControlSections]::All))
142     } else {
143         # Syntax: (All Others) - The default serialization handling of other syntaxes are fine, don't do anything.
144         $InputObject
145     }
146 }
147 
148 <#
149 Calling Get-ADObject that returns multiple objects with -Properties * will only return the properties that were set on
150 the first found object. To counter this problem we will first call Get-ADObject to list all the objects that match the
specified()151 filter specified then get the properties on each object.
152 #>
153 
154 $commonParams = @{
155     IncludeDeletedObjects = $includeDeleted
156 }
157 
158 if ($credential) {
159     $commonParams.Credential = $credential
160 }
161 
162 if ($domainServer) {
163     $commonParams.Server = $domainServer
164 }
165 
166 # First get the IDs for all the AD objects that match the filter specified.
167 $getParams = @{
168     Properties = @('DistinguishedName', 'ObjectGUID')
169 }
170 
171 if ($filter) {
172     $getParams.Filter = $filter
173 } elseif ($identity) {
174     $getParams.Identity = $identity
175 } elseif ($ldapFilter) {
176     $getParams.LDAPFilter = $ldapFilter
177 }
178 
179 # Explicit check on $null as an empty string is different from not being set.
180 if ($null -ne $searchBase) {
181     $getParams.SearchBase = $searchbase
182 }
183 
184 if ($searchScope) {
185     $getParams.SearchScope = switch($searchScope) {
186         base { 'Base' }
187         one_level { 'OneLevel' }
188         subtree { 'Subtree' }
189     }
190 }
191 
192 try {
193     # We run this in a custom PowerShell pipeline so that users of this module can't use any of the variables defined
194     # above in their filter. While the cmdlet won't execute sub expressions we don't want anyone implicitly relying on
195     # a defined variable in this module in case we ever change the name or remove it.
196     $ps = [PowerShell]::Create()
197     $null = $ps.AddCommand('Get-ADObject').AddParameters($commonParams).AddParameters($getParams)
198     $null = $ps.AddCommand('Select-Object').AddParameter('Property', @('DistinguishedName', 'ObjectGUID'))
199 
200     $foundGuids = @($ps.Invoke())
201 } catch {
202     # Because we ran in a pipeline we can't catch ADIdentityNotFoundException. Instead just get the base exception and
203     # do the error checking on that.
204     if ($_.Exception.GetBaseException() -is [Microsoft.ActiveDirectory.Management.ADIdentityNotFoundException]) {
205         $foundGuids = @()
206     } else {
207         # The exception is from the .Invoke() call, compare on the InnerException which was what was actually raised by
208         # the pipeline.
209         $innerException = $_.Exception.InnerException.InnerException
210         if ($innerException -is [Microsoft.ActiveDirectory.Management.ADServerDownException]) {
211             # Point users in the direction of the double hop problem as that is what is typically the cause of this.
212             $msg = "Failed to contact the AD server, this could be caused by the double hop problem over WinRM. "
213             $msg += "Try using the module with auth as Kerberos with credential delegation or CredSSP, become, or "
214             $msg += "defining the domain_username and domain_password module parameters."
215             $module.FailJson($msg, $innerException)
216         } else {
217             throw $innerException
218         }
219     }
220 }
221 
222 $getParams = @{}
223 if ($properties) {
224     $getParams.Properties = $properties
225 }
226 $module.Result.objects = @(foreach ($adId in $foundGuids) {
227     try {
228         $adObject = Get-ADObject @commonParams @getParams -Identity $adId.ObjectGUID
229     } catch {
230         $msg = "Failed to retrieve properties for AD Object '$($adId.DistinguishedName)': $($_.Exception.Message)"
231         $module.Warn($msg)
232         continue
233     }
234 
235     $propertyNames = $adObject.PropertyNames
236     $propertyNames += ($properties | Where-Object { $_ -ne '*' })
237 
238     # Now process each property to an easy to represent string
239     $filteredObject = [Ordered]@{}
240     foreach ($name in ($propertyNames | Sort-Object)) {
241         # In the case of explicit properties that were asked for but weren't set, Get-ADObject won't actually return
242         # the property so this is a defensive check against that scenario.
243         if (-not $adObject.PSObject.Properties.Name.Contains($name)) {
244             $filteredObject.$name = $null
245             continue
246         }
247 
248         $value = $adObject.$name
249         if ($value -is [Microsoft.ActiveDirectory.Management.ADPropertyValueCollection]) {
250             $value = foreach ($v in $value) {
251                 ConvertTo-OutputValue -InputObject $v
252             }
253         } else {
254             $value = ConvertTo-OutputValue -InputObject $value
255         }
256         $filteredObject.$name = $value
257 
258         # For these 2 properties, add an _AnsibleFlags attribute which contains the enum strings that are set.
259         if ($name -eq 'sAMAccountType') {
260             $enumValue = [Ansible.WinDomainObjectInfo.sAMAccountType]$value
261             $filteredObject.'sAMAccountType_AnsibleFlags' = $enumValue.ToString() -split ', '
262         } elseif ($name -eq 'userAccountControl') {
263             $enumValue = [Ansible.WinDomainObjectInfo.UserAccountControl]$value
264             $filteredObject.'userAccountControl_AnsibleFlags' = $enumValue.ToString() -split ', '
265         }
266     }
267 
268     $filteredObject
269 })
270 
271 $module.ExitJson()
272