diff --git a/.gitignore b/.gitignore index f77a1cf..cf70d5a 100644 --- a/.gitignore +++ b/.gitignore @@ -393,4 +393,5 @@ changelogs/.plugin-cache.yaml tests/integration/inventory* tests/integration/targets/domain_controller/.vagrant tests/integration/targets/membership/.vagrant -tests/output/ \ No newline at end of file +tests/output/ +.vagrant/ \ No newline at end of file diff --git a/changelogs/fragments/lookup-dn.yml b/changelogs/fragments/lookup-dn.yml new file mode 100644 index 0000000..f37eead --- /dev/null +++ b/changelogs/fragments/lookup-dn.yml @@ -0,0 +1,19 @@ +minor_changes: + - >- + microsoft.ad AD modules - Added ``domain_credentials`` as a common module option that can be used to specify + credentials for specific AD servers. + - >- + microsoft.ad AD modules - Added ``lookup_failure_action`` on all modules that can specify a list of + distinguishedName values to control what should happen if the lookup fails. + - >- + microsoft.ad.computer - Added the ability to lookup a distinguishedName on a specific domain server for + ``delegates`` and ``managed_by``. + - >- + microsoft.ad.group - Added the ability to lookup a distinguishedName on a specific domain server for + ``managed_by`` and ``members``. + - >- + microsoft.ad.ou - Added the ability to lookup a distinguishedName on a specific domain server for + ``managed_by``. + - >- + microsoft.ad.user - Added the ability to lookup a distinguishedName on a specific domain server for + ``delegates``. diff --git a/docs/docsite/extra-docs.yml b/docs/docsite/extra-docs.yml index 6a548ed..a3b4f8e 100644 --- a/docs/docsite/extra-docs.yml +++ b/docs/docsite/extra-docs.yml @@ -6,6 +6,7 @@ sections: - title: Scenario Guides toctree: + - guide_ad_module_authentication - guide_attributes - guide_ldap_connection - guide_ldap_inventory diff --git a/docs/docsite/rst/guide_ad_module_authentication.rst b/docs/docsite/rst/guide_ad_module_authentication.rst new file mode 100644 index 0000000..632c195 --- /dev/null +++ b/docs/docsite/rst/guide_ad_module_authentication.rst @@ -0,0 +1,120 @@ +.. _ansible_collections.microsoft.ad.docsite.guide_ad_module_authentication: + +**************************** +AD Authentication in Modules +**************************** + +A key requirement of the modules used inside this collection is being able to authenticate a user to the domain controller when managing a resource. This guide will cover the different options available for this scenario. + +.. note:: + This guide covers authentication to a domain controller when using a module on a Windows host. See :ref:`LDAP Authentication ` for information on how authentication is done when using plugins running on Linux. + +.. contents:: + :local: + :depth: 1 + +.. _ansible_collections.microsoft.ad.docsite.guide_ad_module_authentication.implicit_auth: + +Implicit Authentication +======================= + +The first and simplest option is to use the connection user's existing credentials during authentication. This avoids having to specify a username and password in the module's parameters, but it does require that the connection method used by Ansible supports credential delegation. For example using CredSSP authentication with the ``winrm`` and ``psrp`` connection plugin, or using Kerberos delegation. Other authentication options, like NTLM, do not support credential delegation and will not work with implicit authentication. + +The only way to test out if implicit authentication is available is to run the module and see if it works. If it does not work then the error will most likely contain the message ``Failed to contact the AD server``. + +.. _ansible_collections.microsoft.ad.docsite.guide_ad_module_authentication.become: + +Become +====== + +If implicit authentication is not available, the module can be run with ``become`` that specifies the username and password to use for authentication. + +.. code-block:: yaml + + - name: Use become with connection credentials + microsoft.ad.user: + name: MyUser + state: present + become: true + become_method: runas + become_flags: logon_type=new_credentials logon_flags=netcredentials_only + vars: + ansible_become_user: '{{ ansible_user }}' + ansible_become_pass: '{{ ansible_password }}' + +The ``runas`` method is used on Windows and the ``become_flags`` will specify that the credentials should be used for network authentication only. The ``ansible_become_user`` and ``ansible_become_pass`` variables specify the username and password to use for authentication. It is important that both of these variables are set to a valid username and password or else the authentication will fail. + +It is also possible to use the ``SYSTEM`` account for become. This will have the module use the AD computer account for that host when authenticating with the target DC rather than an explicit username and password. The AD computer account must still have the required rights to perform the operation requested. + +.. code-block:: yaml + + - name: Use machine account for authentication + microsoft.ad.user: + name: MyUser + state: present + become: true + become_method: runas + become_user: SYSTEM + +.. _ansible_collections.microsoft.ad.docsite.guide_ad_module_authentication.explicit_creds: + +Explicit Credentials +==================== + +The final option is to specify the username and password as module options. This can be done in two ways; with the ``domain_username`` and ``domain_password`` options, or with the ``domain_credentials`` option. An example of both methods is shown below. + +.. code-block:: yaml + + - name: Use domain_username and domain_password + microsoft.ad.user: + name: MyUser + state: present + domain_username: '{{ ansible_user }}' + domain_password: '{{ ansible_password }}' + + - name: Use domain_credentials + name: MyUser + state: present + domain_credentials: + - username: '{{ ansible_user }}' + password: '{{ ansible_password }}' + +.. note:: + The ``domain_credentials`` option was added in version 1.6.0 of this collection. + +The ``domain_credentials`` option without the ``name`` key, like in the above example, will be the credentials used for authentication with the default domain controller just like ``domain_username`` and ``domain_password``. Using both options together is not supported and will result in an error. + +The ``domain_credentials`` option can also be used to specify server specific credentials. For example when attempting to lookup the identity of an AD object: + +.. code-block:: yaml + + - name: Set member with lookup on different server + microsoft.ad.group: + name: MyGroup + state: present + members: + add: + - GroupOnDefaultDC + - name: GroupOnDefaultDC2 + - name: GroupOnOtherDC + server: OtherDC + - name: GroupOnThirdDC + server: ThirdDC + domain_credentials: + - username: UserForDefaultDC + password: PasswordForDefaultDC + - name: OtherDC + username: UserForOtherDC + password: PasswordForOtherDC + +In the case above there are three members being added to the group: + +* ``GroupOnDefaultDC`` - Will be looked up on the default domain controller using ``UserForDefaultDC`` and ``PasswordForDefaultDC`` +* ``GroupOnDefaultDC2`` - Same as the above just specified as a dictionary +* ``GroupOnOtherDC`` - Will be looked up on ``OtherDC`` using ``UserForOtherDC`` and ``PasswordForOtherDC`` +* ``GroupOnThirdDC`` - Will be looked up on ``ThirdDC`` using the implicit user authentication context + +The value for ``server`` must correspond to a ``name`` entry in ``domain_credentials``. If the server is not specified in ``domain_credentials``, the module will default to using the ``domain_username/domain_password`` or implicit user authentication. + +.. note:: + The default (no ``name`` key) entry in ``domain_credentials`` is only used for lookups without an explicit server set. The ``domain_username`` and ``domain_password`` credential will be used for all connections unless there is an explicit server entry in ``domain_credentials``. diff --git a/docs/docsite/rst/guide_attributes.rst b/docs/docsite/rst/guide_attributes.rst index ee53dce..7ed192c 100644 --- a/docs/docsite/rst/guide_attributes.rst +++ b/docs/docsite/rst/guide_attributes.rst @@ -310,3 +310,77 @@ SDDL strings can be quite complex so building them manually is ill-advised. It i $dn = 'CN=ObjectName,DC=domain,DC=test' $obj = Get-ADObject -Identity $dn -Properties nTSecurityDescriptor $obj.nTSecurityDescriptor.GetSecurityDescriptorSddlForm('All') + +.. _ansible_collections.microsoft.ad.docsite.guide_attributes.dn_lookup_attributes: + +DN Lookup Attributes +==================== + +Some attributes in Active Directory are stored as a Distinguished Name (``DN``) value that references another AD object. Some modules expose a way to lookup the DN using a more human friendly value, such as ``managed_by``. These option values must either be a string or a dictionary with the key ``name`` and optional key ``server``. The string value or the value of ``name`` is the identity to lookup while ``server`` is the domain server to lookup the identity on. The lookup identity value can be specified as a ``distinguishedName``, ``objectGUID``, ``objectSid``, ``sAMAccountName``, or ``userPrincipalName``. The below is an example of how to lookup a DN using the ``sAMAccountName`` using a string value or in the dictionary form: + +.. code-block:: yaml + + - name: Find managed_by using string value + microsoft.ad.group: + name: My Group + scope: global + managed_by: Domain Admins + + - name: Find managed_by using dictionary value with a server + microsoft.ad.group: + name: My Group + scope: global + managed_by: + name: Domain Admins + server: OtherDC + +There are also module options that can set a list of DN values for an attribute. The list values for these options are the same as the single value attributes where each DN lookup is set as a string or a dictionary with the ``name`` and optional ``server`` key. + +.. code-block:: yaml + + - name: Specify a list of DNs to set + microsoft.ad.computer: + identity: TheComputer + delegates: + set: + - FileShare + - name: ServerA + server: OtherDC + +For list attributes with the ``add/remove/set`` subkey options, the ``lookup_failure_action`` option can also be set to ``fail`` (default), ``ignore``, or ``warn``. The ``fail`` option will fail the task if any of the lookups fail, ``ignore`` will ignore any invalid lookups, and ``warn`` will emit a warning but still continue on a lookup failure. + +.. code-block:: yaml + + - name: Specify a list of DNs to set - ignoring lookup failures + microsoft.ad.computer: + identity: TheComputer + delegates: + lookup_failure_action: ignore + set: + - FileShare + - MissingUser + +When a ``server`` key is provided, the lookup will be done using the server value specified. It is possible to also provide explicit credentials just for that server using the ``domain_credentials`` option. + +.. code-block:: yaml + + - name: Set member with lookup on different server + microsoft.ad.group: + name: MyGroup + state: present + members: + add: + - GroupOnDefaultDC + - name: GroupOnDefaultDC2 + - name: GroupOnOtherDC + server: OtherDC + domain_credentials: + - username: UserForDefaultDC + password: PasswordForDefaultDC + - name: OtherDC + username: UserForOtherDC + password: PasswordForOtherDC + +In the above, the ``GroupOnOtherDC`` will be done with ``OtherDC`` with the username ``UserForOtherDC``. + +The documentation for the module option will identify if the option supports the lookup behaviour or whether a DN value must be explicitly provided. diff --git a/docs/docsite/rst/guide_ldap_connection.rst b/docs/docsite/rst/guide_ldap_connection.rst index 60755f0..ed0b290 100644 --- a/docs/docsite/rst/guide_ldap_connection.rst +++ b/docs/docsite/rst/guide_ldap_connection.rst @@ -7,7 +7,7 @@ LDAP Connection guide This guide covers information about communicating with an LDAP server, like Microsoft Active Directory, from the Ansible host. Unlike Windows hosts, there are no builtin mechanisms to communicate and authenticate with an LDAP server, so the plugins that run on the Ansible host require some extra configuration to get working. .. note:: - This guide covers LDAP communication from the Ansible host. This does not apply to the modules that run on the remote Windows hosts. + This guide covers LDAP communication from the Ansible host. This does not apply to the modules that run on the remote Windows hosts. See :ref:`AD Authentication in Modules ` for information on how modules authentication can be configured. .. contents:: :local: diff --git a/docs/docsite/rst/guide_migration.rst b/docs/docsite/rst/guide_migration.rst index c0b01ca..d3e3c2d 100644 --- a/docs/docsite/rst/guide_migration.rst +++ b/docs/docsite/rst/guide_migration.rst @@ -129,6 +129,30 @@ Migrated to :ref:`microsoft.ad.group ` for more information. + .. _ansible_collections.microsoft.ad.docsite.guide_migration.migrated_modules.win_domain_object_info: Module ``win_domain_object_info`` diff --git a/plugins/doc_fragments/ad_object.py b/plugins/doc_fragments/ad_object.py index 3231e23..5042e12 100644 --- a/plugins/doc_fragments/ad_object.py +++ b/plugins/doc_fragments/ad_object.py @@ -76,9 +76,48 @@ class ModuleDocFragment: - The display name of the AD object to set. - This is the value of the C(displayName) LDAP attribute. type: str + domain_credentials: + description: + - Specifies the credentials that should be used when using the server + specified by I(name). + - To specify credentials for the default domain server, use an entry + without the I(name) key or use the I(domain_username) and + I(domain_password) option. + - This can be set under the R(play's module defaults,module_defaults_groups) + under the C(group/microsoft.ad.domain) group. + - See R(AD authentication in modules,ansible_collections.microsoft.ad.docsite.guide_ad_module_authentication) + for more information. + default: [] + type: list + elements: dict + suboptions: + name: + description: + - The name of the server these credentials are for. + - This value should correspond to the value used in other options that + specify a custom server to use, for example an option that references + an AD identity located on a different AD server. + - This key can be omitted in one entry to specify the default + credentials to use when a server is not specified instead of using + I(domain_username) and I(domain_password). + type: str + username: + description: + - The username to use when connecting to the server specified by + I(name). + type: str + required: true + password: + description: + - The password to use when connecting to the server specified by + I(name). + type: str + required: true domain_password: description: - The password for I(domain_username). + - The I(domain_credentials) sub entry without a I(name) key can also be + used to specify the credentials for the default domain authentication. - This can be set under the R(play's module defaults,module_defaults_groups) under the C(group/microsoft.ad.domain) group. type: str @@ -87,6 +126,9 @@ class ModuleDocFragment: - 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. + - Custom credentials can be specified under a I(domain_credentials) entry + without a I(name) key or through I(domain_username) and + I(domain_password). - This can be set under the R(play's module defaults,module_defaults_groups) under the C(group/microsoft.ad.domain) group. type: str @@ -96,6 +138,8 @@ class ModuleDocFragment: - 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. + - The I(domain_credentials) sub entry without a I(name) key can also be + used to specify the credentials for the default domain authentication. - This can be set under the R(play's module defaults,module_defaults_groups) under the C(group/microsoft.ad.domain) group. type: str diff --git a/plugins/module_utils/_ADObject.psm1 b/plugins/module_utils/_ADObject.psm1 index e51c974..70868c3 100644 --- a/plugins/module_utils/_ADObject.psm1 +++ b/plugins/module_utils/_ADObject.psm1 @@ -486,6 +486,7 @@ Function Compare-AnsibleADIdempotentList { } [PSCustomObject]@{ + # $null is explicit here as the AD modules use it to unset a value Value = if ($value.Count) { $value.ToArray() } else { $null } # Also returned if the API doesn't support explicitly setting 1 value ToAdd = $toAdd.ToArray() @@ -494,6 +495,160 @@ Function Compare-AnsibleADIdempotentList { } } +Function ConvertTo-AnsibleADDistinguishedName { + <# + .SYNOPSIS + Converts the input list into DistinguishedNames for later comparison. + + .PARAMETER InputObject + The identity parameter, this can either be a string or a hashtable. + If a hashtable it should contain the name and optional server key to + identity the object to search and set a specific server to search on. + + .PARAMETER Module + The AnsibleModule object associated with the current module execution. + + .PARAMETER Context + The context behind this conversion to add to the error message if there + is one. + + .PARAMETER Server + The default server to search. The Identity server key will override this + value is present. + + .PARAMETER Credential + The credential to search with. This is ignored if the Identity server key + is present. + + .PARAMETER FailureAction + The action to take if the lookup fails. Fail will cause the module to + exit with an error, ignore will ignore the error, and warn will emit a + warning on failure. + #> + [OutputType([string])] + [CmdletBinding()] + param ( + [Parameter(Mandatory, ValueFromPipeline)] + [object[]] + $InputObject, + + [Parameter(Mandatory)] + [object] + $Module, + + [Parameter(Mandatory)] + [string] + $Context, + + [string] + $Server, + + [PSCredential] + $Credential, + + [ValidateSet('Fail', 'Ignore', 'Warn')] + [string] + $FailureAction = 'Fail' + ) + + begin { + $allowedKeys = [string[]]@('name', 'server') + $results = [System.Collections.Generic.List[string]]@() + $getErrors = [System.Collections.Generic.List[string]]@() + $invalidIdentities = [System.Collections.Generic.List[string]]@() + } + + process { + foreach ($obj in $InputObject) { + $getParams = @{} + if ($Server) { + $getParams.Server = $Server + } + if ($Credential) { + $getParams.Credential = $Credential + } + + if ($obj -is [System.Collections.IDictionary]) { + # When using a hashtable, the name and server key can be used + # to specify the identity and server to use. If no server is + # set then it defaults to the default server (if provided) and + # it's credentials. + $existingKeys = [string[]]$obj.Keys + + if ('name' -notin $existingKeys) { + $getErrors.Add("Identity entry does not contain the required name key") + continue + } + $name = [string]$obj.name + + [string[]]$extraKeys = [System.Linq.Enumerable]::Except($existingKeys, $allowedKeys) + if ($extraKeys) { + $extraKeys = $extraKeys | Sort-Object + $getErrors.Add("Identity entry for '$name' contains extra keys: '$($extraKeys -join "', '")'") + continue + } + $getParams.Identity = $name + + if ($obj.server) { + # If a custom server is specified we use that and the + # credential (if any) associated with that server. + $getParams.Server = $obj.server + + if ($Module.ServerCredentials.ContainsKey($obj.server)) { + $getParams.Credential = $Module.ServerCredentials[$obj.server] + } + elseif (-not $Module.DefaultCredentialSet) { + $null = $getParams.Remove('Credential') + } + } + } + else { + # Treat the value as just the identity as a string. + $getParams.Identity = [string]$obj + } + + if (-not $getParams.Identity) { + continue + } + + $adDN = Get-AnsibleADObject @getParams | + Select-Object -ExpandProperty DistinguishedName + if ($adDN) { + $results.Add($adDN) + } + else { + $invalidIdentities.Add($getParams.Identity) + } + } + } + + end { + # This is a weird workaround as FailJson calls exit which means the + # caller won't capture the output causing junk data in the output. By + # only outputting the results if no errors occurred we can avoid that + # problem. + $errorPrefix = "Failed to find the AD object DNs for $Context" + if ($getErrors) { + $msg = "$errorPrefix. $($getErrors -join '. ')." + $Module.FailJson($msg) + } + + if ($invalidIdentities) { + if ($FailureAction -ne 'Ignore') { + $identityString = "'$($invalidIdentities -join "', '")'" + if ($FailureAction -eq 'Fail') { + $Module.FailJson("$errorPrefix. Invalid identities: $identityString") + } + else { + $module.Warn("$errorPrefix. Ignoring invalid identities: $identityString") + } + } + } + + $results + } +} + Function Get-AnsibleADObject { <# .SYNOPSIS @@ -612,9 +767,17 @@ Function Invoke-AnsibleADObject { Attribute - The ldap attribute name to compare against CaseInsensitive - The values are case insensitive (defaults to $false) StateRequired - Set to 'present' or 'absent' if this needs to be defined for either state + DNLookup - Whether each value needs to be looked up to get the DN + IsRawAttribute - Whether the attribute is a raw LDAP attribute name and not a parameter name New - Called when the option is to be set on the New-AD* cmdlet splat Set - Called when the option is to be set on the Set-AD* cmdlet splat + The 'type' key in 'Option' should be a valid Ansible.Basic type or + 'add_remove_set'. When 'add_remove_set' is used the option type becomes + dict with the options subentry for add/remove/set being the Option value + specified. This can be combined with DNLookup to set the value as raw that + can lookup the DN value from the string or dict specified. + If Attribute is set then requested value will be compared with the attribute specified. The current attribute value is added to the before diff state for the option it is on. If New is not specified then the @@ -632,6 +795,10 @@ Function Invoke-AnsibleADObject { It is up to the scriptblock to set the required splat parameters or call whatever function is needed. + The DNLookup key is used to indicate that the add/remove/set values can + either be a string or a dictionary containing the name/server to specify + the name and server to lookup the object DN value. + Both New and Set must set the $Module.Diff.after results accordingly and/or mark $Module.Result.changed if it is making a change outside of adjusting the splat hashtable passed in. @@ -709,6 +876,25 @@ Function Invoke-AnsibleADObject { } } } + domain_credentials = @{ + default = @() + type = 'list' + elements = 'dict' + options = @{ + name = @{ + type = 'str' + } + username = @{ + required = $true + type = 'str' + } + password = @{ + no_log = $true + required = $true + type = 'str' + } + } + } domain_password = @{ no_log = $true type = 'str' @@ -775,7 +961,44 @@ Function Invoke-AnsibleADObject { $stateRequiredIf[$propInfo.StateRequired] += $ansibleOption } - $spec.options[$ansibleOption] = $propInfo.Option + $option = $propInfo.Option + if ($option.type -eq 'add_remove_set') { + $option.type = 'dict' + + $optionElement = $option.Clone() + $optionElement.type = 'list' + + $option = @{ + type = 'dict' + options = @{} + } + + if ($propInfo.DNLookup) { + $optionElement.elements = 'raw' + $option.options.lookup_failure_action = @{ + choices = @('fail', 'ignore', 'warn') + default = 'fail' + type = 'str' + } + } + elseif (-not $optionElement.ContainsKey('elements')) { + $optionElement.elements = 'str' + } + + if ($optionElement.ContainsKey('aliases')) { + $option.aliases = $optionElement.aliases + $null = $optionElement.Remove('aliases') + } + + $option.options.add = $optionElement + $option.options.remove = $optionElement + $option.options.set = $optionElement + } + elseif ($propInfo.DNLookup) { + $option.type = 'raw' + } + + $spec.options[$ansibleOption] = $option if ($propInfo.Attribute) { $propInfo.Attribute @@ -798,15 +1021,39 @@ Function Invoke-AnsibleADObject { $module.Result.object_guid = $null $adParams = @{} + $serverCredentials = @{} + foreach ($domainCred in $module.Params.domain_credentials) { + $cred = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList @( + $domainCred.username, + (ConvertTo-SecureString -AsPlainText -Force -String $domainCred.password) + ) + + if ($domainCred.name) { + $serverCredentials[$domainCred.name] = $cred + } + elseif ($adParams.Credential) { + $module.FailJson("Cannot specify default domain_credentials with domain_username and domain_password") + } + else { + $adParams.Credential = $cred + } + } + $module | Add-Member -MemberType NoteProperty -Name ServerCredentials -Value $serverCredentials + if ($module.Params.domain_server) { $adParams.Server = $module.Params.domain_server } if ($module.Params.domain_username) { + if ($adParams.Credential) { + $msg = "Cannot specify domain_username/domain_password and domain_credentials with an entry that has no name." + $module.FailJson($msg) + } $adParams.Credential = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList @( $module.Params.domain_username, (ConvertTo-SecureString -AsPlainText -Force -String $module.Params.domain_password) ) + $module | Add-Member -MemberType NoteProperty -Name DefaultCredentialSet -Value $true } $defaultObjectPath = & $DefaultPath $module $adParams @@ -922,8 +1169,7 @@ Function Invoke-AnsibleADObject { $objectPath = $null if ($module.Params.path -and $module.Params.path -ne $defaultPathSentinel) { - $objectPath = $path - $newParams.Path = $module.Params.path + $newParams.Path = $objectPath = $module.Params.path } else { $objectPath = $defaultObjectPath @@ -953,11 +1199,45 @@ Function Invoke-AnsibleADObject { $null = & $propInfo.New $module $adParams $newParams } elseif ($propInfo.Attribute) { - if ($propValue -is [System.Collections.IDictionary]) { - $propValue = @($propValue['add']; $propValue['set']) | Select-Object -Unique + # If a dictionary (add/set/remove) and is not a DNLookup single value + if ($propValue -is [System.Collections.IDictionary] -and $propInfo.Option.type -ne 'raw') { + $propValue = if ($propInfo.DNLookup) { + foreach ($actionKvp in $propValue.GetEnumerator()) { + if ($null -eq $actionKvp.Value -or $actionKvp.Key -in @('lookup_failure_action', 'remove')) { + continue + } + + $convertParams = @{ + Module = $module + Context = "$($propInfo.Name).$($actionKvp.Key)" + FailureAction = $propValue.lookup_failure_action + } + $actionKvp.Value | ConvertTo-AnsibleADDistinguishedName @adParams @convertParams + } + } + else { + $propValue['add'] + $propValue['set'] + } + + $propValue = $propValue | Select-Object -Unique + } + elseif ($propInfo.DNLookup) { + $propValue = $propValue | ConvertTo-AnsibleADDistinguishedName @adParams -Module $module -Context $propInfo.Name } - $newParams[$propInfo.Attribute] = $propValue + if ($propInfo.IsRawAttribute) { + if (-not $newParams.ContainsKey('OtherAttributes')) { + $newParams.OtherAttributes = @{} + } + + # The AD cmdlets don't like explicitly casted arrays, use + # ForEach-Object to get back a vanilla object[] to set. + $newParams.OtherAttributes[$propInfo.Attribute] = $propValue | ForEach-Object { "$_" } + } + else { + $newParams[$propInfo.Attribute] = $propValue + } if ($propInfo.Option.no_log) { $propValue = 'VALUE_SPECIFIED_IN_NO_LOG_PARAMETER' @@ -1043,17 +1323,36 @@ Function Invoke-AnsibleADObject { $compareParams = @{ Existing = $actualValue - CaseInsensitive = $propInfo.CaseInsensitive + CaseInsensitive = $propInfo.DNLookup -or $propInfo.CaseInsensitive } - if ($propValue -is [System.Collections.IDictionary]) { - $compareParams.Add = $propValue['add'] - $compareParams.Remove = $propValue['remove'] - $compareParams.Set = $propValue['set'] + # If a dictionary (add/set/remove) and is not a DNLookup single value + if ($propValue -is [System.Collections.IDictionary] -and $propInfo.Option.type -ne 'raw') { + if ($propInfo.DNLookup) { + foreach ($actionKvp in $propValue.GetEnumerator()) { + if ($null -eq $actionKvp.Value -or $actionKvp.Key -eq 'lookup_failure_action') { continue } + + $convertParams = @{ + Module = $module + Context = "$($propInfo.Name).$($actionKvp.Key)" + FailureAction = $propValue.lookup_failure_action + } + $dns = $actionKvp.Value | ConvertTo-AnsibleADDistinguishedName @adParams @convertParams + $compareParams[$actionKvp.Key] = @($dns) + } + } + else { + $compareParams.Add = $propValue['add'] + $compareParams.Remove = $propValue['remove'] + $compareParams.Set = $propValue['set'] + } } elseif ([string]::IsNullOrWhiteSpace($propValue)) { $compareParams.Set = @() } + elseif ($propInfo.DNLookup) { + $compareParams.Set = @($propValue | ConvertTo-AnsibleADDistinguishedName @adParams -Module $module -Context $propInfo.Name) + } else { $compareParams.Set = @($propValue) } @@ -1061,7 +1360,23 @@ Function Invoke-AnsibleADObject { $res = Compare-AnsibleADIdempotentList @compareParams $newValue = $res.Value if ($res.Changed) { - $setParams[$propInfo.Attribute] = $newValue + if ($propInfo.IsRawAttribute) { + if ($newValue) { + if (-not $setParams.ContainsKey('Replace')) { + $setParams['Replace'] = @{} + } + $setParams['Replace'][$propInfo.Attribute] = $newValue + } + else { + if (-not $setParams.ContainsKey('Clear')) { + $setParams['Clear'] = [System.Collections.Generic.List[string]]@() + } + $setParams['Clear'].Add($propInfo.Attribute) + } + } + else { + $setParams[$propInfo.Attribute] = $newValue + } } $noLog = $propInfo.Option.no_log @@ -1169,6 +1484,7 @@ Function Invoke-AnsibleADObject { $exportMembers = @{ Function = @( "Compare-AnsibleADIdempotentList" + "ConvertTo-AnsibleADDistinguishedName" "Get-AnsibleADObject" "Invoke-AnsibleADObject" ) diff --git a/plugins/modules/computer.ps1 b/plugins/modules/computer.ps1 index b97bb10..9010c10 100644 --- a/plugins/modules/computer.ps1 +++ b/plugins/modules/computer.ps1 @@ -12,15 +12,10 @@ $setParams = @{ Name = 'delegates' Option = @{ aliases = 'principals_allowed_to_delegate' - type = 'dict' - options = @{ - add = @{ type = 'list'; elements = 'str' } - remove = @{ type = 'list'; elements = 'str' } - set = @{ type = 'list'; elements = 'str' } - } + type = 'add_remove_set' } Attribute = 'PrincipalsAllowedToDelegateToAccount' - CaseInsensitive = $true + DNLookup = $true } [PSCustomObject]@{ Name = 'dns_hostname' @@ -35,24 +30,8 @@ $setParams = @{ [PSCustomObject]@{ Name = 'kerberos_encryption_types' Option = @{ - type = 'dict' - options = @{ - add = @{ - choices = 'aes128', 'aes256', 'des', 'rc4' - type = 'list' - elements = 'str' - } - remove = @{ - choices = 'aes128', 'aes256', 'des', 'rc4' - type = 'list' - elements = 'str' - } - set = @{ - choices = 'aes128', 'aes256', 'des', 'rc4' - type = 'list' - elements = 'str' - } - } + type = 'add_remove_set' + choices = 'aes128', 'aes256', 'des', 'rc4' } Attribute = 'KerberosEncryptionType' CaseInsensitive = $true @@ -107,8 +86,9 @@ $setParams = @{ } [PSCustomObject]@{ Name = 'managed_by' - Option = @{ type = 'str' } + Option = @{ type = 'raw' } Attribute = 'ManagedBy' + DNLookup = $true } [PSCustomObject]@{ Name = 'sam_account_name' @@ -119,45 +99,11 @@ $setParams = @{ Name = 'spn' Option = @{ aliases = 'spns' - type = 'dict' - options = @{ - add = @{ type = 'list'; elements = 'str' } - remove = @{ type = 'list'; elements = 'str' } - set = @{ type = 'list'; elements = 'str' } - } - } - Attribute = 'ServicePrincipalNames' - New = { - param($Module, $ADParams, $NewParams) - - $spns = @( - $Module.Params.spn.add - $Module.Params.spn.set - ) | Select-Object -Unique - - $NewParams.ServicePrincipalNames = $spns - $Module.Diff.after.spn = $spns - } - Set = { - param($Module, $ADParams, $SetParams, $ADObject) - - $desired = $Module.Params.spn - $compareParams = @{ - Existing = $ADObject.ServicePrincipalNames - CaseInsensitive = $true - } - $res = Compare-AnsibleADIdempotentList @compareParams @desired - if ($res.Changed) { - $SetParams.ServicePrincipalNames = @{} - if ($res.ToAdd) { - $SetParams.ServicePrincipalNames.Add = $res.ToAdd - } - if ($res.ToRemove) { - $SetParams.ServicePrincipalNames.Remove = $res.ToRemove - } - } - $module.Diff.after.kerberos_encryption_types = @($res.Value | Sort-Object) + type = 'add_remove_set' } + Attribute = 'servicePrincipalName' + CaseInsensitive = $true + IsRawAttribute = $true } [PSCustomObject]@{ Name = 'trusted_for_delegation' diff --git a/plugins/modules/computer.py b/plugins/modules/computer.py index 30fea01..cf16025 100644 --- a/plugins/modules/computer.py +++ b/plugins/modules/computer.py @@ -15,14 +15,19 @@ description: - The principal objects that the current AD object can trust for delegation to either add, remove or set. - - The values for each sub option must be specified as a distinguished name - C(CN=shenetworks,CN=Users,DC=ansible,DC=test) + - Each subkey value is a list of values in the form of a + C(distinguishedName), C(objectGUID), C(objectSid), C(sAMAccountName), + or C(userPrincipalName) string or a dictionary with the I(name) and + optional I(server) key. - This is the value set on the C(msDS-AllowedToActOnBehalfOfOtherIdentity) LDAP attribute. - This is a highly sensitive attribute as it allows the principals specified to impersonate any account when authenticating with the AD computer object being managed. - To clear all principals, use I(set) with an empty list. + - See + R(DN Lookup Attributes,ansible_collections.microsoft.ad.docsite.guide_attributes.dn_lookup_attributes) + for more information on how DN lookups work. - See R(Setting list option values,ansible_collections.microsoft.ad.docsite.guide_list_values) for more information on how to add/remove/set list options. aliases: @@ -31,29 +36,35 @@ suboptions: add: description: - - The AD objects by their C(DistinguishedName) to add as a principal - allowed to delegate. + - Adds the principals specified as principals allowed to delegate to. - Any existing principals not specified by I(add) will be untouched unless specified by I(remove) or not in I(set). type: list - elements: str + elements: raw + lookup_failure_action: + description: + - Control the action to take when the lookup fails to find the DN. + - C(fail) will cause the task to fail. + - C(ignore) will ignore the value and continue. + - C(warn) will ignore the value and display a warning. + choices: ['fail', 'ignore', 'warn'] + default: fail + type: str remove: description: - - The AD objects by their C(DistinguishedName) to remove as a principal - allowed to delegate. + - Removes the principals specified as principals allowed to delegate to. - Any existing pricipals not specified by I(remove) will be untouched unless I(set) is defined. type: list - elements: str + elements: raw set: description: - - The AD objects by their C(DistinguishedName) to set as the only - principals allowed to delegate. + - Sets the principals specified as principals allowed to delegate to. - This will remove any existing principals if not specified in this list. - Specify an empty list to remove all principals allowed to delegate. type: list - elements: str + elements: raw dns_hostname: description: - Specifies the fully qualified domain name (FQDN) of the computer. @@ -124,9 +135,13 @@ description: - The user or group that manages the object. - The value can be in the form of a C(distinguishedName), C(objectGUID), - C(objectSid), or sAMAccountName). + C(objectSid), C(sAMAccountName), or C(userPrincipalName) string or a + dictionary with the I(name) and optional I(server) key. - This is the value set on the C(managedBy) LDAP attribute. - type: str + - See + R(DN Lookup Attributes,ansible_collections.microsoft.ad.docsite.guide_attributes.dn_lookup_attributes) + for more information on how DN lookups work. + type: raw sam_account_name: description: - The C(sAMAccountName) value to set for the group. @@ -252,7 +267,7 @@ delegates: set: - CN=FileShare,OU=Computers,DC=domain,DC=test - - CN=DC,OU=Domain Controllers,DC=domain,DC=test + - OtherServer$ # Lookup by sAMAaccountName """ RETURN = r""" diff --git a/plugins/modules/group.ps1 b/plugins/modules/group.ps1 index bbb3aa8..ed4a521 100644 --- a/plugins/modules/group.ps1 +++ b/plugins/modules/group.ps1 @@ -26,141 +26,14 @@ $setParams = @{ Name = 'managed_by' Option = @{ type = 'str' } Attribute = 'ManagedBy' + DNLookup = $true } [PSCustomObject]@{ Name = 'members' - Option = @{ - type = 'dict' - options = @{ - add = @{ - type = 'list' - elements = 'str' - } - remove = @{ - type = 'list' - elements = 'str' - } - set = @{ - type = 'list' - elements = 'str' - } - } - } + Option = @{ type = 'add_remove_set' } Attribute = 'member' - New = { - param($Module, $ADParams, $NewParams) - - $newMembers = @( - foreach ($actionKvp in $Module.Params.members.GetEnumerator()) { - if ($null -eq $actionKvp.Value -or $actionKvp.Key -eq 'remove') { continue } - - $invalidMembers = [System.Collections.Generic.List[string]]@() - - foreach ($m in $actionKvp.Value) { - $obj = Get-AnsibleADObject -Identity $m @ADParams | - Select-Object -ExpandProperty DistinguishedName - if ($obj) { - $obj - } - else { - $invalidMembers.Add($m) - } - } - - if ($invalidMembers) { - $module.FailJson("Failed to find the following ad objects for group members: '$($invalidMembers -join "', '")'") - } - } - ) - - if ($newMembers) { - if (-not $NewParams.ContainsKey('OtherAttributes')) { - $NewParams.OtherAttributes = @{} - } - # The AD cmdlets don't like explicitly casted arrays, use - # ForEach-Object to get back a vanilla object[] to set. - $NewParams.OtherAttributes.member = $newMembers | ForEach-Object { "$_" } - } - $Module.Diff.after.members = @($newMembers | Sort-Object) - } - Set = { - param($Module, $ADParams, $SetParams, $ADObject) - - [string[]]$existingMembers = $ADObject.member - - $desiredState = @{} - foreach ($actionKvp in $Module.Params.members.GetEnumerator()) { - if ($null -eq $actionKvp.Value) { continue } - - $invalidMembers = [System.Collections.Generic.List[string]]@() - - $dns = foreach ($m in $actionKvp.Value) { - $obj = Get-AnsibleADObject -Identity $m @ADParams | - Select-Object -ExpandProperty DistinguishedName - if ($obj) { - $obj - } - else { - $invalidMembers.Add($m) - } - } - - if ($invalidMembers) { - $module.FailJson("Failed to find the following ad objects for group members: '$($invalidMembers -join "', '")'") - } - - $desiredState[$actionKvp.Key] = @($dns) - } - - $ignoreCase = [System.StringComparer]::OrdinalIgnoreCase - [string[]]$diffAfter = @() - if ($desiredState.ContainsKey('set')) { - [string[]]$desiredMembers = $desiredState.set - $diffAfter = $desiredMembers - - $toAdd = [string[]][System.Linq.Enumerable]::Except($desiredMembers, $existingMembers, $ignoreCase) - $toRemove = [string[]][System.Linq.Enumerable]::Except($existingMembers, $desiredMembers, $ignoreCase) - - if ($toAdd -or $toRemove) { - if (-not $SetParams.ContainsKey('Replace')) { - $SetParams.Replace = @{} - } - $SetParams.Replace.member = $desiredMembers - } - } - else { - [string[]]$toAdd = @() - [string[]]$toRemove = @() - $diffAfter = $existingMembers - - if ($desiredState.ContainsKey('add') -and $desiredState.add) { - [string[]]$desiredMembers = $desiredState.add - $toAdd = [string[]][System.Linq.Enumerable]::Except($desiredMembers, $existingMembers, $ignoreCase) - $diffAfter = [System.Linq.Enumerable]::Union($desiredMembers, $diffAfter, $ignoreCase) - } - if ($desiredState.ContainsKey('remove') -and $desiredState.remove) { - - [string[]]$desiredMembers = $desiredState.remove - $toRemove = [string[]][System.Linq.Enumerable]::Intersect($desiredMembers, $existingMembers, $ignoreCase) - $diffAfter = [System.Linq.Enumerable]::Except($diffAfter, $desiredMembers, $ignoreCase) - } - - if ($toAdd) { - if (-not $SetParams.ContainsKey('Add')) { - $SetParams.Add = @{} - } - $SetParams.Add.member = $toAdd - } - if ($toRemove) { - if (-not $SetParams.ContainsKey('Remove')) { - $SetParams.Remove = @{} - } - $SetParams.Remove.member = $toRemove - } - } - - $Module.Diff.after.members = ($diffAfter | Sort-Object) - } + DNLookup = $true + IsRawAttribute = $true } [PSCustomObject]@{ Name = 'sam_account_name' diff --git a/plugins/modules/group.py b/plugins/modules/group.py index 6bd8329..df2c704 100644 --- a/plugins/modules/group.py +++ b/plugins/modules/group.py @@ -32,19 +32,29 @@ description: - The user or group that manages the group. - The value can be in the form of a C(distinguishedName), C(objectGUID), - C(objectSid), or C(sAMAccountName). + C(objectSid), C(sAMAccountName), or C(userPrincipalName) string or a + dictionary with the I(name) and optional I(server) key. - This is the value set on the C(managedBy) LDAP attribute. - type: str + - See + R(DN Lookup Attributes,ansible_collections.microsoft.ad.docsite.guide_attributes.dn_lookup_attributes) + for more information on how DN lookups work. + type: raw members: description: - The members of the group to set. - The value is a dictionary that contains 3 keys, I(add), I(remove), and I(set). - - Each subkey is set to a list of AD principal objects to add, remove or - set as the members of this AD group respectively. A principal can be in - the form of a C(distinguishedName), C(objectGUID), C(objectSid), or - C(sAMAccountName). - - The module will fail if it cannot find any of the members referenced. + - Each subkey value is a list of values in the form of a + C(distinguishedName), C(objectGUID), C(objectSid), C(sAMAccountName), + or C(userPrincipalName) string or a dictionary with the I(name) and + optional I(server) key. + - The value for each subkey can either be specified as a string or a + dictionary with the I(name) and optional I(server) key. The I(name) is + the identity to lookup and I(server) is an optional key to override what + AD server to lookup the identity on. + - See + R(DN Lookup Attributes,ansible_collections.microsoft.ad.docsite.guide_attributes.dn_lookup_attributes) + for more information. type: dict suboptions: add: @@ -52,13 +62,22 @@ - Adds the principals specified as members of the group, keeping the existing membership if they are not specified. type: list - elements: str + elements: raw + lookup_failure_action: + description: + - Control the action to take when the lookup fails to find the DN. + - C(fail) will cause the task to fail. + - C(ignore) will ignore the value and continue. + - C(warn) will ignore the value and display a warning. + choices: ['fail', 'ignore', 'warn'] + default: fail + type: str remove: description: - Removes the principals specified as members of the group, keeping the existing membership if they are not specified. type: list - elements: str + elements: raw set: description: - Sets only the principals specified as members of the group. @@ -66,7 +85,7 @@ if not specified in this list. - Set this to an empty list to remove all members from a group. type: list - elements: str + elements: raw sam_account_name: description: - The C(sAMAccountName) value to set for the group. @@ -199,6 +218,12 @@ set: - Domain Admins - Domain Users + - name: UserInOtherDomain + server: OtherDomain + domain_credentials: + - name: OtherDomain + username: OtherDomainUser + password: '{{ other_domain_password }}' """ RETURN = r""" diff --git a/plugins/modules/ou.ps1 b/plugins/modules/ou.ps1 index 6af68b5..909b13c 100644 --- a/plugins/modules/ou.ps1 +++ b/plugins/modules/ou.ps1 @@ -22,6 +22,7 @@ $setParams = @{ Name = 'managed_by' Option = @{ type = 'str' } Attribute = 'ManagedBy' + DNLookup = $true } [PSCustomObject]@{ Name = 'postal_code' diff --git a/plugins/modules/ou.py b/plugins/modules/ou.py index 5d1d605..1e31cc8 100644 --- a/plugins/modules/ou.py +++ b/plugins/modules/ou.py @@ -26,9 +26,13 @@ description: - The user or group that manages the object. - The value can be in the form of a C(distinguishedName), C(objectGUID), - C(objectSid), or sAMAccountName). + C(objectSid), C(sAMAccountName), or C(userPrincipalName) string or a + dictionary with the I(name) and optional I(server) key. - This is the value set on the C(managedBy) LDAP attribute. - type: str + - See + R(DN Lookup Attributes,ansible_collections.microsoft.ad.docsite.guide_attributes.dn_lookup_attributes) + for more information on how DN lookups work. + type: raw postal_code: description: - Configures the user's postal code / zip code. @@ -116,6 +120,13 @@ attributes: set: comment: A comment for the OU + +- name: Set managedBy using an identity from another DC + microsoft.ad.ou: + name: MyOU + managed_by: + name: manager-user + server: OtherDC """ RETURN = r""" diff --git a/plugins/modules/user.ps1 b/plugins/modules/user.ps1 index f918379..4d5571b 100644 --- a/plugins/modules/user.ps1 +++ b/plugins/modules/user.ps1 @@ -103,15 +103,10 @@ $setParams = @{ Name = 'delegates' Option = @{ aliases = 'principals_allowed_to_delegate' - type = 'dict' - options = @{ - add = @{ type = 'list'; elements = 'str' } - remove = @{ type = 'list'; elements = 'str' } - set = @{ type = 'list'; elements = 'str' } - } + type = 'add_remove_set' } Attribute = 'PrincipalsAllowedToDelegateToAccount' - CaseInsensitive = $true + DNLookup = $true } [PSCustomObject]@{ diff --git a/plugins/modules/user.py b/plugins/modules/user.py index 1b0350c..dba4951 100644 --- a/plugins/modules/user.py +++ b/plugins/modules/user.py @@ -40,14 +40,19 @@ description: - The principal objects that the current AD object can trust for delegation to either add, remove or set. - - The values for each sub option must be specified as a distinguished name - C(CN=shenetworks,CN=Users,DC=ansible,DC=test) + - Each subkey value is a list of values in the form of a + C(distinguishedName), C(objectGUID), C(objectSid), C(sAMAccountName), + or C(userPrincipalName) string or a dictionary with the I(name) and + optional I(server) key. - This is the value set on the C(msDS-AllowedToActOnBehalfOfOtherIdentity) LDAP attribute. - This is a highly sensitive attribute as it allows the principals specified to impersonate any account when authenticating with the AD computer object being managed. - To clear all principals, use I(set) with an empty list. + - See + R(DN Lookup Attributes,ansible_collections.microsoft.ad.docsite.guide_attributes.dn_lookup_attributes) + for more information on how DN lookups work. - See R(Setting list option values,ansible_collections.microsoft.ad.docsite.guide_list_values) for more information on how to add/remove/set list options. aliases: @@ -56,29 +61,36 @@ suboptions: add: description: - - The AD objects by their C(DistinguishedName) to add as a principal - allowed to delegate. + - Adds the principals specified as principals allowed to delegate to. - Any existing principals not specified by I(add) will be untouched unless specified by I(remove) or not in I(set). type: list - elements: str + elements: raw + lookup_failure_action: + description: + - Control the action to take when the lookup fails to find the DN. + - C(fail) will cause the task to fail. + - C(ignore) will ignore the value and continue. + - C(warn) will ignore the value and display a warning. + choices: ['fail', 'ignore', 'warn'] + default: fail + type: str remove: description: - - The AD objects by their C(DistinguishedName) to remove as a principal - allowed to delegate. + - Removes the principals specified as principals allowed to delegate to. - Any existing principals not specified by I(remove) will be untouched unless I(set) is defined. type: list - elements: str + elements: raw set: description: - - The AD objects by their C(DistinguishedName) to set as the only + - Sets the principals specified as principals allowed to delegate to. principals allowed to delegate. - This will remove any existing principals if not specified in this list. - Specify an empty list to remove all principals allowed to delegate. type: list - elements: str + elements: raw email: description: - Configures the user's email address. diff --git a/tests/integration/targets/computer/tasks/tests.yml b/tests/integration/targets/computer/tasks/tests.yml index 2a403c3..3619df4 100644 --- a/tests/integration/targets/computer/tasks/tests.yml +++ b/tests/integration/targets/computer/tasks/tests.yml @@ -99,14 +99,41 @@ that: - not remove_comp_again is changed +- name: expect failure with invalid DN lookup entry - no name + computer: + name: MyComputer + state: present + delegates: + set: + - CN=krbtgt,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + - server: fail + register: invalid_dn_lookup_no_name + failed_when: >- + invalid_dn_lookup_no_name.msg != "Failed to find the AD object DNs for delegates.set. Identity entry does not contain the required name key." + +- name: expect failure with invalid DN lookup entry - extra keys + computer: + name: MyComputer + state: present + delegates: + add: + - name: name + invalid2: bar + invalid1: foo + register: invalid_dn_lookup_extra_keys + failed_when: >- + invalid_dn_lookup_extra_keys.msg != "Failed to find the AD object DNs for delegates.add. Identity entry for 'name' contains extra keys: 'invalid1', 'invalid2'." + - name: create computer with custom options computer: name: MyComputer state: present delegates: + lookup_failure_action: ignore set: - CN=krbtgt,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} - - CN=Administrator,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + - name: CN=Administrator,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + - CN=Missing,{{ setup_domain_info.output[0].defaultNamingContext }} kerberos_encryption_types: set: - aes128 @@ -188,8 +215,11 @@ name: MyComputer path: CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} delegates: + lookup_failure_action: warn set: - - CN=KRBTGT,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + - name: CN=KRBTGT,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + - '' + - CN=Missing,{{ setup_domain_info.output[0].defaultNamingContext }} dns_hostname: other.domain.com kerberos_encryption_types: set: @@ -236,6 +266,9 @@ assert: that: - change_comp is changed + - change_comp.warnings | length == 1 + - >- + change_comp.warnings[0] == "Failed to find the AD object DNs for delegates.set. Ignoring invalid identities: 'CN=Missing," ~ setup_domain_info.output[0].defaultNamingContext ~ "'" - change_comp_actual.objects[0].dnsHostName == 'other.domain.com' - change_comp_actual.objects[0].location == 'comp location' - change_comp_actual.objects[0]['msDS-SupportedEncryptionTypes'] == 20 @@ -247,6 +280,17 @@ - '"ADS_UF_TRUSTED_FOR_DELEGATION" not in change_comp_actual.objects[0].userAccountControl_AnsibleFlags' - change_comp_delegates.output == ["krbtgt"] +- name: fail with invalid delegate identity + computer: + name: MyComputer + path: CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + delegates: + set: + - CN=Missing,{{ setup_domain_info.output[0].defaultNamingContext }} + register: invalid_delegate + failed_when: >- + invalid_delegate.msg != "Failed to find the AD object DNs for delegates.set. Invalid identities: 'CN=Missing," ~ setup_domain_info.output[0].defaultNamingContext ~ "'" + - name: add and remove list options computer: name: MyComputer @@ -254,9 +298,10 @@ delegates: add: - CN=Administrator,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + - '' remove: + - name: '' - CN=KRBTGT,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} - - CN=Missing,{{ setup_domain_info.output[0].defaultNamingContext }} kerberos_encryption_types: add: - aes128 @@ -305,7 +350,6 @@ - CN=Administrator,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} remove: - CN=KRBTGT,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} - - CN=Missing,{{ setup_domain_info.output[0].defaultNamingContext }} kerberos_encryption_types: add: - aes128 diff --git a/tests/integration/targets/domain_child/tasks/cross_domain.yml b/tests/integration/targets/domain_child/tasks/cross_domain.yml new file mode 100644 index 0000000..5bca433 --- /dev/null +++ b/tests/integration/targets/domain_child/tasks/cross_domain.yml @@ -0,0 +1,382 @@ +- name: create test object in parent domain with domain_username creds - check mode + microsoft.ad.user: + name: ParentUser1 + path: '{{ parent_ou }}' + state: present + password: '{{ domain_password }}' + update_password: when_changed + domain_server: '{{ domain_realm }}' + domain_username: '{{ domain_user_upn }}' + domain_password: '{{ domain_password }}' + register: user_with_creds1_check + check_mode: true + delegate_to: CHILD + +- name: get result of create test object in parent domain with domain_username creds - check mode + microsoft.ad.object_info: + identity: CN=ParentUser1,{{ parent_ou }} + register: user_with_creds1_check_actual + delegate_to: PARENT + +- name: assert create test object in parent domain with domain_username creds - check mode + assert: + that: + - user_with_creds1_check is changed + - user_with_creds1_check.distinguished_name == "CN=ParentUser1," ~ parent_ou + - user_with_creds1_check_actual.objects == [] + +- name: create test object in parent domain with domain_username creds + microsoft.ad.user: + name: ParentUser1 + path: '{{ parent_ou }}' + state: present + password: '{{ domain_password }}' + update_password: when_changed + domain_server: '{{ domain_realm }}' + domain_username: '{{ domain_user_upn }}' + domain_password: '{{ domain_password }}' + register: user_with_creds1 + delegate_to: CHILD + +- name: get result of create test object in parent domain with domain_username creds + microsoft.ad.object_info: + identity: CN=ParentUser1,{{ parent_ou }} + register: user_with_creds1_actual + delegate_to: PARENT + +- name: assert create test object in parent domain with domain_username creds + assert: + that: + - user_with_creds1 is changed + - user_with_creds1.distinguished_name == "CN=ParentUser1," ~ parent_ou + - user_with_creds1_actual.objects | count == 1 + - user_with_creds1_actual.objects[0].ObjectGUID == user_with_creds1.object_guid + - user_with_creds1_actual.objects[0].DistinguishedName == user_with_creds1.distinguished_name + +- name: create test object in parent domain with domain_username creds - idempotent + microsoft.ad.user: + name: ParentUser1 + path: '{{ parent_ou }}' + state: present + password: '{{ domain_password }}' + update_password: when_changed + domain_server: '{{ domain_realm }}' + domain_username: '{{ domain_user_upn }}' + domain_password: '{{ domain_password }}' + register: user_with_creds1_again + delegate_to: CHILD + +- name: assert create test object in parent domain with domain_username creds - idempotent + assert: + that: + - not user_with_creds1_again is changed + - user_with_creds1_again.distinguished_name == user_with_creds1.distinguished_name + - user_with_creds1_again.object_guid == user_with_creds1.object_guid + +- name: create test object in parent domain with domain_credentials creds - check mode + microsoft.ad.user: + name: ParentUser2 + path: '{{ parent_ou }}' + state: present + password: '{{ domain_password }}' + update_password: when_changed + domain_server: '{{ domain_realm }}' + domain_credentials: + - username: '{{ domain_user_upn }}' + password: '{{ domain_password }}' + register: user_with_creds2_check + check_mode: true + delegate_to: CHILD + +- name: get result of create test object in parent domain with domain_credentials creds - check mode + microsoft.ad.object_info: + identity: CN=ParentUser2,{{ parent_ou }} + register: user_with_creds2_check_actual + delegate_to: PARENT + +- name: assert create test object in parent domain with domain_credentials creds - check mode + assert: + that: + - user_with_creds2_check is changed + - user_with_creds2_check.distinguished_name == "CN=ParentUser2," ~ parent_ou + - user_with_creds2_check_actual.objects == [] + +- name: create test object in parent domain with domain_credentials creds + microsoft.ad.user: + name: ParentUser2 + path: '{{ parent_ou }}' + state: present + password: '{{ domain_password }}' + update_password: when_changed + domain_server: '{{ domain_realm }}' + domain_credentials: + - username: '{{ domain_user_upn }}' + password: '{{ domain_password }}' + register: user_with_creds2 + delegate_to: CHILD + +- name: get result of create test object in parent domain with domain_credentials creds + microsoft.ad.object_info: + identity: CN=ParentUser2,{{ parent_ou }} + register: user_with_creds2_actual + delegate_to: PARENT + +- name: assert create test object in parent domain with domain_credentials creds + assert: + that: + - user_with_creds2 is changed + - user_with_creds2.distinguished_name == "CN=ParentUser2," ~ parent_ou + - user_with_creds2_actual.objects | count == 1 + - user_with_creds2_actual.objects[0].ObjectGUID == user_with_creds2.object_guid + - user_with_creds2_actual.objects[0].DistinguishedName == user_with_creds2.distinguished_name + +- name: create test object in parent domain with domain_credentials creds - idempotent + microsoft.ad.user: + name: ParentUser2 + path: '{{ parent_ou }}' + state: present + password: '{{ domain_password }}' + update_password: when_changed + domain_server: '{{ domain_realm }}' + domain_credentials: + - username: '{{ domain_user_upn }}' + password: '{{ domain_password }}' + register: user_with_creds2_again + delegate_to: CHILD + +- name: assert create test object in parent domain with domain_credentials creds - idempotent + assert: + that: + - not user_with_creds2_again is changed + - user_with_creds2_again.distinguished_name == user_with_creds2.distinguished_name + - user_with_creds2_again.object_guid == user_with_creds2.object_guid + +- name: edit user with domain_username creds - check mode + microsoft.ad.user: + name: ParentUser1 + path: '{{ parent_ou }}' + state: present + password: '{{ domain_password }}' + update_password: when_changed + domain_server: '{{ domain_realm }}' + domain_username: '{{ domain_user_upn }}' + domain_password: '{{ domain_password }}' + description: User Description + spn: + set: + - HTTP/ParentUser1 + attributes: + set: + comment: My comment + register: set_with_creds1_check + delegate_to: CHILD + check_mode: true + +- name: get result of set user with domain_username creds - check mode + microsoft.ad.object_info: + identity: '{{ user_with_creds1.object_guid }}' + properties: + - comment + - Description + - servicePrincipalName + register: set_with_creds1_check_actual + delegate_to: PARENT + +- name: assert set user with domain_username creds - check mode + assert: + that: + - set_with_creds1_check is changed + - set_with_creds1_check.distinguished_name == user_with_creds1.distinguished_name + - set_with_creds1_check.object_guid == user_with_creds1.object_guid + - set_with_creds1_check_actual.objects[0].Description == None + - set_with_creds1_check_actual.objects[0].DistinguishedName == user_with_creds1.distinguished_name + - set_with_creds1_check_actual.objects[0].Name == 'ParentUser1' + - set_with_creds1_check_actual.objects[0].ObjectGUID == user_with_creds1.object_guid + - set_with_creds1_check_actual.objects[0].comment == None + - set_with_creds1_check_actual.objects[0].servicePrincipalName == None + +- name: edit user with domain_username creds + microsoft.ad.user: + name: ParentUser1 + path: '{{ parent_ou }}' + state: present + password: '{{ domain_password }}' + update_password: when_changed + domain_server: '{{ domain_realm }}' + domain_username: '{{ domain_user_upn }}' + domain_password: '{{ domain_password }}' + description: User Description + spn: + set: + - HTTP/ParentUser1 + attributes: + set: + comment: My comment + register: set_with_creds1 + delegate_to: CHILD + +- name: get result of set user with domain_username creds + microsoft.ad.object_info: + identity: '{{ user_with_creds1.object_guid }}' + properties: + - comment + - Description + - servicePrincipalName + register: set_with_creds1_actual + delegate_to: PARENT + +- name: assert set user with domain_username creds + assert: + that: + - set_with_creds1 is changed + - set_with_creds1.distinguished_name == user_with_creds1.distinguished_name + - set_with_creds1.object_guid == user_with_creds1.object_guid + - set_with_creds1_actual.objects[0].Description == "User Description" + - set_with_creds1_actual.objects[0].DistinguishedName == user_with_creds1.distinguished_name + - set_with_creds1_actual.objects[0].Name == 'ParentUser1' + - set_with_creds1_actual.objects[0].ObjectGUID == user_with_creds1.object_guid + - set_with_creds1_actual.objects[0].comment == "My comment" + - set_with_creds1_actual.objects[0].servicePrincipalName == "HTTP/ParentUser1" + +- name: edit user with domain_credentials creds - check mode + microsoft.ad.user: + name: ParentUser2 + path: '{{ parent_ou }}' + state: present + password: '{{ domain_password }}' + update_password: when_changed + domain_server: '{{ domain_realm }}' + domain_credentials: + - username: '{{ domain_user_upn }}' + password: '{{ domain_password }}' + description: User Description + spn: + set: + - HTTP/ParentUser2 + attributes: + set: + comment: My comment + register: set_with_creds2_check + delegate_to: CHILD + check_mode: true + +- name: get result of set user with domain_credentials creds - check mode + microsoft.ad.object_info: + identity: '{{ user_with_creds2.object_guid }}' + properties: + - comment + - Description + - servicePrincipalName + register: set_with_creds2_check_actual + delegate_to: PARENT + +- name: assert set user with domain_credentials creds - check mode + assert: + that: + - set_with_creds2_check is changed + - set_with_creds2_check.distinguished_name == user_with_creds2.distinguished_name + - set_with_creds2_check.object_guid == user_with_creds2.object_guid + - set_with_creds2_check_actual.objects[0].Description == None + - set_with_creds2_check_actual.objects[0].DistinguishedName == user_with_creds2.distinguished_name + - set_with_creds2_check_actual.objects[0].Name == 'ParentUser2' + - set_with_creds2_check_actual.objects[0].ObjectGUID == user_with_creds2.object_guid + - set_with_creds2_check_actual.objects[0].comment == None + - set_with_creds2_check_actual.objects[0].servicePrincipalName == None + +- name: edit user with domain_credentials creds + microsoft.ad.user: + name: ParentUser2 + path: '{{ parent_ou }}' + state: present + password: '{{ domain_password }}' + update_password: when_changed + domain_server: '{{ domain_realm }}' + domain_credentials: + - username: '{{ domain_user_upn }}' + password: '{{ domain_password }}' + description: User Description + spn: + set: + - HTTP/ParentUser2 + attributes: + set: + comment: My comment + register: set_with_creds2 + delegate_to: CHILD + +- name: get result of set user with domain_credentials creds + microsoft.ad.object_info: + identity: '{{ user_with_creds2.object_guid }}' + properties: + - comment + - Description + - servicePrincipalName + register: set_with_creds2_actual + delegate_to: PARENT + +- name: assert set user with domain_credentials creds + assert: + that: + - set_with_creds2 is changed + - set_with_creds2.distinguished_name == user_with_creds2.distinguished_name + - set_with_creds2.object_guid == user_with_creds2.object_guid + - set_with_creds2_actual.objects[0].Description == "User Description" + - set_with_creds2_actual.objects[0].DistinguishedName == user_with_creds2.distinguished_name + - set_with_creds2_actual.objects[0].Name == 'ParentUser2' + - set_with_creds2_actual.objects[0].ObjectGUID == user_with_creds2.object_guid + - set_with_creds2_actual.objects[0].comment == "My comment" + - set_with_creds2_actual.objects[0].servicePrincipalName == "HTTP/ParentUser2" + +- name: set value with DN lookup and creds + microsoft.ad.group: + name: Group-CHILD + path: '{{ child_ou }}' + state: present + members: + add: + - User-CHILD + - name: User-PARENT + server: '{{ domain_realm }}' + domain_credentials: + - name: '{{ domain_realm }}' + username: '{{ domain_user_upn }}' + password: '{{ domain_password }}' + register: lookup_with_creds + delegate_to: CHILD + +- name: get result of set value with DN lookup and creds + microsoft.ad.object_info: + identity: '{{ lookup_with_creds.object_guid }}' + properties: + - member + register: lookup_with_creds_actual + delegate_to: CHILD + +- name: assert set value with DN lookup and creds + assert: + that: + - lookup_with_creds is changed + - parent_user in lookup_with_creds_actual.objects[0].member + - child_user in lookup_with_creds_actual.objects[0].member + +- name: set value with DN lookup and creds - idempotent + microsoft.ad.group: + name: Group-CHILD + path: '{{ child_ou }}' + state: present + members: + add: + - User-CHILD + - name: User-PARENT + server: '{{ domain_realm }}' + domain_credentials: + - name: '{{ domain_realm }}' + username: '{{ domain_user_upn }}' + password: '{{ domain_password }}' + register: lookup_with_creds_again + delegate_to: CHILD + +- name: assert set value with DN lookup and creds - idempotent + assert: + that: + - not lookup_with_creds_again is changed diff --git a/tests/integration/targets/domain_child/test.yml b/tests/integration/targets/domain_child/test.yml index 6cb2495..ba936e1 100644 --- a/tests/integration/targets/domain_child/test.yml +++ b/tests/integration/targets/domain_child/test.yml @@ -1,6 +1,6 @@ - name: ensure time is in sync hosts: windows - gather_facts: no + gather_facts: false tasks: - name: get current host datetime command: date +%s @@ -56,7 +56,7 @@ - name: run microsoft.ad.domain_child child tests hosts: CHILD - gather_facts: no + gather_facts: false tasks: - name: check domain status to see if test will run @@ -69,7 +69,7 @@ - name: run microsoft.ad.domain_child tree tests hosts: TREE - gather_facts: no + gather_facts: false tasks: - name: check domain status to see if test will run @@ -79,3 +79,68 @@ - ansible.builtin.include_tasks: tasks/main_tree.yml when: domain_status.output[0].Domain != child_domain_name + +- name: run extra tests to test out cross domain functionality in other modules + hosts: localhost + gather_facts: false + + tasks: + - name: create test OU in each domain + microsoft.ad.ou: + name: Ansible-{{ item }} + state: present + delegate_to: '{{ item }}' + register: ou_info + loop: + - PARENT + - CHILD + + - block: + - name: set facts for each OU DN + ansible.builtin.set_fact: + parent_ou: '{{ ou_info.results[0].distinguished_name }}' + child_ou: '{{ ou_info.results[1].distinguished_name }}' + + - name: create test users + microsoft.ad.user: + name: User-{{ item }} + state: present + password: '{{ domain_password }}' + path: '{{ {"PARENT": parent_ou, "CHILD": child_ou}[item] }}' + register: user_info + delegate_to: '{{ item }}' + loop: + - PARENT + - CHILD + + - name: create test groups + microsoft.ad.group: + name: Group-{{ item }} + state: present + path: '{{ {"PARENT": parent_ou, "CHILD": child_ou}[item] }}' + scope: universal + register: group_info + delegate_to: '{{ item }}' + loop: + - PARENT + - CHILD + + - name: set facts for each test user and group DN + ansible.builtin.set_fact: + parent_user: '{{ user_info.results[0].distinguished_name }}' + parent_group: '{{ group_info.results[0].distinguished_name }}' + child_user: '{{ user_info.results[1].distinguished_name }}' + child_group: '{{ group_info.results[1].distinguished_name }}' + + - name: run cross domain tests + ansible.builtin.import_tasks: tasks/cross_domain.yml + + always: + - name: remove test OU in each domain + microsoft.ad.ou: + name: Ansible-{{ item }} + state: absent + delegate_to: '{{ item }}' + loop: + - PARENT + - CHILD diff --git a/tests/integration/targets/group/tasks/tests.yml b/tests/integration/targets/group/tasks/tests.yml index b40041b..958398a 100644 --- a/tests/integration/targets/group/tasks/tests.yml +++ b/tests/integration/targets/group/tasks/tests.yml @@ -107,7 +107,8 @@ - my_user_2 - another-user register: fail_invalid_members - failed_when: 'fail_invalid_members.msg != "Failed to find the following ad objects for group members: ''fake-user'', ''another-user''"' + failed_when: >- + fail_invalid_members.msg != "Failed to find the AD object DNs for members.add. Invalid identities: 'fake-user', 'another-user'" - name: add members to a group - check group: @@ -141,7 +142,7 @@ members: add: - my_user_1 - - '{{ test_users.results[2].sid }}' + - name: '{{ test_users.results[2].sid }}' - MyGroup2-ReallyLongGroupNameHere register: add_member @@ -376,7 +377,8 @@ - my_user_2 - another-user register: fail_invalid_members - failed_when: 'fail_invalid_members.msg != "Failed to find the following ad objects for group members: ''fake-user'', ''another-user''"' + failed_when: >- + fail_invalid_members.msg != "Failed to find the AD object DNs for members.add. Invalid identities: 'fake-user', 'another-user'" - name: create group with custom options group: @@ -388,7 +390,8 @@ scope: domainlocal category: distribution homepage: www.ansible.com - managed_by: Domain Admins + managed_by: + name: Domain Admins members: add: - my_user_1 diff --git a/tests/integration/targets/ou/tasks/tests.yml b/tests/integration/targets/ou/tasks/tests.yml index 49d06ae..b6061b7 100644 --- a/tests/integration/targets/ou/tasks/tests.yml +++ b/tests/integration/targets/ou/tasks/tests.yml @@ -163,7 +163,8 @@ country: US description: Custom description display_name: OU display Name - managed_by: Domain Users + managed_by: + name: Domain Users postal_code: 10001 state_province: '' street: Main