From 7a78930dd08095d4e64c3430fb67014f7c79b583 Mon Sep 17 00:00:00 2001 From: Jordan Borean Date: Tue, 6 Dec 2022 05:06:18 +1000 Subject: [PATCH] Add module to manage AD objects --- .azure-pipelines/azure-pipelines.yml | 2 +- plugins/doc_fragments/ad_attribute.py | 65 ++ plugins/module_utils/ADAttribute.psm1 | 397 ++++++++ plugins/module_utils/ADIdentity.psm1 | 103 ++ plugins/modules/object.ps1 | 273 ++++++ plugins/modules/object.py | 232 +++++ plugins/modules/object_info.ps1 | 17 +- plugins/modules/object_info.py | 23 +- tests/integration/targets/object/aliases | 2 + .../targets/object/defaults/main.yml | 2 + .../integration/targets/object/meta/main.yml | 2 + .../integration/targets/object/tasks/main.yml | 9 + .../targets/object/tasks/tests.yml | 876 ++++++++++++++++++ .../targets/setup_domain/tasks/main.yml | 1 + 14 files changed, 1995 insertions(+), 9 deletions(-) create mode 100644 plugins/doc_fragments/ad_attribute.py create mode 100644 plugins/module_utils/ADAttribute.psm1 create mode 100644 plugins/module_utils/ADIdentity.psm1 create mode 100644 plugins/modules/object.ps1 create mode 100644 plugins/modules/object.py create mode 100644 tests/integration/targets/object/aliases create mode 100644 tests/integration/targets/object/defaults/main.yml create mode 100644 tests/integration/targets/object/meta/main.yml create mode 100644 tests/integration/targets/object/tasks/main.yml create mode 100644 tests/integration/targets/object/tasks/tests.yml diff --git a/.azure-pipelines/azure-pipelines.yml b/.azure-pipelines/azure-pipelines.yml index 770c06a..4d0afee 100644 --- a/.azure-pipelines/azure-pipelines.yml +++ b/.azure-pipelines/azure-pipelines.yml @@ -23,7 +23,7 @@ schedules: variables: - name: checkoutPath - value: ansible_collections/community/windows + value: ansible_collections/ansible/active_directory - name: coverageBranches value: main - name: pipelinesCoverage diff --git a/plugins/doc_fragments/ad_attribute.py b/plugins/doc_fragments/ad_attribute.py new file mode 100644 index 0000000..4f1cdb9 --- /dev/null +++ b/plugins/doc_fragments/ad_attribute.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2023 Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + + +class ModuleDocFragment: + + # Common options for ansible_collections.ansible.active_directory.plugins.module_utils.ADAttribute + DOCUMENTATION = r""" +options: + attributes: + description: + - The attributes to either add, remove, or set on the AD object. + - The value of each attribute option should be a dictionary where the key + is the LDAP attribute, e.g. C(firstName), C(comment) and the value is the + value, or list of values, to set for that attribute. + - The attribute value(s) can either be the raw string, integer, or bool + value to add, remove, or set on the attribute in question. + - The value can also be a dictionary with the I(type) key set to C(bytes), + C(date_time), C(security_descriptor), or C(raw) and the value for this + entry under the I(value) key. + - The C(bytes) type has a value that is a base64 encoded string of the raw + bytes to set. + - The C(date_time) type has a value that is the ISO 8601 DateTime string of + the DateTime to set. The DateTime will be set as the Microsoft FILETIME + integer value which is the number of 100 nanoseconds since 1601-01-01 in + UTC. + - The C(security_descriptor) type has a value that is the Security + Descriptor SDDL string used for the C(nTSecurityDescriptor) attribute. + - The C(raw) type is the int, string, or boolean value to set. + - String attribute values are compared using a case sensitive match on the + AD object being managed. + default: {} + type: dict + suboptions: + add: + description: + - A dictionary of all the attributes and their value(s) to add to the + AD object being managed if they are not already present. + - This is used for attributes that can contain multiple values, if the + attribute only allows a single value, use I(set) instead. + default: {} + type: dict + remove: + description: + - A dictionary of all the attributes and their value(s) to remove from + the AD object being managed if they are present. + - This is used for attributes that can contain multiple values, if the + attribute only allows a single value, use I(set) instead. + default: {} + type: dict + set: + description: + - A dictionary of all attributes and their value(s) to set on the AD + object being managed. + - This will replace any existing values if they do not match the ones + being requested. + - The order of attribute values are not checked only, only that the + values requested are the only values on the object attribute. + - Set this to null or an empty list to clear any values for the + attribute. + default: {} + type: dict +""" diff --git a/plugins/module_utils/ADAttribute.psm1 b/plugins/module_utils/ADAttribute.psm1 new file mode 100644 index 0000000..1d6d5c5 --- /dev/null +++ b/plugins/module_utils/ADAttribute.psm1 @@ -0,0 +1,397 @@ +# Copyright (c) 2023 Ansible Project +# Simplified BSD License (see licenses/simplified_bsd.txt or https://opensource.org/licenses/BSD-2-Clause) + +Function Compare-AnsibleADAttribute { + <# + .SYNOPSIS + Compares AD attribute values. + + .PARAMETER Name + The attribute name to compare. + + .PARAMETER ADObject + The AD object to compare with. + + .PARAMETER Attribute + The attribute value(s) to add/remove/set. + + .PARAMETER Action + Set to Add to add the value(s), Remove to remove the value(s), and Set to replace the value(s). + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [string] + $Name, + + [Parameter()] + [AllowNull()] + [Microsoft.ActiveDirectory.Management.ADObject] + $ADObject, + + [Parameter()] + [AllowEmptyCollection()] + [object] + $Attribute, + + [ValidateSet("Add", "Remove", "Set")] + [string] + $Action + ) + + <# Gets all the known types the AD module can return + + DateTime, Guid, SecurityIdentifier are all from readonly properties + that the AD module alaises of the real LDAP attributes. + + Get-ADObject -LDAPFilter '(objectClass=*)' -Properties * | + ForEach-Object { + foreach ($name in $_.PSObject.Properties.Name) { + if ($name -in @('AddedProperties', 'ModifiedProperties', 'RemovedProperties', 'PropertyNames')) { continue } + + $v = $_.$name + if ($null -eq $v) { continue } + if ($v -isnot [System.Collections.IList] -or $v -is [System.Byte[]]) { + $v = @(, $v) + } + + foreach ($value in $v) { + $value.GetType() + } + } + } | + Sort-Object -Unique + #> + $getDiffValue = { + if ($_ -is [System.Byte[]]) { + [System.Convert]::ToBase64String($_) + } + elseif ($_ -is [System.DirectoryServices.ActiveDirectorySecurity]) { + $_.GetSecurityDescriptorSddlForm([System.Security.AccessControl.AccessControlSections]::All) + } + else { + # Bool, Int32, Int64, String + $_ + } + } + + $existingAttributes = [System.Collections.Generic.List[object]]@() + if ($ADObject -and $ADObject.$Name) { + $existingValues = $ADObject.$Name + if ($null -ne $existingValues) { + if ( + $existingValues -is [System.Collections.IList] -and + $existingValues -isnot [System.Byte[]] + ) { + # Wrap with @() to help pwsh unroll the property value collection + $existingAttributes.AddRange(@($existingValues)) + + } + else { + $existingAttributes.Add($existingValues) + } + } + } + + $desiredAttributes = [System.Collections.Generic.List[object]]@() + if ($null -ne $Attribute -and $Attribute -isnot [System.Collections.IList]) { + $Attribute = @($Attribute) + } + foreach ($attr in $Attribute) { + if ($attr -is [System.Collections.IDictionary]) { + if ($attr.Keys.Count -gt 2) { + $keyList = $attr.Keys -join "', '" + throw "Attribute '$Name' entry should only contain the 'type' and 'value' keys, found: '$keyList'" + } + + $type = $attr.type + $value = $attr.value + } + else { + $type = 'raw' + $value = $attr + } + + switch ($type) { + bytes { + $desiredAttributes.Add([System.Convert]::FromBase64String($value)) + } + date_time { + $dtVal = [DateTime]::ParseExact( + "o", + $value, + [System.Globalization.CultureInfo]::InvariantCulture) + $desiredAttributes.Add($dtVal.ToFileTimeUtc()) + } + int { + $desiredAttributes.Add([Int64]$value) + } + security_descriptor { + $sd = New-Object -TypeName System.DirectoryServices.ActiveDirectorySecurity + $sd.SetSecurityDescriptorSddlForm($value) + $desiredAttributes.Add($sd) + } + raw { + $desiredAttributes.Add($value) + } + default { throw "Attribute type '$type' must be bytes, date_time, int, security_descriptor, or raw" } + } + } + + $diffBefore = @($existingAttributes | ForEach-Object -Process $getDiffValue) + $diffAfter = [System.Collections.Generic.List[object]]@() + $value = [System.Collections.Generic.List[object]]@() + $changed = $false + + # It's a lot easier to compare the string values + $existing = [string[]]$diffBefore + $desired = [string[]]@($desiredAttributes | ForEach-Object -Process $getDiffValue) + + if ($Action -eq 'Add') { + $diffAfter.AddRange($existingAttributes) + + for ($i = 0; $i -lt $desired.Length; $i++) { + if ($desired[$i] -cnotin $existing) { + $value.Add($desiredAttributes[$i]) + $diffAfter.Add($desiredAttributes[$i]) + $changed = $true + } + } + } + elseif ($Action -eq 'Remove') { + $diffAfter.AddRange($existingAttributes) + + for ($i = $desired.Length - 1; $i -ge 0; $i--) { + if ($desired[$i] -cin $existing) { + $value.Add($desiredAttributes[$i]) + $diffAfter.RemoveAt($i) + $changed = $true + } + } + } + else { + $diffAfter.AddRange($desiredAttributes) + + $toAdd = [string[]][System.Linq.Enumerable]::Except($desired, $existing) + $toRemove = [string[]][System.Linq.Enumerable]::Except($existing, $desired) + if ($toAdd.Length -or $toRemove.Length) { + $changed = $true + } + + if ($changed) { + $value.AddRange($desiredAttributes) + } + } + + [PSCustomObject]@{ + Name = $Name + Value = $value.ToArray() # AD cmdlets expect an array here + Changed = $changed + DiffBefore = @($diffBefore | Sort-Object) + DiffAfter = @($diffAfter | ForEach-Object -Process $getDiffValue | Sort-Object) + } +} + +Function Update-AnsibleADSetADObjectParam { + <# + .SYNOPSIS + Updates the Set-AD* parameter splat with the parameters needed to set the + attributes requested. + It will output a boolean that indicates whether a change is needed to + update the attributes. + + .PARAMETER Splat + The parameter splat to update. + + .PARAMETER Add + The attributes to add. + + .PARAMETER Remove + The attributes to remove. + + .PARAMETER Set + The attributes to set. + + .PARAMETER Diff + An optional dictionary that can be used to store the diff output value on + what was changed. + + .PARAMETER ADObject + The AD object to compare the requested attribute values with. + + .PARAMETER ForNew + This Splat is used for New-AD* and will update the OtherAttributes + parameter. + #> + [OutputType([bool])] + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [System.Collections.IDictionary] + $Splat, + + [Parameter()] + [AllowNull()] + [System.Collections.IDictionary] + $Add, + + [Parameter()] + [AllowNull()] + [System.Collections.IDictionary] + $Remove, + + [Parameter()] + [AllowNull()] + [System.Collections.IDictionary] + $Set, + + [Parameter()] + [System.Collections.IDictionary] + $Diff, + + [Parameter()] + [AllowNull()] + [Microsoft.ActiveDirectory.Management.ADObject] + $ADObject, + + [Parameter()] + [switch] + $ForNew + ) + + $diffBefore = @{} + $diffAfter = @{} + + $addAttributes = @{} + $removeAttributes = @{} + $replaceAttributes = @{} + $clearAttributes = [System.Collections.Generic.List[String]]@() + + if ($Add.Count) { + foreach ($kvp in $Add.GetEnumerator()) { + $val = Compare-AnsibleADAttribute -Name $kvp.Key -ADObject $ADObject -Attribute $kvp.Value -Action Add + if ($val.Changed -and $val.Value.Count) { + $addAttributes[$kvp.Key] = $val.Value + } + $diffBefore[$kvp.Key] = $val.DiffBefore + $diffAfter[$kvp.Key] = $val.DiffAfter + } + } + # remove doesn't make sense when creating a new object + if (-not $ForNew -and $Remove.Count) { + foreach ($kvp in $Remove.GetEnumerator()) { + $val = Compare-AnsibleADAttribute -Name $kvp.Key -ADObject $ADObject -Attribute $kvp.Value -Action Remove + if ($val.Changed -and $val.Value.Count) { + $removeAttributes[$kvp.Key] = $val.Value + } + $diffBefore[$kvp.Key] = $val.DiffBefore + $diffAfter[$kvp.Key] = $val.DiffAfter + } + } + if ($Set.Count) { + foreach ($kvp in $Set.GetEnumerator()) { + $val = Compare-AnsibleADAttribute -Name $kvp.Key -ADObject $ADObject -Attribute $kvp.Value -Action Set + if ($val.Changed) { + if ($val.Value.Count) { + $replaceAttributes[$kvp.Key] = $val.Value + } + else { + $clearAttributes.Add($kvp.Key) + } + } + $diffBefore[$kvp.Key] = $val.DiffBefore + $diffAfter[$kvp.Key] = $val.DiffAfter + } + } + + $changed = $false + if ($ForNew) { + $diffBefore = $null + $otherAttributes = @{} + + foreach ($kvp in $addAttributes.GetEnumerator()) { + $otherAttributes[$kvp.Key] = $kvp.Value + } + foreach ($kvp in $replaceAttributes.GetEnumerator()) { + $otherAttributes[$kvp.Key] = $kvp.Value + } + + if ($otherAttributes.Count) { + $changed = $true + $Splat.OtherAttributes = $otherAttributes + } + } + else { + if ($addAttributes.Count) { + $changed = $true + $Splat.Add = $addAttributes + } + if ($removeAttributes.Count) { + $changed = $true + $Splat.Remove = $removeAttributes + } + if ($replaceAttributes.Count) { + $changed = $true + $Splat.Replace = $replaceAttributes + } + if ($clearAttributes.Count) { + $changed = $true + $Splat.Clear = $clearAttributes + } + } + + if ($null -ne $Diff.Count) { + $Diff.after = $diffAfter + $Diff.before = $diffBefore + } + + $changed +} + +Function Get-AnsibleADAttributeSpec { + <# + .SYNOPSIS + Used by modules to get the argument spec fragment for AnsibleModule that + want to expose the AD attribute management. + + .EXAMPLE + $spec = @{ + options = @{} + } + $module = [Ansible.Basic.AnsibleModule]::Create($args, $spec, @(Get-AnsibleADAttributeSpec)) + + .NOTES + The options here are reflected in the doc fragment 'ansible.active_directory.ad_attribute' at + 'plugins/doc_fragments/ad_attribute.py'. + #> + @{ + options = @{ + attributes = @{ + default = @{} + type = 'dict' + options = @{ + add = @{ + default = @{} + type = 'dict' + } + remove = @{ + default = @{} + type = 'dict' + } + set = @{ + default = @{} + type = 'dict' + } + } + } + } + } +} + +$exportMembers = @{ + Function = @( + "Get-AnsibleADAttributeSpec" + "Update-AnsibleADSetADObjectParam" + ) +} +Export-ModuleMember @exportMembers diff --git a/plugins/module_utils/ADIdentity.psm1 b/plugins/module_utils/ADIdentity.psm1 new file mode 100644 index 0000000..b57eda1 --- /dev/null +++ b/plugins/module_utils/ADIdentity.psm1 @@ -0,0 +1,103 @@ +# Copyright (c) 2023 Ansible Project +# Simplified BSD License (see licenses/simplified_bsd.txt or https://opensource.org/licenses/BSD-2-Clause) + +Function Get-AnsibleADObject { + <# + .SYNOPSIS + The -Identity params is limited to just objectGuid and distinguishedName + on Get-ADObject. Try to preparse the value to support more common props + like sAMAccountName, objectSid, userPrincipalName. + + .PARAMETER Identity + The Identity to get. + + .PARAMETER Properties + Extra properties to request on the object + + .PARAMETER Server + The explicit domain controller to query. + + .PARAMETER Credential + Custom queries to authenticate with. + + .PARAMETER GetCommand + The Get-AD* cmdlet to use to get the AD object. Defaults to Get-ADObject + if not specified. + #> + [OutputType([Microsoft.ActiveDirectory.Management.ADObject])] + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [string] + $Identity, + + [Parameter()] + [AllowEmptyCollection()] + [string[]] + $Properties, + + [string] + $Server, + + [PSCredential] + $Credential, + + [System.Management.Automation.CommandInfo] + $GetCommand = $null + + ) + + $getByteFilterValue = { + @($args[0] | ForEach-Object { + '\' + [System.BitConverter]::ToString($_).ToLowerInvariant() + }) -join '' + } + + $ldapFilter = $null + + $objectGuid = [Guid]::Empty + if ([System.Guid]::TryParse($Identity, [ref]$objectGuid)) { + $value = &$getByteFilterValue $objectGuid.ToByteArray() + $ldapFilter = "(objectGUID=$value)" + } + elseif ($Identity -match '^.*\@.*\..*$') { + $ldapFilter = "(userPrincipalName=$($Matches[0]))" + } + elseif ($Identity -match '^(?:[^:*?""<>|\/\\]+\\)?(?[^;:""<>|?,=\*\+\\\(\)]{1,20})$') { + $ldapFilter = "(sAMAccountName=$($Matches.username))" + } + else { + try { + $sid = New-Object -TypeName System.Security.Principal.SecurityIdentifier -ArgumentList $Identity + $sidBytes = New-Object -TypeName System.Byte[] -ArgumentList $sid.BinaryLength + $sid.GetBinaryForm($sidBytes, 0) + $value = &$getByteFilterValue $sidBytes + $ldapFilter = "(objectSid=$value)" + } + catch [System.ArgumentException] { + $ldapFilter = "(distinguishedName=$Identity)" + } + } + + $getParms = $PSBoundParameters + $null = $getParms.Remove('Identity') + if ($Properties.Count -eq 0) { + $null = $getParms.Remove('Properties') + } + + $cmd = if ($GetCommand) { + $GetCommand + } + else { + Get-Command -Name Get-ADObject -Module ActiveDirectory + } + + & $cmd @PSBoundParameters -LDAPFilter $ldapFilter | Select-Object -First 1 +} + +$exportMembers = @{ + Function = @( + "Get-AnsibleADObject" + ) +} +Export-ModuleMember @exportMembers diff --git a/plugins/modules/object.ps1 b/plugins/modules/object.ps1 new file mode 100644 index 0000000..b2e7850 --- /dev/null +++ b/plugins/modules/object.ps1 @@ -0,0 +1,273 @@ +#!powershell + +# Copyright: (c) 2023, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic +#AnsibleRequires -PowerShell ..module_utils.ADAttribute +#AnsibleRequires -PowerShell ..module_utils.ADIdentity + +$spec = @{ + options = @{ + description = @{ + type = 'str' + } + display_name = @{ + type = 'str' + } + domain_password = @{ + no_log = $true + type = 'str' + } + domain_server = @{ + type = 'str' + } + domain_username = @{ + type = 'str' + } + identity = @{ + type = 'str' + } + name = @{ + type = 'str' + } + path = @{ + type = 'str' + } + state = @{ + choices = 'absent', 'present' + default = 'present' + type = 'str' + } + type = @{ + type = 'str' + } + } + required_if = @( + , @("state", "present", @("name", "type")) + ) + required_one_of = @( + , @("identity", "name") + ) + required_together = @(, @('domain_username', 'domain_password')) + supports_check_mode = $true +} + +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec, @(Get-AnsibleADAttributeSpec)) + +$module.Result.object_guid = $null +$module.Result.distinguished_name = $null + +Import-Module -Name ActiveDirectory + +$adParams = @{} +if ($module.Params.domain_server) { + $adParams.Server = $module.Params.domain_server +} + +if ($module.Params.domain_username) { + $adParams.Credential = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList @( + $module.Params.domain_username, + (ConvertTo-SecureString -AsPlainText -Force -String $module.Params.domain_password) + ) +} + +[string[]]$requestedProperties = [System.Collections.Generic.HashSet[string]]@( + 'description' + 'displayName' + 'name' + 'objectClass' + $module.Params.attributes.add.Keys + $module.Params.attributes.remove.Keys + $module.Params.attributes.set.Keys +) | Where-Object { $_ } + +$defaultNamingContext = (Get-ADRootDSE -Properties defaultNamingContext @adParams).defaultNamingContext +$identity = if ($module.Params.identity) { + $module.Params.identity +} +else { + $ouPath = $defaultNamingContext + if ($module.Params.path) { + $ouPath = $module.Params.path + } + "CN=$($module.Params.name -replace ',', '\,'),$ouPath" +} + +$getParams = @{ + Identity = $identity + Properties = $requestedProperties +} +$adObject = Get-AnsibleADObject @getParams @adParams +if ($adObject) { + $module.Result.object_guid = $adObject.ObjectGUID + $module.Result.distinguished_name = $adObject.DistinguishedName + + $module.Diff.before = @{ + attributes = $null + name = $adObject.Name + description = $adObject.Description + display_name = $adObject.DisplayName + path = @($adObject.DistinguishedName -split '[^\\],', 2)[-1] + type = $adObject.ObjectClass + } +} +else { + $module.Diff.before = $null +} + +if ($module.Params.state -eq 'absent') { + if ($adObject) { + $removeParams = @{ + Confirm = $false + WhatIf = $module.CheckMode + } + + # Remove-ADObject -Recursive fails with access is denied, use this + # instead to remove the child objects manually + Get-ADObject -Filter * -Searchbase $adObject.DistinguishedName | + Sort-Object -Property { $_.DistinguishedName.Length } -Descending | + Remove-ADObject @removeParams @adParams + + $module.Result.changed = $true + } + + $module.Diff.after = $null +} +else { + $attributes = $module.Params.attributes + $objectDN = $null + $objectGuid = $null + + if (-not $adObject) { + $newParams = @{ + Confirm = $false + Name = $module.Params.name + Type = $module.Params.type + WhatIf = $module.CheckMode + PassThru = $true + } + if ($module.Params.description) { + $newParams.Description = $module.Params.description + } + if ($module.Params.display_name) { + $newParams.DisplayName = $module.Params.display_name + } + + $objectPath = $null + if ($module.Params.path) { + $objectPath = $path + $newParams.Path = $module.Params.path + } + else { + $objectPath = $defaultNamingContext + } + + $diffAttributes = @{} + $null = Update-AnsibleADSetADObjectParam @attributes -Splat $newParams -Diff $diffAttributes -ForNew + + $adObject = New-ADObject @newParams @adParams + $module.Result.changed = $true + + if ($module.CheckMode) { + $objectDN = "CN=$($module.Params.name -replace ',', '\,'),$objectPath" + $objectGuid = [Guid]::Empty # Dummy value for check mode + } + else { + $objectDN = $adObject.DistinguishedName + $objectGuid = $adObject.ObjectGUID + } + + $module.Diff.after = @{ + attributes = $diffAttributes.after + name = $module.Params.name + description = $module.Params.description + display_name = $module.Params.display_name + path = $objectPath + type = $module.Params.type + } + } + else { + $objectDN = $adObject.DistinguishedName + $objectGuid = $adObject.ObjectGUID + + $commonParams = @{ + Confirm = $false + Identity = $adObject.ObjectGUID + PassThru = $true + WhatIf = $module.CheckMode + } + $setParams = @{} + + if ($adObject.ObjectClass -ne $module.Params.type) { + $msg = -join @( + "Cannot change object type $($adObject.ObjectClass) of existing object " + "$($adObject.DistinguishedName) to $($module.Params.type)" + ) + $module.FailJson($msg) + } + + $diffAttributes = @{} + $changed = Update-AnsibleADSetADObjectParam @attributes -Splat $setParams -Diff $diffAttributes -ADObject $adObject + if ($changed) { + } + + $description = $adObject.Description + if ($module.Params.description -and $module.Params.description -cne $description) { + $description = $module.Params.description + $setParams.Description = $description + $changed = $true + } + + $displayName = $adObject.DisplayName + if ($module.Params.display_name -and $module.Params.display_name -cne $displayName) { + $displayName = $module.Params.display_name + $setParams.DisplayName = $displayName + $changed = $true + } + + $objectName = $adObject.Name + $objectPath = @($objectDN -split '[^\\],', 2)[-1] + + if ($module.Params.name -cne $objectName) { + $objectName = $module.Params.name + $adObject = Rename-ADObject @commonParams -NewName $objectName + $module.Result.changed = $true + } + + if ($module.Params.path -and $module.Params.path -ne $objectPath) { + $objectPath = $module.Params.path + $adObject = Move-ADObject @commonParams -TargetPath $objectPath + $module.Result.changed = $true + } + + if ($changed) { + $adObject = Set-ADObject @commonParams @setParams @adParams + $module.Result.changed = $true + } + + if ($module.CheckMode) { + $objectDN = "CN=$($objectName -replace ',', '\,'),$objectPath" + } + else { + $objectDN = $adObject.DistinguishedName + } + + $module.Diff.before.attributes = $diffAttributes.before + $module.Diff.after = @{ + attributes = $diffAttributes.after + name = $objectName + description = $description + display_name = $displayName + path = $objectPath + type = $module.Params.type + } + } + + # Explicit vars are set when running in check mode as the adObject may not + # have the desired values set at runtime + $module.Result.distinguished_name = $objectDN + $module.Result.object_guid = $objectGuid.Guid +} + +$module.ExitJson() diff --git a/plugins/modules/object.py b/plugins/modules/object.py new file mode 100644 index 0000000..de6f07a --- /dev/null +++ b/plugins/modules/object.py @@ -0,0 +1,232 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2023, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r""" +--- +module: object +short_description: Manage Active Directory objects +description: +- Manages Active Directory objects and their attributes. +requirements: +- C(ActiveDirectory) PowerShell module +options: + description: + description: + - The description of the AD object to set. + - This is the value set on the C(description) LDAP attribute. + type: str + display_name: + description: + - The display name of the AD object to set. + - This is the value of the C(displayName) LDAP attribute. + type: str + domain_password: + description: + - The password for I(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 + identity: + description: + - The identity of the AD object used to find the AD object to manage. + - Must be specified if I(name) is not set, when trying to rename the object + with a new I(name), or when trying to move the object into a different + I(path). + - The identity can be in the form of a GUID representing the C(objectGUID) + value, the C(userPrincipalName), C(sAMAccountName), C(objectSid), or + C(distinguishedName). + - If omitted, the AD object to managed is selected by the + C(distinguishedName) using the format C(CN={{ name }},{{ path }}). If + I(path) is not defined, the C(defaultNamingContext) is used instead. + type: str + name: + description: + - The C(name) of the AD object to manage. + - If I(identity) is specified, and the name of the object it found does not + match this value, the object will be renamed. + - This must be set when I(state=present) or if I(identity) is not set. + type: str + path: + description: + - The path of the OU or the container where the new object should exist in. + - If no path is specified, the default is the C(defaultNamingContext) of + domain. + type: str + state: + description: + - Set to C(present) to ensure the AD object exists. + - Set to C(absent) to remove the AD object if it exists. + - The option I(name) must be set when I(state=present). + choices: + - absent + - present + default: present + type: str + type: + description: + - The object type of the AD object. + - This corresponds to the C(objectClass) of the AD object. + - Some examples of a type are C(user), C(computer), C(group), C(subnet), + C(contact), C(container). + - This is required when I(state=present). + type: str +notes: +- This is a generic module used to create and manage any object type in Active + Directory. It will not validate all the correct defaults are set for each + type when it is created. If a type specific module is available to manage + that AD object type it is recommend to use that. +- Some LDAP attributes can have only a single value set while others can have + multiple. Some attributes are also read only and cannot be changed. It is + recommened to look at the schema metadata for an attribute where + C(System-Only) are read only values and C(Is-Single-Value) are attributes + with only 1 value. +- Attempting to set multiple values to a C(Is-Single-Value) attribute results + in undefined behaviour. +extends_documentation_fragment: +- ansible.active_directory.ad_attribute +- ansible.builtin.action_common_attributes +attributes: + check_mode: + support: full + diff_mode: + support: full + platform: + platforms: + - windows +seealso: +- module: ansible.active_directory.domain +- module: ansible.active_directory.domain_controller +- module: ansible.active_directory.object_info +- module: community.windows.win_domain_computer +- module: community.windows.win_domain_group +- module: community.windows.win_domain_user +author: +- Jordan Borean (@jborean93) +""" + +EXAMPLES = r""" +# Use this to get all valid types in a domain environment +# (Get-ADObject -SearchBase (Get-ADRootDSE).subschemaSubentry -Filter * -Properties objectClasses).objectClasses | +# Select-String -Pattern "Name\s+'(\w+)'" | +# ForEach-Object { $_.Matches.Groups[1].Value } | +# Sort-Object + +- name: Create a contact object + ansible.active_directory.object: + name: MyContact + description: My Contact Description + type: contact + state: present + +- name: Rename a contact object + ansible.active_directory.object: + identity: '{{ contact_obj.object_guid }}' + name: RenamedContact + type: contact + state: present + +- name: Move a contact object + ansible.active_directory.object: + identity: '{{ contact_object.object_guid }}' + name: MyContact + path: OU=Contacts,DC=domain,DC=test + type: contact + state: present + +- name: Remove a contact object in default path + ansible.active_directory.object: + name: MyContact + state: absent + +- name: Remove a contact object in custom path + ansible.active_directory.object: + name: MyContact + path: OU=Contacts,DC=domain,DC=test + state: absent + +- name: Remove a contact by identity + ansible.active_directory.object: + identity: '{{ contact_obj.object_guid }}' + state: absent + +- name: Create container object with custom attributes + ansible.active_directory.object: + name: App + attributes: + set: + wWWHomePage: https://ansible.com + type: container + state: present + +- name: Clear attribute of any value + ansible.active_directory.object: + name: App + attributes: + set: + wWWHomePage: ~ + type: container + state: present + +- name: Edit object security with Everyone Allow All access + ansible.active_directory.object: + name: App + attributes: + add: + nTSecurityDescriptor: + type: security_descriptor + value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;WD) + type: container + state: present + +- name: Ensure multiple values are present in attribute + ansible.active_directory.object: + name: App + attributes: + add: + extensionName: + - value 1 + - value 2 + type: container + state: present + +- name: Ensure multiple values are not present in attribute + ansible.active_directory.object: + name: App + attributes: + remove: + extensionName: + - value 1 + - value 3 + type: container + state: present +""" + +RETURN = r""" +object_guid: + description: + - The C(objectGUID) of the AD object that was created, removed, or edited. + - If a new object was created in check mode, a GUID of 0s will be returned. + returned: always + type: str + sample: d84a141f-2b99-4f08-9da0-ed2d26864ba1 +distinguished_name: + description: + - The C(distinguishedName) of the AD object that was created, removed, or edited. + returned: always + type: str + sample: CN=TestUser,CN=Users,DC=domain,DC=test +""" diff --git a/plugins/modules/object_info.ps1 b/plugins/modules/object_info.ps1 index 5539c0e..bec248f 100644 --- a/plugins/modules/object_info.ps1 +++ b/plugins/modules/object_info.ps1 @@ -207,12 +207,19 @@ try { $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 = @() + # Because we ran in a pipeline we can't catch ADIdentityNotFoundException. Instead this scans each InnerException + # to see if it contains ADIdentityNotFoundException. + $exp = $_.Exception + $foundGuids = $null + while ($exp) { + $exp = $exp.InnerException + if ($exp -is [Microsoft.ActiveDirectory.Management.ADIdentityNotFoundException]) { + $foundGuids = @() + break + } } - else { + + if ($null -eq $foundGuids) { # The exception is from the .Invoke() call, compare on the InnerException which was what was actually raised by # the pipeline. $innerException = $_.Exception.InnerException.InnerException diff --git a/plugins/modules/object_info.py b/plugins/modules/object_info.py index 3ca9358..66506b3 100644 --- a/plugins/modules/object_info.py +++ b/plugins/modules/object_info.py @@ -11,11 +11,11 @@ description: - Gather information about multiple Active Directory object(s). requirements: -- ActiveDirectory PowerShell module +- C(ActiveDirectory) PowerShell module options: domain_password: description: - - The password for C(domain_username). + - The password for I(domain_username). type: str domain_server: description: @@ -72,7 +72,7 @@ type: str search_scope: description: - - Specify the scope of when searching for an object in the C(search_base). + - Specify the scope of when searching for an object in the I(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. @@ -87,6 +87,23 @@ - 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. +extends_documentation_fragment: +- ansible.builtin.action_common_attributes +attributes: + check_mode: + support: full + diff_mode: + support: full + platform: + platforms: + - windows +seealso: +- module: ansible.active_directory.domain +- module: ansible.active_directory.domain_controller +- module: ansible.active_directory.object +- module: community.windows.win_domain_computer +- module: community.windows.win_domain_group +- module: community.windows.win_domain_user author: - Jordan Borean (@jborean93) """ diff --git a/tests/integration/targets/object/aliases b/tests/integration/targets/object/aliases new file mode 100644 index 0000000..ccd8a25 --- /dev/null +++ b/tests/integration/targets/object/aliases @@ -0,0 +1,2 @@ +windows +shippable/windows/group1 diff --git a/tests/integration/targets/object/defaults/main.yml b/tests/integration/targets/object/defaults/main.yml new file mode 100644 index 0000000..e557e2d --- /dev/null +++ b/tests/integration/targets/object/defaults/main.yml @@ -0,0 +1,2 @@ +object_name: My, Contact +object_dn: CN=My\, Contact,{{ setup_domain_info.output[0].defaultNamingContext }} diff --git a/tests/integration/targets/object/meta/main.yml b/tests/integration/targets/object/meta/main.yml new file mode 100644 index 0000000..4ce45dc --- /dev/null +++ b/tests/integration/targets/object/meta/main.yml @@ -0,0 +1,2 @@ +dependencies: +- setup_domain diff --git a/tests/integration/targets/object/tasks/main.yml b/tests/integration/targets/object/tasks/main.yml new file mode 100644 index 0000000..a10a393 --- /dev/null +++ b/tests/integration/targets/object/tasks/main.yml @@ -0,0 +1,9 @@ +- block: + - import_tasks: tests.yml + + always: + - name: remove temp object + object: + name: '{{ object_name }}' + identity: '{{ object_identity | default(omit) }}' + state: absent diff --git a/tests/integration/targets/object/tasks/tests.yml b/tests/integration/targets/object/tasks/tests.yml new file mode 100644 index 0000000..ceafbdc --- /dev/null +++ b/tests/integration/targets/object/tasks/tests.yml @@ -0,0 +1,876 @@ +- name: create contact object - check + object: + name: '{{ object_name }}' + type: contact + state: present + register: create_check + check_mode: true + +- name: get result of create contact object - check + object_info: + identity: '{{ create_check.distinguished_name }}' + register: create_check_actual + +- name: assert create contact object - check + assert: + that: + - create_check is changed + - create_check.distinguished_name == object_dn + - create_check.object_guid == '00000000-0000-0000-0000-000000000000' + - create_check_actual.objects == [] + +- name: create contact object + object: + name: '{{ object_name }}' + type: contact + state: present + register: create + +- name: get result of create contact object + object_info: + identity: '{{ create_check.distinguished_name }}' + properties: + - objectClass + register: create_actual + +- name: assert create contact object + assert: + that: + - create is changed + - create_actual.objects | length == 1 + - create_actual.objects[0].ObjectClass == 'contact' + - create.distinguished_name == object_dn + - create.distinguished_name == create_actual.objects[0].DistinguishedName + - create.object_guid == create_actual.objects[0].ObjectGUID + +- set_fact: + object_identity: '{{ create.object_guid }}' + +- name: create contact object - idempotent + object: + name: '{{ object_name }}' + type: contact + state: present + register: create_again + +- name: assert create contact object - idempotent + assert: + that: + - not create_again is changed + - create_again.distinguished_name == create_actual.objects[0].DistinguishedName + - create_again.object_guid == create_actual.objects[0].ObjectGUID + +- name: fail to change type + object: + name: '{{ object_name }}' + state: present + type: failure + register: fail_change_type + failed_when: fail_change_type.msg != "Cannot change object type contact of existing object " ~ object_dn ~ " to failure" + +- name: rename and set display name of object - check + object: + name: My, Contact 2 + identity: '{{ object_identity }}' + display_name: Display Name + state: present + type: contact + register: rename_check + check_mode: true + +- name: get result of create contact object - check + object_info: + identity: '{{ object_identity }}' + properties: + - displayName + - name + register: rename_check_actual + +- name: assert rename and set display name of object - check + assert: + that: + - rename_check is changed + - rename_check.distinguished_name == 'CN=My\, Contact 2,' ~ setup_domain_info.output[0].defaultNamingContext + - rename_check.object_guid == object_identity + - rename_check_actual.objects[0].DisplayName == None + - rename_check_actual.objects[0].DistinguishedName == object_dn + - rename_check_actual.objects[0].Name == object_name + +- name: rename and set display name of object + object: + name: My, Contact 2 + identity: '{{ object_identity }}' + display_name: Display Name + state: present + type: contact + register: rename + +- name: get result of create contact object + object_info: + identity: '{{ object_identity }}' + properties: + - displayName + - name + register: rename_actual + +- name: assert rename and set display name of object + assert: + that: + - rename is changed + - rename.distinguished_name == 'CN=My\, Contact 2,' ~ setup_domain_info.output[0].defaultNamingContext + - rename.object_guid == object_identity + - rename_actual.objects[0].DisplayName == 'Display Name' + - rename_actual.objects[0].DistinguishedName == 'CN=My\, Contact 2,' ~ setup_domain_info.output[0].defaultNamingContext + - rename_actual.objects[0].Name == 'My, Contact 2' + +- name: rename and set display name of object - idempotent + object: + name: My, Contact 2 + identity: '{{ object_identity }}' + display_name: Display Name + state: present + type: contact + register: rename_again + +- name: assert rename and set display name of object - idempotent + assert: + that: + - not rename_again is changed + - rename_again.distinguished_name == 'CN=My\, Contact 2,' ~ setup_domain_info.output[0].defaultNamingContext + - rename_again.object_guid == object_identity + +- name: move and set description - check + object: + name: My, Contact 2 + identity: '{{ object_identity }}' + path: CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + description: My Description + state: present + type: contact + register: move_check + check_mode: true + +- name: move and set description - check + object_info: + identity: '{{ object_identity }}' + properties: + - description + - name + register: move_check_actual + +- name: assert move and set description - check + assert: + that: + - move_check is changed + - move_check.distinguished_name == 'CN=My\, Contact 2,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext + - move_check.object_guid == object_identity + - move_check_actual.objects[0].Description == None + - move_check_actual.objects[0].DistinguishedName == 'CN=My\, Contact 2,' ~ setup_domain_info.output[0].defaultNamingContext + - move_check_actual.objects[0].Name == 'My, Contact 2' + +- name: move and set description + object: + name: My, Contact 2 + identity: '{{ object_identity }}' + path: CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + description: My Description + state: present + type: contact + register: move + +- name: move and set description + object_info: + identity: '{{ object_identity }}' + properties: + - description + - name + register: move_actual + +- name: assert move and set description + assert: + that: + - move is changed + - move.distinguished_name == 'CN=My\, Contact 2,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext + - move.object_guid == object_identity + - move_actual.objects[0].Description == 'My Description' + - move_actual.objects[0].DistinguishedName == 'CN=My\, Contact 2,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext + - move_actual.objects[0].Name == 'My, Contact 2' + +- name: move and set description - idempotent + object: + name: My, Contact 2 + identity: '{{ object_identity }}' + path: CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + description: My Description + state: present + type: contact + register: move_again + +- name: assert move and set description - idempotent + assert: + that: + - not move_again is changed + - move_again.distinguished_name == 'CN=My\, Contact 2,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext + - move_again.object_guid == object_identity + +- name: rename and move - check + object: + name: '{{ object_name }}' + identity: '{{ object_identity }}' + path: '{{ setup_domain_info.output[0].defaultNamingContext }}' + state: present + type: contact + register: rename_and_move_check + check_mode: true + +- name: get result of rename and move - check + object_info: + identity: '{{ object_identity }}' + register: rename_and_move_check_actual + +- name: assert rename and move - check + assert: + that: + - rename_and_move_check is changed + - rename_and_move_check.distinguished_name == object_dn + - rename_and_move_check.object_guid == object_identity + - rename_and_move_check_actual.objects[0].DistinguishedName == 'CN=My\, Contact 2,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext + - rename_and_move_check_actual.objects[0].Name == 'My, Contact 2' + +- name: rename and move + object: + name: '{{ object_name }}' + identity: '{{ object_identity }}' + path: '{{ setup_domain_info.output[0].defaultNamingContext }}' + state: present + type: contact + register: rename_and_move + +- name: get result of rename and move + object_info: + identity: '{{ object_identity }}' + register: rename_and_move_actual + +- name: assert rename and move + assert: + that: + - rename_and_move is changed + - rename_and_move.distinguished_name == object_dn + - rename_and_move.object_guid == object_identity + - rename_and_move_actual.objects[0].DistinguishedName == object_dn + - rename_and_move_actual.objects[0].Name == object_name + +- name: remove object by name - check + object: + name: '{{ object_name }}' + state: absent + register: remove_check + check_mode: true + +- name: get result of remove by name - check + object_info: + identity: '{{ object_identity }}' + register: remove_check_actual + +- name: assert remove object by name - check + assert: + that: + - remove_check is changed + - remove_check.distinguished_name == object_dn + - remove_check.object_guid == object_identity + - remove_check_actual.objects | length == 1 + +- name: remove object by name + object: + name: '{{ object_name }}' + state: absent + register: remove + +- name: get result of remove by name + object_info: + identity: '{{ object_identity }}' + register: remove_actual + +- name: assert remove object by name - check + assert: + that: + - remove is changed + - remove.distinguished_name == object_dn + - remove.object_guid == object_identity + - remove_actual.objects == [] + +- name: remove object by name - idempotent + object: + name: '{{ object_name }}' + state: absent + register: remove_again + +- name: assert remove object by name - check + assert: + that: + - not remove_again is changed + - remove_again.distinguished_name == None + - remove_again.object_guid == None + +- name: create object with custom path, description, and display_name + object: + name: My, Container + description: Test Description + display_name: Display Name + path: CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + state: present + type: container + register: create_custom + +- set_fact: + object_identity: '{{ create_custom.object_guid }}' + +- name: get result of create object with custom path, description, and display_name + object_info: + identity: '{{ object_identity }}' + properties: + - description + - displayName + - objectClass + register: create_custom_actual + +- name: assert create object with custom path, description, and display_name + assert: + that: + - create_custom is changed + - create_custom.distinguished_name == 'CN=My\, Container,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext + - create_custom.object_guid == object_identity + - create_custom_actual.objects[0].Description == 'Test Description' + - create_custom_actual.objects[0].DisplayName == 'Display Name' + - create_custom_actual.objects[0].ObjectClass == 'container' + +- name: create child object in container with attributes + object: + name: Contact + path: CN=My\, Container,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + description: Test Description + display_name: Display Name + attributes: + add: + wWWHomePage: https://ansible.com + extensionName: + - value 1 + - value 2 + type: contact + state: present + register: sub_object + +- name: get result of child object attributes + object_info: + identity: '{{ sub_object.object_guid }}' + properties: + - wWWHomePage + - extensionName + register: sub_object_actual + +- name: assert create child object in container with attributes + assert: + that: + - sub_object is changed + - sub_object.distinguished_name == 'CN=Contact,CN=My\, Container,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext + - sub_object_actual.objects[0].wWWHomePage == 'https://ansible.com' + - sub_object_actual.objects[0].extensionName | length == 2 + - '"value 1" in sub_object_actual.objects[0].extensionName' + - '"value 2" in sub_object_actual.objects[0].extensionName' + +- name: add attribute - check + object: + name: Contact + path: CN=My\, Container,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + type: contact + description: test description + display_name: display name + attributes: + add: + adminDescription: Test description + attributeCertificateAttribute: + - type: bytes + value: Zm9v + - type: bytes + value: YmFy + - type: bytes + value: ZGVm + extensionName: + - value 1 + - value 3 + dsaSignature: + type: bytes + value: Zm9v + state: present + register: add_attr_check + check_mode: true + +- name: get result of add attribute - check + object_info: + identity: '{{ sub_object.object_guid }}' + properties: + - Description + - DisplayName + - adminDescription + - attributeCertificateAttribute + - dsaSignature + - extensionName + register: add_attr_check_actual + +- name: assert add attribute - check + assert: + that: + - add_attr_check is changed + - add_attr_check_actual.objects[0].adminDescription == None + - add_attr_check_actual.objects[0].attributeCertificateAttribute == None + - add_attr_check_actual.objects[0].Description == 'Test Description' + - add_attr_check_actual.objects[0].DisplayName == 'Display Name' + - add_attr_check_actual.objects[0].dsaSignature == None + - add_attr_check_actual.objects[0].extensionName | length == 2 + - '"value 1" in add_attr_check_actual.objects[0].extensionName' + - '"value 2" in add_attr_check_actual.objects[0].extensionName' + +- name: add attribute + object: + name: Contact + path: CN=My\, Container,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + type: contact + description: test description + display_name: display name + attributes: + add: + adminDescription: Test description + attributeCertificateAttribute: + - type: bytes + value: Zm9v + - type: bytes + value: YmFy + - type: bytes + value: ZGVm + extensionName: + - value 1 + - value 3 + dsaSignature: + type: bytes + value: Zm9v + state: present + register: add_attr + +- name: get result of add attribute + object_info: + identity: '{{ sub_object.object_guid }}' + properties: + - description + - DisplayName + - adminDescription + - attributeCertificateAttribute + - dsaSignature + - extensionName + register: add_attr_actual + +- name: assert add attribute + assert: + that: + - add_attr is changed + - add_attr_actual.objects[0].adminDescription == 'Test description' + - add_attr_actual.objects[0].attributeCertificateAttribute | length == 3 + - '"Zm9v" in add_attr_actual.objects[0].attributeCertificateAttribute' + - '"YmFy" in add_attr_actual.objects[0].attributeCertificateAttribute' + - '"ZGVm" in add_attr_actual.objects[0].attributeCertificateAttribute' + - add_attr_actual.objects[0].Description == 'test description' + - add_attr_actual.objects[0].DisplayName == 'display name' + - add_attr_actual.objects[0].dsaSignature == 'Zm9v' + - add_attr_actual.objects[0].extensionName | length == 3 + - '"value 1" in add_attr_actual.objects[0].extensionName' + - '"value 2" in add_attr_actual.objects[0].extensionName' + - '"value 3" in add_attr_actual.objects[0].extensionName' + +- name: add attribute - idempotent + object: + name: Contact + path: CN=My\, Container,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + type: contact + description: test description + display_name: display name + attributes: + add: + adminDescription: Test description + attributeCertificateAttribute: + - type: bytes + value: Zm9v + - type: bytes + value: YmFy + - type: bytes + value: ZGVm + extensionName: + - value 1 + - value 3 + dsaSignature: + type: bytes + value: Zm9v + state: present + register: add_attr_again + +- name: assert add attribute - idempotent + assert: + that: + - not add_attr_again is changed + +- name: remove attribute - check + object: + name: Contact + path: CN=My\, Container,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + type: contact + attributes: + remove: + attributeCertificateAttribute: + - type: bytes + value: YmFy + extensionName: + - value 2 + state: present + register: remove_attr_check + check_mode: true + +- name: get result of remove attribute - check + object_info: + identity: '{{ sub_object.object_guid }}' + properties: + - attributeCertificateAttribute + - extensionName + register: remove_attr_check_actual + +- name: assert remove attribute - check + assert: + that: + - remove_attr_check is changed + - remove_attr_check_actual.objects[0].attributeCertificateAttribute | length == 3 + - '"Zm9v" in remove_attr_check_actual.objects[0].attributeCertificateAttribute' + - '"YmFy" in remove_attr_check_actual.objects[0].attributeCertificateAttribute' + - '"ZGVm" in remove_attr_check_actual.objects[0].attributeCertificateAttribute' + - remove_attr_check_actual.objects[0].extensionName | length == 3 + - '"value 1" in remove_attr_check_actual.objects[0].extensionName' + - '"value 2" in remove_attr_check_actual.objects[0].extensionName' + - '"value 3" in remove_attr_check_actual.objects[0].extensionName' + +- name: remove attribute + object: + name: Contact + path: CN=My\, Container,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + type: contact + attributes: + remove: + attributeCertificateAttribute: + - type: bytes + value: YmFy + extensionName: + - value 2 + state: present + register: remove_attr + +- name: get result of remove attribute + object_info: + identity: '{{ sub_object.object_guid }}' + properties: + - attributeCertificateAttribute + - extensionName + register: remove_attr_actual + +- name: assert remove attribute + assert: + that: + - remove_attr is changed + - remove_attr_actual.objects[0].attributeCertificateAttribute | length == 2 + - '"Zm9v" in remove_attr_actual.objects[0].attributeCertificateAttribute' + - '"ZGVm" in remove_attr_actual.objects[0].attributeCertificateAttribute' + - remove_attr_actual.objects[0].extensionName | length == 2 + - '"value 1" in remove_attr_actual.objects[0].extensionName' + - '"value 3" in remove_attr_actual.objects[0].extensionName' + +- name: remove attribute - idempotent + object: + name: Contact + path: CN=My\, Container,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + type: contact + attributes: + remove: + attributeCertificateAttribute: + - type: bytes + value: YmFy + extensionName: + - value 2 + state: present + register: remove_attr_again + +- name: assert remove attribute - idempotent + assert: + that: + - not remove_attr_again is changed + +- name: set attribute - check + object: + name: Contact + path: CN=My\, Container,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + type: contact + attributes: + set: + adminDescription: Test Description + attributeCertificateAttribute: + - type: bytes + value: Zm9v + - type: bytes + value: YmFy + extensionName: + - value 1 + - value 2 + dsaSignature: + type: bytes + value: YmFy + state: present + register: set_attr_check + check_mode: true + +- name: get result of set attribute - check + object_info: + identity: '{{ sub_object.object_guid }}' + properties: + - description + - DisplayName + - adminDescription + - attributeCertificateAttribute + - dsaSignature + - extensionName + register: set_attr_check_actual + +- name: assert set attribute - check + assert: + that: + - set_attr_check is changed + - set_attr_check_actual.objects[0].adminDescription == 'Test description' + - set_attr_check_actual.objects[0].attributeCertificateAttribute | length == 2 + - '"Zm9v" in set_attr_check_actual.objects[0].attributeCertificateAttribute' + - '"ZGVm" in set_attr_check_actual.objects[0].attributeCertificateAttribute' + - set_attr_check_actual.objects[0].Description == 'test description' + - set_attr_check_actual.objects[0].DisplayName == 'display name' + - set_attr_check_actual.objects[0].dsaSignature == 'Zm9v' + - set_attr_check_actual.objects[0].extensionName | length == 2 + - '"value 1" in set_attr_check_actual.objects[0].extensionName' + - '"value 3" in set_attr_check_actual.objects[0].extensionName' + +- name: set attribute + object: + name: Contact + path: CN=My\, Container,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + type: contact + attributes: + set: + adminDescription: Test Description + attributeCertificateAttribute: + - type: bytes + value: Zm9v + - type: bytes + value: YmFy + extensionName: + - value 1 + - value 2 + dsaSignature: + type: bytes + value: YmFy + state: present + register: set_attr + +- name: get result of set attribute + object_info: + identity: '{{ sub_object.object_guid }}' + properties: + - description + - DisplayName + - adminDescription + - attributeCertificateAttribute + - dsaSignature + - extensionName + register: set_attr_actual + +- name: assert set attribute + assert: + that: + - set_attr is changed + - set_attr_actual.objects[0].adminDescription == 'Test Description' + - set_attr_actual.objects[0].attributeCertificateAttribute | length == 2 + - '"Zm9v" in set_attr_actual.objects[0].attributeCertificateAttribute' + - '"YmFy" in set_attr_actual.objects[0].attributeCertificateAttribute' + - set_attr_actual.objects[0].Description == 'test description' + - set_attr_actual.objects[0].DisplayName == 'display name' + - set_attr_actual.objects[0].dsaSignature == 'YmFy' + - set_attr_actual.objects[0].extensionName | length == 2 + - '"value 1" in set_attr_actual.objects[0].extensionName' + - '"value 2" in set_attr_actual.objects[0].extensionName' + +- name: set attribute - idempotent + object: + name: Contact + path: CN=My\, Container,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + type: contact + attributes: + set: + adminDescription: Test Description + attributeCertificateAttribute: + - type: bytes + value: Zm9v + - type: bytes + value: YmFy + extensionName: + - value 1 + - value 2 + dsaSignature: + type: bytes + value: YmFy + state: present + register: set_attr_again + +- name: assert set attribute - idempotent + assert: + that: + - not set_attr_again is changed + + +- name: clear attribute - check + object: + name: Contact + path: CN=My\, Container,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + type: contact + attributes: + set: + adminDescription: + attributeCertificateAttribute: [] + extensionName: + dsaSignature: [] + state: present + register: clear_attr_check + check_mode: true + +- name: get result of clear attribute - check + object_info: + identity: '{{ sub_object.object_guid }}' + properties: + - description + - DisplayName + - adminDescription + - attributeCertificateAttribute + - dsaSignature + - extensionName + register: clear_attr_check_actual + +- name: assert clear attribute - check + assert: + that: + - clear_attr_check is changed + - clear_attr_check_actual.objects[0].adminDescription == 'Test Description' + - clear_attr_check_actual.objects[0].attributeCertificateAttribute | length == 2 + - '"Zm9v" in clear_attr_check_actual.objects[0].attributeCertificateAttribute' + - '"YmFy" in clear_attr_check_actual.objects[0].attributeCertificateAttribute' + - clear_attr_check_actual.objects[0].Description == 'test description' + - clear_attr_check_actual.objects[0].DisplayName == 'display name' + - clear_attr_check_actual.objects[0].dsaSignature == 'YmFy' + - clear_attr_check_actual.objects[0].extensionName | length == 2 + - '"value 1" in clear_attr_check_actual.objects[0].extensionName' + - '"value 2" in clear_attr_check_actual.objects[0].extensionName' + +- name: clear attribute + object: + name: Contact + path: CN=My\, Container,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + type: contact + attributes: + set: + adminDescription: + attributeCertificateAttribute: [] + extensionName: + dsaSignature: [] + state: present + register: clear_attr + +- name: get result of clear attribute + object_info: + identity: '{{ sub_object.object_guid }}' + properties: + - description + - DisplayName + - adminDescription + - attributeCertificateAttribute + - dsaSignature + - extensionName + register: clear_attr_actual + +- name: assert clear attribute + assert: + that: + - clear_attr is changed + - clear_attr_actual.objects[0].adminDescription == None + - clear_attr_actual.objects[0].attributeCertificateAttribute == None + - clear_attr_actual.objects[0].dsaSignature == None + - clear_attr_actual.objects[0].extensionName == None + +- name: clear attribute - idempotent + object: + name: Contact + path: CN=My\, Container,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + type: contact + attributes: + set: + adminDescription: + attributeCertificateAttribute: [] + extensionName: + dsaSignature: [] + state: present + register: clear_attr_again + +- name: assert clear attribute - idempotent + assert: + that: + - not clear_attr_again is changed + +- name: set security descriptor attr + object: + name: Contact + path: CN=My\, Container,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + type: contact + attributes: + set: + nTSecurityDescriptor: + type: security_descriptor + value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;WD) + state: present + register: set_sd + +- name: get result of set security descriptor attr + object_info: + identity: '{{ sub_object.object_guid }}' + properties: + - nTSecurityDescriptor + register: set_sd_actual + +- name: assert set security descriptor attr + assert: + that: + - set_sd is changed + # It might change the owner, we only care about the DACL bit + - set_sd_actual.objects[0].nTSecurityDescriptor.endswith('D:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;WD)') + +- name: set security descriptor attr - idempotent + object: + name: Contact + path: CN=My\, Container,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + type: contact + attributes: + set: + nTSecurityDescriptor: + type: security_descriptor + value: '{{ set_sd_actual.objects[0].nTSecurityDescriptor }}' + state: present + register: set_sd_again + +- name: assert set security descriptor attr - idemnpotent + assert: + that: + - not set_sd_again is changed diff --git a/tests/integration/targets/setup_domain/tasks/main.yml b/tests/integration/targets/setup_domain/tasks/main.yml index 122c718..3376208 100644 --- a/tests/integration/targets/setup_domain/tasks/main.yml +++ b/tests/integration/targets/setup_domain/tasks/main.yml @@ -49,6 +49,7 @@ } } $attempts + register: setup_domain_info become: yes become_method: runas become_user: SYSTEM