diff options
Diffstat (limited to 'ansible_collections/microsoft/ad')
62 files changed, 1615 insertions, 858 deletions
diff --git a/ansible_collections/microsoft/ad/.ansible-lint b/ansible_collections/microsoft/ad/.ansible-lint new file mode 100644 index 000000000..cad3b8e14 --- /dev/null +++ b/ansible_collections/microsoft/ad/.ansible-lint @@ -0,0 +1,11 @@ +profile: production + +exclude_paths: + # This file is autogenerated so we cannot change the format + - changelogs/changelog.yaml + # Incorrect error around supported ansible versions in this file + - meta/runtime.yml + - tests/integration/ + # We skip a rule that has to be skipped + - tests/sanity/ignore-*.txt + - tests/unit/ diff --git a/ansible_collections/microsoft/ad/.azure-pipelines/azure-pipelines.yml b/ansible_collections/microsoft/ad/.azure-pipelines/azure-pipelines.yml index 18ecd1b75..5db3a5531 100644 --- a/ansible_collections/microsoft/ad/.azure-pipelines/azure-pipelines.yml +++ b/ansible_collections/microsoft/ad/.azure-pipelines/azure-pipelines.yml @@ -36,7 +36,7 @@ variables: resources: containers: - container: default - image: quay.io/ansible/azure-pipelines-test-container:3.0.0 + image: quay.io/ansible/azure-pipelines-test-container:4.0.1 pool: Standard @@ -44,89 +44,77 @@ stages: - stage: Dependencies displayName: Dependencies jobs: - - job: dep_download - displayName: Download Dependencies - pool: - vmImage: ubuntu-latest - steps: - - checkout: self - fetchDepth: 1 - - task: UsePythonVersion@0 - inputs: - versionSpec: '3.10' - - bash: python -m pip install ansible-core - displayName: Install Ansible - - bash: ansible-galaxy collection install -r tests/requirements.yml -p collections - displayName: Install collection requirements - - task: PublishPipelineArtifact@1 - inputs: - targetPath: collections - artifactName: CollectionRequirements + - job: dep_download + displayName: Download Dependencies + pool: + vmImage: ubuntu-latest + steps: + - checkout: self + fetchDepth: 1 + - task: UsePythonVersion@0 + inputs: + versionSpec: "3.10" + - bash: python -m pip install ansible-core + displayName: Install Ansible + - bash: ansible-galaxy collection install -r tests/requirements.yml -p collections + displayName: Install collection requirements + - task: PublishPipelineArtifact@1 + inputs: + targetPath: collections + artifactName: CollectionRequirements - stage: Ansible_devel displayName: Ansible devel dependsOn: - - Dependencies + - Dependencies jobs: - template: templates/matrix.yml parameters: - nameFormat: '{0}' - testFormat: 'devel/{0}' + nameFormat: "{0}" + testFormat: "devel/{0}" targets: - name: Sanity test: sanity - name: Units test: units - - stage: Ansible_2_15 - displayName: Ansible 2.15 + - stage: Ansible_2_16 + displayName: Ansible 2.16 dependsOn: - - Dependencies + - Dependencies jobs: - template: templates/matrix.yml parameters: - nameFormat: '{0}' - testFormat: '2.15/{0}' + nameFormat: "{0}" + testFormat: "2.16/{0}" targets: - name: Sanity test: sanity - name: Units test: units - - stage: Ansible_2_14 - displayName: Ansible 2.14 - dependsOn: - - Dependencies - jobs: - - template: templates/matrix.yml - parameters: - nameFormat: '{0}' - testFormat: '2.14/{0}' - targets: - - name: Sanity - test: sanity - - name: Units - test: units - - stage: Ansible_2_13 - displayName: Ansible 2.13 + - name: Lint + test: lint + - stage: Ansible_2_15 + displayName: Ansible 2.15 dependsOn: - - Dependencies + - Dependencies jobs: - template: templates/matrix.yml parameters: - nameFormat: '{0}' - testFormat: '2.13/{0}' + nameFormat: "{0}" + testFormat: "2.15/{0}" targets: - name: Sanity test: sanity - name: Units test: units - - stage: Ansible_2_12 - displayName: Ansible 2.12 + - stage: Ansible_2_14 + displayName: Ansible 2.14 dependsOn: - - Dependencies + - Dependencies jobs: - template: templates/matrix.yml parameters: - nameFormat: '{0}' - testFormat: '2.12/{0}' + nameFormat: "{0}" + testFormat: "2.14/{0}" targets: - name: Sanity test: sanity @@ -135,7 +123,7 @@ stages: - stage: Windows displayName: Windows dependsOn: - - Dependencies + - Dependencies jobs: - template: templates/matrix.yml parameters: @@ -151,10 +139,9 @@ stages: condition: succeededOrFailed() dependsOn: - Ansible_devel + - Ansible_2_16 - Ansible_2_15 - Ansible_2_14 - - Ansible_2_13 - - Ansible_2_12 - Windows jobs: - template: templates/coverage.yml diff --git a/ansible_collections/microsoft/ad/.github/workflows/docs-pr.yml b/ansible_collections/microsoft/ad/.github/workflows/docs-pr.yml index 3b89bc6a7..cc0320a93 100644 --- a/ansible_collections/microsoft/ad/.github/workflows/docs-pr.yml +++ b/ansible_collections/microsoft/ad/.github/workflows/docs-pr.yml @@ -2,7 +2,7 @@ name: Collection Docs concurrency: group: docs-pr-${{ github.head_ref }} cancel-in-progress: true -on: +"on": pull_request_target: types: [opened, synchronize, reopened, closed] @@ -44,7 +44,7 @@ jobs: - name: PR comment uses: ansible-community/github-docs-build/actions/ansible-docs-build-comment@main with: - body-includes: '## Docs Build' + body-includes: "## Docs Build" reactions: heart action: ${{ needs.build-docs.outputs.changed != 'true' && 'remove' || '' }} on-closed-action: remove diff --git a/ansible_collections/microsoft/ad/.github/workflows/docs-push.yml b/ansible_collections/microsoft/ad/.github/workflows/docs-push.yml index b9cef6529..68e3149a3 100644 --- a/ansible_collections/microsoft/ad/.github/workflows/docs-push.yml +++ b/ansible_collections/microsoft/ad/.github/workflows/docs-push.yml @@ -2,14 +2,14 @@ name: Collection Docs concurrency: group: docs-push-${{ github.sha }} cancel-in-progress: true -on: +"on": push: branches: - main tags: - - '*' + - "*" schedule: - - cron: '0 13 * * *' + - cron: "0 13 * * *" jobs: build-docs: diff --git a/ansible_collections/microsoft/ad/CHANGELOG.rst b/ansible_collections/microsoft/ad/CHANGELOG.rst index 1cd66e250..53c63d173 100644 --- a/ansible_collections/microsoft/ad/CHANGELOG.rst +++ b/ansible_collections/microsoft/ad/CHANGELOG.rst @@ -4,6 +4,86 @@ Ansible Microsoft Active Directory Release Notes .. contents:: Topics +v1.5.0 +====== + +Release Summary +--------------- + +Release summary for v1.5.0 + +Minor Changes +------------- + +- Added ``group/microsoft.ad.domain`` module defaults group for the ``computer``, ``group``, ``object_info``, ``object``, ``ou``, and ``user`` module. Users can use this defaults group to set common connection options for these modules such as the ``domain_server``, ``domain_username``, and ``domain_password`` options. +- Added support for Jinja2 templating in ldap inventory. + +Bugfixes +-------- + +- microsoft.ad.group - Support membership lookup of groups that are longer than 20 characters long +- microsoft.ad.membership - Add helpful hint when the failure was due to a missing/invalid ``domain_ou_path`` - https://github.com/ansible-collections/microsoft.ad/issues/88 + +New Plugins +----------- + +Filter +~~~~~~ + +- dn_escape - Escape an LDAP DistinguishedName value string. +- parse_dn - Parses an LDAP DistinguishedName string into an object. + +v1.4.1 +====== + +Release Summary +--------------- + +Release summary for v1.4.1 + +Bugfixes +-------- + +- debug_ldap_client - handle failures when attempting to get the krb5 context and default CCache rather than fail with a traceback + +v1.4.0 +====== + +Release Summary +--------------- + +Prepare for v1.4.0 release + +Minor Changes +------------- + +- Make ``name`` an optional parameter for the AD modules. Either ``name`` or ``identity`` needs to be set with their respective behaviours. If creating a new AD user and only ``identity`` is set, that will be the value used for the name of the object. +- Set minimum supported Ansible version to 2.14 to align with the versions still supported by Ansible. +- object_info - Add ActiveDirectory module import + +v1.3.0 +====== + +Release Summary +--------------- + +release summary for v1.3.0 + +Minor Changes +------------- + +- AD objects will no longer be moved to the default AD path for their type if no ``path`` was specified. Use the value ``microsoft.ad.default_path`` to explicitly set the path to the default path if that behaviour is desired. +- microsoft.ad.ldap - Added the option ``filter_without_computer`` to not add the AND clause ``objectClass=computer`` to the final filter used - https://github.com/ansible-collections/microsoft.ad/issues/55 + +Bugfixes +-------- + +- Added the missing dependency ``dpapi-ng`` to Ansible Execution Environments requirements file for LAPS decryption support +- Ensure renaming and moving an object will be done with the ``domain_server`` and ``domain_username`` credentials specified - https://github.com/ansible-collections/microsoft.ad/issues/54 +- Fix up ``protect_from_deletion`` when creating new AD objects - https://github.com/ansible-collections/microsoft.ad/issues/47 +- Fix up date_time attribute comparisons to be idempotent - https://github.com/ansible-collections/microsoft.ad/issues/57 +- microsoft.ad.user - Ensure the ``spn`` diff after key is ``spn`` and not ``kerberos_encryption_types`` +- microsoft.ad.user - treat an expired account as a password that needs to be changed v1.2.0 ====== @@ -40,7 +120,6 @@ Release Summary This release includes the new ``microsoft.ad.ldap`` inventory plugin which can be used to generate an Ansible inventory from an LDAP/AD source. - Bugfixes -------- diff --git a/ansible_collections/microsoft/ad/FILES.json b/ansible_collections/microsoft/ad/FILES.json index 872564d57..0dd2d37a2 100644 --- a/ansible_collections/microsoft/ad/FILES.json +++ b/ansible_collections/microsoft/ad/FILES.json @@ -102,7 +102,7 @@ "name": ".azure-pipelines/azure-pipelines.yml", "ftype": "file", "chksum_type": "sha256", - "chksum_sha256": "7399f9af28ee502b0427a620c8a10226bd48e7dc9fd30b861496e4ec348ea630", + "chksum_sha256": "bd6f3baf66ceaa437b9b6a25f26a4169acb7c08fbacc02b0e7550825ec6d1ec4", "format": 1 }, { @@ -123,14 +123,14 @@ "name": ".github/workflows/docs-pr.yml", "ftype": "file", "chksum_type": "sha256", - "chksum_sha256": "391d92c0465a8e47effc66a9cf324f29d0c4e2a930f88a51e3b59f293f3bd5af", + "chksum_sha256": "e04671e2fc8ead695e9c803b7bc515653a265704d2f15b1c42b3294ab022e202", "format": 1 }, { "name": ".github/workflows/docs-push.yml", "ftype": "file", "chksum_type": "sha256", - "chksum_sha256": "88c9ec3537c09586daa9a9a0a3e8492183583e0027b2b30bec8479976fc7a336", + "chksum_sha256": "69431bf4445f0f00a38b41e802026d147d71535c788eca4a2ae6cfb93a39d188", "format": 1 }, { @@ -158,14 +158,14 @@ "name": "changelogs/changelog.yaml", "ftype": "file", "chksum_type": "sha256", - "chksum_sha256": "befa64184037082f1e72a8b49a73d517b83de70d910e77d52223c808df295dee", + "chksum_sha256": "5a0d5c07afae82f7e211f5f8bf93c2d0a8ffc3cc594bd93f3bc5efc855546cff", "format": 1 }, { "name": "changelogs/config.yaml", "ftype": "file", "chksum_type": "sha256", - "chksum_sha256": "9295eb29396e7bddcfbbf9a29d55e74967d93d81e1746acffff3a125ca673f0f", + "chksum_sha256": "4249dbb0bbd72c96eab8ad726ea860106155adc800546eae3094f9b41a5c5c91", "format": 1 }, { @@ -207,7 +207,7 @@ "name": "docs/docsite/rst/guide_ldap_inventory.rst", "ftype": "file", "chksum_type": "sha256", - "chksum_sha256": "6b909ba926c43f767ff77a5f47dc6a3bc942d8ecfcda07ddfb4f53fdf1d1a4e6", + "chksum_sha256": "08fa36b7ba2b14757c265a69fb12a66492a265d0a58886dc44a46babe0013bb0", "format": 1 }, { @@ -228,14 +228,14 @@ "name": "docs/docsite/extra-docs.yml", "ftype": "file", "chksum_type": "sha256", - "chksum_sha256": "66b108d94b81a74952e688b940e53c14f7b8b8576ae03e2eca26b0a534398a17", + "chksum_sha256": "3ba33d70d90d838dee9bb1644d9cabbe184450979613b031507dc1fb11c2bdd8", "format": 1 }, { "name": "docs/docsite/links.yml", "ftype": "file", "chksum_type": "sha256", - "chksum_sha256": "7ff3fa1af7839cec431231c6c99a99b3f26abb71fd8274be688637d1df900e88", + "chksum_sha256": "9804f88675a31b43075cb05258c9c23dac9ffaf560c06661b4e6bbf08e0fe649", "format": 1 }, { @@ -249,7 +249,7 @@ "name": "meta/runtime.yml", "ftype": "file", "chksum_type": "sha256", - "chksum_sha256": "630851c61449d7c47813d060147d38bed3d3bc3737c32cede676adef5e68cfb6", + "chksum_sha256": "62732d6aedd5c18f0950231696d9523920405b725ddafa3ff07d7d888c3317bf", "format": 1 }, { @@ -277,7 +277,7 @@ "name": "plugins/action/debug_ldap_client.py", "ftype": "file", "chksum_type": "sha256", - "chksum_sha256": "1688b0ec07534b5afd3ef63991d32ffe19ddfb712f40048ad28d6814a0c22b7c", + "chksum_sha256": "1c20c3a1192ea9023986143ea5cc5da5d6902e334ee00d884cd328bba2144a71", "format": 1 }, { @@ -312,14 +312,14 @@ "name": "plugins/doc_fragments/ad_object.py", "ftype": "file", "chksum_type": "sha256", - "chksum_sha256": "b1c50c545cbd6c458b8973bfd48d8ee5e3f6852544d54d900b12557603f4fe88", + "chksum_sha256": "6296c1c278de0d5fd4af66393e7e074e9f57b73788888b74856f28a46b1852c8", "format": 1 }, { "name": "plugins/doc_fragments/ldap_connection.py", "ftype": "file", "chksum_type": "sha256", - "chksum_sha256": "a75d7898bffabee8ee47c0bf9a350cd1b3967f8fe866fa5e61f7ce91e36b198d", + "chksum_sha256": "70c3cef5f7c2a102ab915450e274484939a520a640b82f6d43d9f1ff47b3af6b", "format": 1 }, { @@ -333,28 +333,42 @@ "name": "plugins/filter/as_datetime.yml", "ftype": "file", "chksum_type": "sha256", - "chksum_sha256": "f8dccb8976ae2accdbd2069278deedae239bfda4202d86740d2f686dfb5a03f1", + "chksum_sha256": "99b565758aa019dfc5fb4efb09e1b73358720efdba6a9bc8f59a5eecdfba0340", "format": 1 }, { "name": "plugins/filter/as_guid.yml", "ftype": "file", "chksum_type": "sha256", - "chksum_sha256": "ebffb3dd6af47aa0c718e040a2409b8683a20976d5fff127763fbc49890138f4", + "chksum_sha256": "fa28486f9320cff99261b2b00312a35f9ae08ba6b2f602503117a95b2fe08431", "format": 1 }, { "name": "plugins/filter/as_sid.yml", "ftype": "file", "chksum_type": "sha256", - "chksum_sha256": "3f9cc11cd5913ef72931ea2a99c5ed6c8201a01c36c0711c3560e292d7a47b66", + "chksum_sha256": "c356f4025ddf811474d3cd65470f891ecb0511771e0a8c11201b0d8ec14d9a14", + "format": 1 + }, + { + "name": "plugins/filter/dn_escape.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "60bbfd6a249d938eb257b77eea7f086846769b4b2713b3781b4039cadcca4cbb", "format": 1 }, { "name": "plugins/filter/ldap_converters.py", "ftype": "file", "chksum_type": "sha256", - "chksum_sha256": "d7b6b285070d2f08396625bebca108d2b7b37589f0fee1e19cf29f5b91976637", + "chksum_sha256": "3170f53a8144a99e79892d241cb2820db8b4c12b837983d753aa6101c01a6a51", + "format": 1 + }, + { + "name": "plugins/filter/parse_dn.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "106c9f7e848aaad811a88f5cb09cc01d25c3eb248463bd817c0a757f3b88abab", "format": 1 }, { @@ -375,7 +389,7 @@ "name": "plugins/inventory/ldap.py", "ftype": "file", "chksum_type": "sha256", - "chksum_sha256": "4c66b492ae0f756b8072b735a504d0fe86a4bde9e6643263905b373d964a83f7", + "chksum_sha256": "e09952907a3409d3e3730362b2d62d3321b13096f9206ddd10b5f9d0413dc17f", "format": 1 }, { @@ -389,7 +403,7 @@ "name": "plugins/module_utils/_ADObject.psm1", "ftype": "file", "chksum_type": "sha256", - "chksum_sha256": "907b6fddd20ac29beb5da47804bc36390a28c96aaf21d7dd234b0b427a4f4d5c", + "chksum_sha256": "a8e86078dfa4a138807bf4b9d5a7820d017ece515d6dce44800e273f7079260e", "format": 1 }, { @@ -410,7 +424,7 @@ "name": "plugins/modules/computer.py", "ftype": "file", "chksum_type": "sha256", - "chksum_sha256": "e80c917f2d27024de019aab613539e06393c2b091fc28d4d6f8d8ff4d2f2fc12", + "chksum_sha256": "8072a01ef9ffe97d05a2249c867fd0623cceb2514b436fb32e6bc74fea7c4019", "format": 1 }, { @@ -431,7 +445,7 @@ "name": "plugins/modules/domain.py", "ftype": "file", "chksum_type": "sha256", - "chksum_sha256": "e9d147e546b0106819f8701a8a042a16a90f096230bb3e3b45c2c7897427fa06", + "chksum_sha256": "711592202ac4384d826a4a246fde4f168699e9fdc6bd52a5a54a3e482fc6ee8f", "format": 1 }, { @@ -445,7 +459,7 @@ "name": "plugins/modules/domain_controller.py", "ftype": "file", "chksum_type": "sha256", - "chksum_sha256": "4ca6e453049d7aa6db5c6308c9ca27776643f79eebe7c25acb2483ccd09d99e9", + "chksum_sha256": "6d1779d52492dc68599a205be7518a84388b6f83322caf23daf4708989cb62f7", "format": 1 }, { @@ -459,21 +473,21 @@ "name": "plugins/modules/group.py", "ftype": "file", "chksum_type": "sha256", - "chksum_sha256": "0d5798ef2f80469552235dc54c41425fa1d39f3aea8def7eb73b8983ec75e1bb", + "chksum_sha256": "ccb8945f218faace36c9fa29bc0180a0e43f35a8ac01015b133e460db82db21c", "format": 1 }, { "name": "plugins/modules/membership.ps1", "ftype": "file", "chksum_type": "sha256", - "chksum_sha256": "338694bd0ef4b7fb67f57c53f5fbfcacdb8ff15cda7a0ecdb60840b1dfed427a", + "chksum_sha256": "acff85cf60f65e36759593d6dc32dd9cdaa8e781bad4ffdce3673fa5a7c3442f", "format": 1 }, { "name": "plugins/modules/membership.py", "ftype": "file", "chksum_type": "sha256", - "chksum_sha256": "69f0ff59fcbc0b522cd92a603ba7cf6f7e40367d2a85bbfb9091951f5b332969", + "chksum_sha256": "0b11e8b437989e371d5b396eb86cbcf2c010a34c0d2510845623e5b74a134453", "format": 1 }, { @@ -487,21 +501,21 @@ "name": "plugins/modules/object.py", "ftype": "file", "chksum_type": "sha256", - "chksum_sha256": "339532821d58909eba0a0e3282523c56f84daea36859a4c821f52cf667188deb", + "chksum_sha256": "4384e7040a815bea61ad11a19a0b65015f418228e980062347a19531012934b7", "format": 1 }, { "name": "plugins/modules/object_info.ps1", "ftype": "file", "chksum_type": "sha256", - "chksum_sha256": "befcb40f361de744801e9d13e00c54db5c7a4fd89737c1739523dd50eb17deeb", + "chksum_sha256": "d5d42e4496db19f79900a941974ccc25d86540f9a4b19c23f2424fb6c4795d90", "format": 1 }, { "name": "plugins/modules/object_info.py", "ftype": "file", "chksum_type": "sha256", - "chksum_sha256": "e92106b16dad378e7a3f05d4ab069fca10fe13c06c70ba3fdf75b5f5e9cbcfab", + "chksum_sha256": "403a343f8c36ec12fb991e570aa99b5e58a74329f6e346618d8de557915445ea", "format": 1 }, { @@ -515,7 +529,7 @@ "name": "plugins/modules/offline_join.py", "ftype": "file", "chksum_type": "sha256", - "chksum_sha256": "626c3ae19c6e13a972a48487f32a96dcacdfe29d9da43dec0263d6e6576cc224", + "chksum_sha256": "be130ac527a1b6bd69d8eade49e4bb5b3ffbd866b17bbbec332fc641f87af736", "format": 1 }, { @@ -529,21 +543,21 @@ "name": "plugins/modules/ou.py", "ftype": "file", "chksum_type": "sha256", - "chksum_sha256": "334d42e543c9b9c917acbdb626474aa16d323ef6dc91b75b502b06441c87b5eb", + "chksum_sha256": "96b3ce4a4ade31daa6aae80829dab807f513ce0af6728fd3d954866be516b266", "format": 1 }, { "name": "plugins/modules/user.ps1", "ftype": "file", "chksum_type": "sha256", - "chksum_sha256": "63ffbeed02bfea6595f388ef80f4ddb85df345075a6eaf0842df3c8bf618cb08", + "chksum_sha256": "c4c651c30ba1f15b85b6f76f7c2048f6493a1298b8a958a39e5623e16d0f505e", "format": 1 }, { "name": "plugins/modules/user.py", "ftype": "file", "chksum_type": "sha256", - "chksum_sha256": "945c8482ca609248030b2c6785564f46b8be956956686091470991645bf86395", + "chksum_sha256": "cd159582daa2ed5b7aeeda556a7218529ef60965d6e6fb657addc9b587dbc62a", "format": 1 }, { @@ -690,7 +704,7 @@ "name": "tests/integration/targets/computer/tasks/tests.yml", "ftype": "file", "chksum_type": "sha256", - "chksum_sha256": "7b6720028a85a9a96681ee25cb42546a3a3949e752c08153cd7cad349207b589", + "chksum_sha256": "9a0987642dce4687f66cba03796f9710be7a2048d54353a274e2edf2d13245ae", "format": 1 }, { @@ -886,7 +900,7 @@ "name": "tests/integration/targets/group/tasks/tests.yml", "ftype": "file", "chksum_type": "sha256", - "chksum_sha256": "e9a9d54179215e7639c947d075997844a0eaf1c09eb1de56ae94dc1f38c3ba88", + "chksum_sha256": "508aa71de34afe93bc3d68f856e5b66fc43decbc9b879d346d0be60fc7de6a30", "format": 1 }, { @@ -984,7 +998,7 @@ "name": "tests/integration/targets/inventory_ldap/roles/test/tasks/main.yml", "ftype": "file", "chksum_type": "sha256", - "chksum_sha256": "55f188a5f6be5795223b7457054a2bae83449e65d723148807d45a8cdfc39366", + "chksum_sha256": "5daf0e5ad00564782b9356d997fc40f74cf8de0bf8143f659ae4c32105a9fcec", "format": 1 }, { @@ -1026,7 +1040,7 @@ "name": "tests/integration/targets/membership/tasks/main.yml", "ftype": "file", "chksum_type": "sha256", - "chksum_sha256": "13948a7fc5cdb40bbb3bd8ffddfc7b4f2cb8a69334ff7c81bef8bcc138904684", + "chksum_sha256": "ed9037a9950eccef2189e91c0aab66ab2edf5f128c8cc941c150129b1b8f4fca", "format": 1 }, { @@ -1054,7 +1068,7 @@ "name": "tests/integration/targets/membership/ansible.cfg", "ftype": "file", "chksum_type": "sha256", - "chksum_sha256": "a5afe6b77e8ad47ec4f53c1c38e7fee337f993a92256b1283cf4cdd51aea7567", + "chksum_sha256": "387e911ebbb0c2c393436d806b2d23135080898417d406d2a54a054db7a5427b", "format": 1 }, { @@ -1131,7 +1145,7 @@ "name": "tests/integration/targets/object/tasks/tests.yml", "ftype": "file", "chksum_type": "sha256", - "chksum_sha256": "b462b1e4acdd0d3f2bc0edf8e3144222e6c7a329f94cd0ceded45146f78808a8", + "chksum_sha256": "1dca77890b6d93b7c1c50625eba4b9f8b2fd2e89e9e75ea072f2afbdcc2f7e96", "format": 1 }, { @@ -1376,7 +1390,7 @@ "name": "tests/integration/targets/user/tasks/tests.yml", "ftype": "file", "chksum_type": "sha256", - "chksum_sha256": "1d07f872d4a225641a65b58c26a14aa936f77138eff1c8ad0b5a833861282e1a", + "chksum_sha256": "7e74639819afa56c4a630f75600156f3a411b58bb615566f33b905f84baee967", "format": 1 }, { @@ -1401,20 +1415,6 @@ "format": 1 }, { - "name": "tests/sanity/ignore-2.12.txt", - "ftype": "file", - "chksum_type": "sha256", - "chksum_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - "format": 1 - }, - { - "name": "tests/sanity/ignore-2.13.txt", - "ftype": "file", - "chksum_type": "sha256", - "chksum_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - "format": 1 - }, - { "name": "tests/sanity/ignore-2.14.txt", "ftype": "file", "chksum_type": "sha256", @@ -1436,104 +1436,20 @@ "format": 1 }, { - "name": "tests/unit", - "ftype": "dir", - "chksum_type": null, - "chksum_sha256": null, - "format": 1 - }, - { - "name": "tests/unit/compat", - "ftype": "dir", - "chksum_type": null, - "chksum_sha256": null, - "format": 1 - }, - { - "name": "tests/unit/compat/__init__.py", - "ftype": "file", - "chksum_type": "sha256", - "chksum_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - "format": 1 - }, - { - "name": "tests/unit/compat/mock.py", - "ftype": "file", - "chksum_type": "sha256", - "chksum_sha256": "a4c95e1616f09fd8cecc228b798dc4a15936d96764e3d9ccdfd7a0d65bec38e4", - "format": 1 - }, - { - "name": "tests/unit/mock", - "ftype": "dir", - "chksum_type": null, - "chksum_sha256": null, - "format": 1 - }, - { - "name": "tests/unit/mock/__init__.py", + "name": "tests/sanity/ignore-2.17.txt", "ftype": "file", "chksum_type": "sha256", "chksum_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "format": 1 }, { - "name": "tests/unit/mock/loader.py", - "ftype": "file", - "chksum_type": "sha256", - "chksum_sha256": "3b51ec0d45347a3568300e50f688998007e346f052bd2e961c2ac6d13f7cee4d", - "format": 1 - }, - { - "name": "tests/unit/mock/path.py", - "ftype": "file", - "chksum_type": "sha256", - "chksum_sha256": "f7cd5d873578e209b53bd460bb23dbb86b42efe2a0a922f6ee98f0fa484ad5a4", - "format": 1 - }, - { - "name": "tests/unit/mock/procenv.py", - "ftype": "file", - "chksum_type": "sha256", - "chksum_sha256": "4ab36f9027750f01ee3886998538b5451f7378b09af194539bea233db3406976", - "format": 1 - }, - { - "name": "tests/unit/mock/vault_helper.py", - "ftype": "file", - "chksum_type": "sha256", - "chksum_sha256": "4535613601c419f7d20f0c21e638dabccf69b4a7fac99d5f6f9b81d1519dafd6", - "format": 1 - }, - { - "name": "tests/unit/mock/yaml_helper.py", - "ftype": "file", - "chksum_type": "sha256", - "chksum_sha256": "fada9f3506c951e21c60c2a0e68d3cdf3cadd71c8858b2d14a55c4b778f10983", - "format": 1 - }, - { - "name": "tests/unit/modules", + "name": "tests/unit", "ftype": "dir", "chksum_type": null, "chksum_sha256": null, "format": 1 }, { - "name": "tests/unit/modules/__init__.py", - "ftype": "file", - "chksum_type": "sha256", - "chksum_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - "format": 1 - }, - { - "name": "tests/unit/modules/utils.py", - "ftype": "file", - "chksum_type": "sha256", - "chksum_sha256": "4101c5ff6e1aa27810f7897d80858d99a8780c44dd52e1710b89b9078ddf11eb", - "format": 1 - }, - { "name": "tests/unit/plugins", "ftype": "dir", "chksum_type": null, @@ -1558,7 +1474,7 @@ "name": "tests/unit/plugins/filter/test_ldap_converters.py", "ftype": "file", "chksum_type": "sha256", - "chksum_sha256": "38a2a3ea0ebbfc3239d208f4311344fb010549cd3a7654efe3909d1d64197e65", + "chksum_sha256": "ff9908431cafe902262088cbb9939bb30debbe6d1e7b677062214dfb9cda57bf", "format": 1 }, { @@ -1653,6 +1569,13 @@ "format": 1 }, { + "name": "tests/utils/shippable/lint.sh", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "f945142c1487de0996b9bc46bc18c82d5c0b6c9470f0f48b87634c2e4b0eabf5", + "format": 1 + }, + { "name": "tests/utils/shippable/sanity.sh", "ftype": "file", "chksum_type": "sha256", @@ -1684,7 +1607,14 @@ "name": "tests/requirements.yml", "ftype": "file", "chksum_type": "sha256", - "chksum_sha256": "b79e882cd4f6bc6d9f6ba519dfd6fda8cc029782089d00f7b85ed24f4bb50bb7", + "chksum_sha256": "b260e4aeb32c257255239329d630557f17fc47be6d851a0b82d9dbb8fcdb6094", + "format": 1 + }, + { + "name": ".ansible-lint", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "33235ea099dc7bd2061e0271afcef60424799834d3023d14652b6296e3f133c2", "format": 1 }, { @@ -1698,7 +1628,7 @@ "name": "CHANGELOG.rst", "ftype": "file", "chksum_type": "sha256", - "chksum_sha256": "84b11100ae6883e41c8ebeed2d1ecf0eda432b7e69bbe30389bcb7059902ac0d", + "chksum_sha256": "fba73adcf8b95de1f2c33cc77b6f8216cb15754dbe34756a8f386f77d9c6db75", "format": 1 }, { @@ -1719,7 +1649,7 @@ "name": "README.md", "ftype": "file", "chksum_type": "sha256", - "chksum_sha256": "6f7c024d3f1ddee4c8684eeb67277f01dbd3aec9105868865865ec647c871de4", + "chksum_sha256": "e419193ef655a36813fec99c809526454af2c0267384d0069b4f3e024bb7f98e", "format": 1 }, { @@ -1733,14 +1663,14 @@ "name": "codecov.yml", "ftype": "file", "chksum_type": "sha256", - "chksum_sha256": "26db67130ad8012ae45796321b9a7b101e2d24087658d4cdbc8e4acce5c1e9ac", + "chksum_sha256": "b9fa3a8710d188756f6489e274cd9c0ab0dce2d6e2f2eccc2e4dfe7b9d1be21f", "format": 1 }, { "name": "requirements.txt", "ftype": "file", "chksum_type": "sha256", - "chksum_sha256": "b95f76f543c0b502c7d27048d57217466973c61406419674148d46c30718fa35", + "chksum_sha256": "eddad0ac4f13c4659d7273c95dfc20581b973e5fae5ab71a797dd026ead00974", "format": 1 } ], diff --git a/ansible_collections/microsoft/ad/MANIFEST.json b/ansible_collections/microsoft/ad/MANIFEST.json index 6bf9c8e98..57ce22ed2 100644 --- a/ansible_collections/microsoft/ad/MANIFEST.json +++ b/ansible_collections/microsoft/ad/MANIFEST.json @@ -2,7 +2,7 @@ "collection_info": { "namespace": "microsoft", "name": "ad", - "version": "1.2.0", + "version": "1.5.0", "authors": [ "Jordan Borean @jborean93", "Matt Davis @nitzmahone" @@ -25,7 +25,7 @@ "name": "FILES.json", "ftype": "file", "chksum_type": "sha256", - "chksum_sha256": "48f8bf20d14aee4afc464d2b0f6937fdf921a633aeb35294baa728d08ba7d2d2", + "chksum_sha256": "4da03dbf6e40118c90aad5d75b02840c99ac9169e450044d11d902a2e3a5f97f", "format": 1 }, "format": 1 diff --git a/ansible_collections/microsoft/ad/README.md b/ansible_collections/microsoft/ad/README.md index 2136ab761..af41a27e7 100644 --- a/ansible_collections/microsoft/ad/README.md +++ b/ansible_collections/microsoft/ad/README.md @@ -7,7 +7,7 @@ The `microsoft.ad` collection includes the plugins supported by Ansible to help ## Ansible version compatibility -This collection has been tested against following Ansible versions: **>=2.12**. +This collection has been tested against following Ansible versions: **>=2.14**. Plugins and modules within a collection may be tested with only specific Ansible versions. A collection may contain metadata that identifies these versions. @@ -84,7 +84,7 @@ The current process for publishing new versions of the Microsoft AD Collection i * Update the `CHANGELOG`: * Make sure you have [`antsibull-changelog`](https://pypi.org/project/antsibull-changelog/) installed `pip install antsibull-changelog`. * Make sure there are fragments for all known changes in `changelogs/fragments`. - * Add a new `release_summary` fragment: `echo "Release summary for v..." > changelogs/fragments/release-summary.yml` + * Add a new `release_summary` fragment: `echo "release_summary: Release summary for v..." > changelogs/fragments/release-summary.yml` * Run `antsibull-changelog release`. * Commit the changes and wait for CI to be green * Create a release with the tag that matches the version number diff --git a/ansible_collections/microsoft/ad/changelogs/changelog.yaml b/ansible_collections/microsoft/ad/changelogs/changelog.yaml index 37b1f8113..f9d1dc51f 100644 --- a/ansible_collections/microsoft/ad/changelogs/changelog.yaml +++ b/ansible_collections/microsoft/ad/changelogs/changelog.yaml @@ -66,3 +66,90 @@ releases: - release_summary.yml - server2012.yml release_date: '2023-06-14' + 1.3.0: + changes: + bugfixes: + - Added the missing dependency ``dpapi-ng`` to Ansible Execution Environments + requirements file for LAPS decryption support + - Ensure renaming and moving an object will be done with the ``domain_server`` + and ``domain_username`` credentials specified - https://github.com/ansible-collections/microsoft.ad/issues/54 + - Fix up ``protect_from_deletion`` when creating new AD objects - https://github.com/ansible-collections/microsoft.ad/issues/47 + - Fix up date_time attribute comparisons to be idempotent - https://github.com/ansible-collections/microsoft.ad/issues/57 + - microsoft.ad.user - Ensure the ``spn`` diff after key is ``spn`` and not ``kerberos_encryption_types`` + - microsoft.ad.user - treat an expired account as a password that needs to be + changed + minor_changes: + - AD objects will no longer be moved to the default AD path for their type if + no ``path`` was specified. Use the value ``microsoft.ad.default_path`` to + explicitly set the path to the default path if that behaviour is desired. + - microsoft.ad.ldap - Added the option ``filter_without_computer`` to not add + the AND clause ``objectClass=computer`` to the final filter used - https://github.com/ansible-collections/microsoft.ad/issues/55 + release_summary: release summary for v1.3.0 + fragments: + - datetime-attributes.yml + - default-path.yml + - dpapi-req.yml + - ldap-filter-raw.yml + - move-adparams.yml + - protect-from-deletion.yml + - release-summary.yml + - user-account-expired-password.yml + - user-spn-diff.yml + release_date: '2023-08-11' + 1.4.0: + changes: + minor_changes: + - Make ``name`` an optional parameter for the AD modules. Either ``name`` or + ``identity`` needs to be set with their respective behaviours. If creating + a new AD user and only ``identity`` is set, that will be the value used for + the name of the object. + - Set minimum supported Ansible version to 2.14 to align with the versions still + supported by Ansible. + - object_info - Add ActiveDirectory module import + release_summary: Prepare for v1.4.0 release + fragments: + - 73-import-activedirectory-module.yml + - ansible_support.yml + - release_summary.yml + - search-by-identity.yml + release_date: '2023-11-16' + 1.4.1: + changes: + bugfixes: + - debug_ldap_client - handle failures when attempting to get the krb5 context + and default CCache rather than fail with a traceback + release_summary: Release summary for v1.4.1 + fragments: + - debug_ldap_client-failure.yml + - release_summary.yml + release_date: '2023-11-23' + 1.5.0: + changes: + bugfixes: + - microsoft.ad.group - Support membership lookup of groups that are longer than + 20 characters long + - microsoft.ad.membership - Add helpful hint when the failure was due to a missing/invalid + ``domain_ou_path`` - https://github.com/ansible-collections/microsoft.ad/issues/88 + minor_changes: + - Added ``group/microsoft.ad.domain`` module defaults group for the ``computer``, + ``group``, ``object_info``, ``object``, ``ou``, and ``user`` module. Users + can use this defaults group to set common connection options for these modules + such as the ``domain_server``, ``domain_username``, and ``domain_password`` + options. + - Added support for Jinja2 templating in ldap inventory. + release_summary: Release summary for v1.5.0 + fragments: + - default_options.yml + - group-support-long-group-names.yml + - membership-invalid-ou.yml + - release-summary.yml + - templating_support.yml + plugins: + filter: + - description: Escape an LDAP DistinguishedName value string. + name: dn_escape + namespace: null + - description: Parses an LDAP DistinguishedName string into an object. + name: parse_dn + namespace: null + release_date: '2024-03-20' diff --git a/ansible_collections/microsoft/ad/changelogs/config.yaml b/ansible_collections/microsoft/ad/changelogs/config.yaml index fb1af5440..69706db0f 100644 --- a/ansible_collections/microsoft/ad/changelogs/config.yaml +++ b/ansible_collections/microsoft/ad/changelogs/config.yaml @@ -9,21 +9,21 @@ notesdir: fragments prelude_section_name: release_summary prelude_section_title: Release Summary sections: -- - major_changes - - Major Changes -- - minor_changes - - Minor Changes -- - breaking_changes - - Breaking Changes / Porting Guide -- - deprecated_features - - Deprecated Features -- - removed_features - - Removed Features (previously deprecated) -- - security_fixes - - Security Fixes -- - bugfixes - - Bugfixes -- - known_issues - - Known Issues + - - major_changes + - Major Changes + - - minor_changes + - Minor Changes + - - breaking_changes + - Breaking Changes / Porting Guide + - - deprecated_features + - Deprecated Features + - - removed_features + - Removed Features (previously deprecated) + - - security_fixes + - Security Fixes + - - bugfixes + - Bugfixes + - - known_issues + - Known Issues title: Ansible Microsoft Active Directory trivial_section_name: trivial diff --git a/ansible_collections/microsoft/ad/codecov.yml b/ansible_collections/microsoft/ad/codecov.yml index e77553e59..44c524336 100644 --- a/ansible_collections/microsoft/ad/codecov.yml +++ b/ansible_collections/microsoft/ad/codecov.yml @@ -1,10 +1,10 @@ --- ignore: -- .azure-pipelines/* -- tests/unit/compat/* -- tests/unit/mock/* -- tests/unit/**/conftest.py -- tests/unit/conftest.py + - .azure-pipelines/* + - tests/unit/compat/* + - tests/unit/mock/* + - tests/unit/**/conftest.py + - tests/unit/conftest.py fixes: -- "/ansible_collections/microsoft/ad/::" + - "/ansible_collections/microsoft/ad/::" diff --git a/ansible_collections/microsoft/ad/docs/docsite/extra-docs.yml b/ansible_collections/microsoft/ad/docs/docsite/extra-docs.yml index e69755e97..6a548ed38 100644 --- a/ansible_collections/microsoft/ad/docs/docsite/extra-docs.yml +++ b/ansible_collections/microsoft/ad/docs/docsite/extra-docs.yml @@ -4,10 +4,10 @@ # SPDX-License-Identifier: GPL-3.0-or-later sections: -- title: Scenario Guides - toctree: - - guide_attributes - - guide_ldap_connection - - guide_ldap_inventory - - guide_list_values - - guide_migration
\ No newline at end of file + - title: Scenario Guides + toctree: + - guide_attributes + - guide_ldap_connection + - guide_ldap_inventory + - guide_list_values + - guide_migration diff --git a/ansible_collections/microsoft/ad/docs/docsite/links.yml b/ansible_collections/microsoft/ad/docs/docsite/links.yml index 7349c5f6b..6a9c7d3de 100644 --- a/ansible_collections/microsoft/ad/docs/docsite/links.yml +++ b/ansible_collections/microsoft/ad/docs/docsite/links.yml @@ -6,20 +6,20 @@ edit_on_github: repository: ansible-collections/microsoft.ad branch: main - path_prefix: '' + path_prefix: "" extra_links: -- description: Report an issue - url: https://github.com/ansible-collections/microsoft.ad/issues/new/choose + - description: Report an issue + url: https://github.com/ansible-collections/microsoft.ad/issues/new/choose communication: matrix_rooms: - - topic: General usage and support questions - room: '#windows:ansible.im' + - topic: General usage and support questions + room: "#windows:ansible.im" irc_channels: - - topic: General usage and support questions - network: Libera - channel: '#ansible-windows' + - topic: General usage and support questions + network: Libera + channel: "#ansible-windows" mailing_lists: - - topic: Ansible Project List - url: https://groups.google.com/g/ansible-project + - topic: Ansible Project List + url: https://groups.google.com/g/ansible-project diff --git a/ansible_collections/microsoft/ad/docs/docsite/rst/guide_ldap_inventory.rst b/ansible_collections/microsoft/ad/docs/docsite/rst/guide_ldap_inventory.rst index 798775b21..b9aa9b96d 100644 --- a/ansible_collections/microsoft/ad/docs/docsite/rst/guide_ldap_inventory.rst +++ b/ansible_collections/microsoft/ad/docs/docsite/rst/guide_ldap_inventory.rst @@ -225,6 +225,7 @@ The following filters can be used as an easy way to further convert the coerced * :ref:`microsoft.ad.as_datetime <ansible_collections.microsoft.ad.as_datetime_filter>` * :ref:`microsoft.ad.as_guid <ansible_collections.microsoft.ad.as_guid_filter>` * :ref:`microsoft.ad.as_sid <ansible_collections.microsoft.ad.as_sid_filter>` +* :ref:`microsoft.ad.parse_dn <ansible_collections.microsoft.ad.parse_dn_filter>` An example of these filters being used in the ``attributes`` option can be seen below: @@ -409,7 +410,7 @@ The ``raw`` value contains the raw base64 encoded value as stored in AD. The ``t * ``encrypted_value``: The encrypted password blob as a base64 string * ``flags``: The flags set as a bitwise int value, currently these are undocumented by Microsoft -* ``update_timestamp``: The FILETIME value of when the +* ``update_timestamp``: The FILETIME value of when the * ``value``: The decrypted value containing the username and password as a JSON string * ``debug``: Debug information that indicates why it failed to decrypt the value diff --git a/ansible_collections/microsoft/ad/meta/runtime.yml b/ansible_collections/microsoft/ad/meta/runtime.yml index afd650545..b62c1248b 100644 --- a/ansible_collections/microsoft/ad/meta/runtime.yml +++ b/ansible_collections/microsoft/ad/meta/runtime.yml @@ -1 +1,9 @@ -requires_ansible: '>=2.12'
\ No newline at end of file +requires_ansible: '>=2.14' +action_groups: + domain: + - computer + - group + - object_info + - object + - ou + - user
\ No newline at end of file diff --git a/ansible_collections/microsoft/ad/plugins/action/debug_ldap_client.py b/ansible_collections/microsoft/ad/plugins/action/debug_ldap_client.py index a33f21dda..fbe990f4e 100644 --- a/ansible_collections/microsoft/ad/plugins/action/debug_ldap_client.py +++ b/ansible_collections/microsoft/ad/plugins/action/debug_ldap_client.py @@ -52,7 +52,9 @@ class ActionModule(ActionBase): "dns": dns_info, "kerberos": kerb_info, "packages": { - "dnspython": self._import_lib("dns.resolver", package_name="dnspython"), + "dnspython": self._import_lib( + "dns.resolver", package_name="dnspython" + ), "dpapi_ng": self._import_lib("dpapi_ng", package_name="dpapi-ng"), "krb5": self._import_lib("krb5"), "pyspnego": self._import_lib("spnego", package_name="pyspnego"), @@ -77,8 +79,6 @@ class ActionModule(ActionBase): ctx = krb5.init_context() except Exception: res["exception"] = traceback.format_exc() - - if not ctx: return res try: @@ -106,8 +106,6 @@ class ActionModule(ActionBase): default_cc = krb5.cc_default(ctx) except Exception: res["exception"] = traceback.format_exc() - - if not default_cc: return res try: @@ -154,7 +152,9 @@ class ActionModule(ActionBase): } ) - highest_record = next(iter(sorted(records, key=lambda k: (k["priority"], -k["weight"]))), None) + highest_record = next( + iter(sorted(records, key=lambda k: (k["priority"], -k["weight"]))), None + ) if highest_record: res["default_server"] = highest_record["target"].rstrip(".") res["default_port"] = highest_record["port"] diff --git a/ansible_collections/microsoft/ad/plugins/doc_fragments/ad_object.py b/ansible_collections/microsoft/ad/plugins/doc_fragments/ad_object.py index 31ed8eacd..3231e2341 100644 --- a/ansible_collections/microsoft/ad/plugins/doc_fragments/ad_object.py +++ b/ansible_collections/microsoft/ad/plugins/doc_fragments/ad_object.py @@ -79,12 +79,16 @@ options: domain_password: description: - The password for I(domain_username). + - This can be set under the R(play's module defaults,module_defaults_groups) + under the C(group/microsoft.ad.domain) group. 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. + - This can be set under the R(play's module defaults,module_defaults_groups) + under the C(group/microsoft.ad.domain) group. type: str domain_username: description: @@ -92,38 +96,46 @@ options: - 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. + - This can be set under the R(play's module defaults,module_defaults_groups) + under the C(group/microsoft.ad.domain) group. 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). + - This 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 + - If omitted, the AD object to manage 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. - - This is not always going to be the same as the C(sAMAccountName) for user - objects. It is strictly the C(name) of the object in the path specified. - Use I(identity) to select an object to manage by C(sAMAccountName). + - The C(name) of the AD object to manage, this is not the C(sAMAccountName) + of the object but the LDAP C(cn) or C(name) entry of the object in the + path specified. Use I(identity) to select an object to manage by its + C(sAMAccountName). + - If I(identity) is specified, and the name of the object found by that + identity does not match this value, the object will be renamed. + - This must be specified 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 for most objects. + - If creating a new object, the new object will be created at the path + specified. If no path is specified then the C(defaultNamingContext) of + the domain will be used as the path for most object types. + - If managing an existing object found by I(identity), the path of the + found object will be moved to the one specified by this option. If no + path is specified, the object will not be moved. - The modules M(microsoft.ad.computer), M(microsoft.ad.user), and M(microsoft.ad.group) have their own default path that is configured on the Active Directory domain controller. + - This can be set to the literal value C(microsoft.ad.default_path) which + will equal the default value used when creating a new object. type: str protect_from_deletion: description: diff --git a/ansible_collections/microsoft/ad/plugins/doc_fragments/ldap_connection.py b/ansible_collections/microsoft/ad/plugins/doc_fragments/ldap_connection.py index 9300881cb..327c1ba76 100644 --- a/ansible_collections/microsoft/ad/plugins/doc_fragments/ldap_connection.py +++ b/ansible_collections/microsoft/ad/plugins/doc_fragments/ldap_connection.py @@ -31,6 +31,7 @@ options: installed. - See R(LDAP authentication,ansible_collections.microsoft.ad.docsite.guide_ldap_connection.authentication) for more information. + - This option can be set using a Jinja2 template value. choices: - simple - certificate @@ -47,6 +48,7 @@ options: certificate validation. - If omitted, the default CA store used for validation is dependent on the current Python settings. + - This option can be set using a Jinja2 template value. type: str env: - name: MICROSOFT_AD_LDAP_CA_CERT @@ -60,6 +62,7 @@ options: hostname checks performed by TLS. - See R(Certificate validation,ansible_collections.microsoft.ad.docsite.guide_ldap_connection.cert_validation) for more information. + - This option can be set using a Jinja2 template value. choices: - always - ignore @@ -80,6 +83,7 @@ options: - Use I(certificate_key) if the certificate specified does not contain the key. - Use I(certificate_password) if the key is encrypted with a password. + - This option can be set using a Jinja2 template value. type: str env: - name: MICROSOFT_AD_LDAP_CERTIFICATE @@ -89,6 +93,7 @@ options: - The value can either be a path to a file containing the key in the PEM or DER encoded form, or it can be the string of a PEM encoded key. - Use I(certificate_password) if the key is encrypted with a password. + - This option can be set using a Jinja2 template value. type: str env: - name: MICROSOFT_AD_LDAP_CERTIFICATE_KEY @@ -96,6 +101,7 @@ options: description: - The password used to decrypt the certificate key specified by I(certificate) or I(certificate_key). + - This option can be set using a Jinja2 template value. type: str env: - name: MICROSOFT_AD_LDAP_CERTIFICATE_PASSWORD @@ -103,6 +109,7 @@ options: description: - The timeout in seconds to wait until the connection is established before failing. + - This option can be set using a Jinja2 template value. default: 5 type: int env: @@ -117,6 +124,7 @@ options: - If using C(auth_protocol=simple) over LDAP without TLS then this must be set to C(False). As no encryption is used, all traffic will be in plaintext and should be avoided. + - This option can be set using a Jinja2 template value. default: true type: bool env: @@ -129,6 +137,7 @@ options: - If I(auth_protocol) is C(negotiate), C(kerberos), or C(ntlm) and no password is specified, it will attempt to use the local cached credential specified by I(username) if available. + - This option can be set using a Jinja2 template value. type: str env: - name: MICROSOFT_AD_LDAP_PASSWORD @@ -137,6 +146,7 @@ options: - The LDAP port to use for the connection. - Port 389 is used for LDAP and port 686 is used for LDAPS. - Defaults to port C(636) if C(tls_mode=ldaps) otherwise C(389). + - This option can be set using a Jinja2 template value. type: int env: - name: MICROSOFT_AD_LDAP_PORT @@ -147,6 +157,7 @@ options: C(default_realm) setting and with an SRV DNS lookup. - See R(Server lookup,ansible_collections.microsoft.ad.docsite.guide_ldap_connection.server_lookup) for more information. + - This option can be set using a Jinja2 template value. type: str env: - name: MICROSOFT_AD_LDAP_SERVER @@ -159,6 +170,7 @@ options: operation before the authentication bind. - It is recommended to use C(ldaps) over C(start_tls) if TLS is going to be used. + - This option can be set using a Jinja2 template value. choices: - ldaps - start_tls @@ -173,6 +185,7 @@ options: - If I(auth_protocol) is C(negotiate), C(kerberos), or C(ntlm) and no username is specified, it will attempt to use the local cached credential if available, for example one retrieved by C(kinit). + - This option can be set using a Jinja2 template value. type: str env: - name: MICROSOFT_AD_LDAP_USERNAME diff --git a/ansible_collections/microsoft/ad/plugins/filter/as_datetime.yml b/ansible_collections/microsoft/ad/plugins/filter/as_datetime.yml index f8e7911f9..0a5fd55aa 100644 --- a/ansible_collections/microsoft/ad/plugins/filter/as_datetime.yml +++ b/ansible_collections/microsoft/ad/plugins/filter/as_datetime.yml @@ -4,36 +4,36 @@ DOCUMENTATION: name: as_datetime author: - - Jordan Borean (@jborean93) + - Jordan Borean (@jborean93) short_description: Converts an LDAP value to a datetime string version_added: 1.1.0 seealso: - - ref: microsoft.ad.as_guid <ansible_collections.microsoft.ad.as_guid_filter> - description: microsoft.ad.as_guid filter - - ref: microsoft.ad.as_sid <ansible_collections.microsoft.ad.as_sid_filter> - description: microsoft.ad.as_sid filter - - ref: microsoft.ad.ldap <ansible_collections.microsoft.ad.ldap_inventory> - description: microsoft.ad.ldap inventory + - ref: microsoft.ad.as_guid <ansible_collections.microsoft.ad.as_guid_filter> + description: microsoft.ad.as_guid filter + - ref: microsoft.ad.as_sid <ansible_collections.microsoft.ad.as_sid_filter> + description: microsoft.ad.as_sid filter + - ref: microsoft.ad.ldap <ansible_collections.microsoft.ad.ldap_inventory> + description: microsoft.ad.ldap inventory description: - - Converts an LDAP integer or raw value to a datetime string. - - Should be used with the C(microsoft.ad.ldap) plugin to convert - attribute values to a datetime string. + - Converts an LDAP integer or raw value to a datetime string. + - Should be used with the C(microsoft.ad.ldap) plugin to convert + attribute values to a datetime string. positional: _input options: _input: description: - - The LDAP attribute bytes or integer value representing a FILETIME - integer stored in LDAP. - - The resulting datetime will be set as a UTC datetime as that's how the - FILETIME value is stored in LDAP. + - The LDAP attribute bytes or integer value representing a FILETIME + integer stored in LDAP. + - The resulting datetime will be set as a UTC datetime as that's how the + FILETIME value is stored in LDAP. type: raw required: true format: description: - - The string format to format the datetime object as. - - Defaults to an ISO 8601 compatible string, for example - C(2023-02-06T07:39:09.195321+0000). - default: '%Y-%m-%dT%H:%M:%S.%f%z' + - The string format to format the datetime object as. + - Defaults to an ISO 8601 compatible string, for example + C(2023-02-06T07:39:09.195321+0000). + default: "%Y-%m-%dT%H:%M:%S.%f%z" type: str EXAMPLES: | @@ -50,5 +50,5 @@ EXAMPLES: | RETURN: _value: description: - - The datetime string value(s) formatted as per the I(format) option. - type: string
\ No newline at end of file + - The datetime string value(s) formatted as per the I(format) option. + type: string diff --git a/ansible_collections/microsoft/ad/plugins/filter/as_guid.yml b/ansible_collections/microsoft/ad/plugins/filter/as_guid.yml index 3110a5057..e704ac133 100644 --- a/ansible_collections/microsoft/ad/plugins/filter/as_guid.yml +++ b/ansible_collections/microsoft/ad/plugins/filter/as_guid.yml @@ -4,28 +4,28 @@ DOCUMENTATION: name: as_guid author: - - Jordan Borean (@jborean93) + - Jordan Borean (@jborean93) short_description: Converts an LDAP value to a GUID string version_added: 1.1.0 seealso: - - ref: microsoft.ad.as_datetime <ansible_collections.microsoft.ad.as_datetime_filter> - description: microsoft.ad.as_datetime filter - - ref: microsoft.ad.as_sid <ansible_collections.microsoft.ad.as_sid_filter> - description: microsoft.ad.as_sid filter - - ref: microsoft.ad.ldap <ansible_collections.microsoft.ad.ldap_inventory> - description: microsoft.ad.ldap inventory + - ref: microsoft.ad.as_datetime <ansible_collections.microsoft.ad.as_datetime_filter> + description: microsoft.ad.as_datetime filter + - ref: microsoft.ad.as_sid <ansible_collections.microsoft.ad.as_sid_filter> + description: microsoft.ad.as_sid filter + - ref: microsoft.ad.ldap <ansible_collections.microsoft.ad.ldap_inventory> + description: microsoft.ad.ldap inventory description: - - Converts an LDAP string or raw value to a guid string. - - Should be used with the C(microsoft.ad.ldap) plugin to convert - attribute values to a guid string. + - Converts an LDAP string or raw value to a guid string. + - Should be used with the C(microsoft.ad.ldap) plugin to convert + attribute values to a guid string. positional: _input options: _input: description: - - The LDAP attribute bytes or string value representing a GUID - stored in LDAP. - - If using a string as input, it must be a base64 string representing - the GUIDs bytes. + - The LDAP attribute bytes or string value representing a GUID + stored in LDAP. + - If using a string as input, it must be a base64 string representing + the GUIDs bytes. type: raw required: true @@ -38,5 +38,5 @@ EXAMPLES: | RETURN: _value: description: - - The guid string value(s). - type: string
\ No newline at end of file + - The guid string value(s). + type: string diff --git a/ansible_collections/microsoft/ad/plugins/filter/as_sid.yml b/ansible_collections/microsoft/ad/plugins/filter/as_sid.yml index 5e33e3189..a3b610861 100644 --- a/ansible_collections/microsoft/ad/plugins/filter/as_sid.yml +++ b/ansible_collections/microsoft/ad/plugins/filter/as_sid.yml @@ -4,39 +4,39 @@ DOCUMENTATION: name: as_sid author: - - Jordan Borean (@jborean93) + - Jordan Borean (@jborean93) short_description: Converts an LDAP value to a Security Identifier string version_added: 1.1.0 seealso: - - ref: microsoft.ad.as_datetime <ansible_collections.microsoft.ad.as_datetime_filter> - description: microsoft.ad.as_datetime filter - - ref: microsoft.ad.as_guid <ansible_collections.microsoft.ad.as_guid_filter> - description: microsoft.ad.as_guid filter - - ref: microsoft.ad.ldap <ansible_collections.microsoft.ad.ldap_inventory> - description: microsoft.ad.ldap inventory + - ref: microsoft.ad.as_datetime <ansible_collections.microsoft.ad.as_datetime_filter> + description: microsoft.ad.as_datetime filter + - ref: microsoft.ad.as_guid <ansible_collections.microsoft.ad.as_guid_filter> + description: microsoft.ad.as_guid filter + - ref: microsoft.ad.ldap <ansible_collections.microsoft.ad.ldap_inventory> + description: microsoft.ad.ldap inventory description: - - Converts an LDAP string or raw value to a security identifier string. - - Should be used with the C(microsoft.ad.ldap) plugin to convert - attribute values to a security identifier string. + - Converts an LDAP string or raw value to a security identifier string. + - Should be used with the C(microsoft.ad.ldap) plugin to convert + attribute values to a security identifier string. positional: _input options: _input: description: - - The LDAP attribute bytes or string value representing a Security - Identifier stored in LDAP. - - If using a string as input, it must be a base64 string representing - the SIDs bytes. + - The LDAP attribute bytes or string value representing a Security + Identifier stored in LDAP. + - If using a string as input, it must be a base64 string representing + the SIDs bytes. type: raw required: true EXAMPLES: | # This is an example used in the microsoft.ad.ldap plugin - + attributes: objectSid: raw | microsoft.ad.as_sid RETURN: _value: description: - - The security identifier string value(s). - type: string
\ No newline at end of file + - The security identifier string value(s). + type: string diff --git a/ansible_collections/microsoft/ad/plugins/filter/dn_escape.yml b/ansible_collections/microsoft/ad/plugins/filter/dn_escape.yml new file mode 100644 index 000000000..bd14f336b --- /dev/null +++ b/ansible_collections/microsoft/ad/plugins/filter/dn_escape.yml @@ -0,0 +1,46 @@ +# Copyright (c) 2023 Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION: + name: dn_escape + author: + - Jordan Borean (@jborean93) + short_description: Escape an LDAP DistinguishedName value string. + version_added: 1.5.0 + seealso: + - ref: microsoft.ad.parse_dn <ansible_collections.microsoft.ad.parse_dn_filter> + description: microsoft.ad.parse_dn filter + - ref: microsoft.ad.ldap <ansible_collections.microsoft.ad.ldap_inventory> + description: microsoft.ad.ldap inventory + description: + - Escapes a string value for use in an LDAP DistinguishedName. + - This can be used to escape special characters when building a + DistinguishedName value. + positional: _input + options: + _input: + description: + - The string value to escape. + - This should be just the RDN value not including the attribute type + that prefixes the value, for example C(MyValue) and not C(CN=MyValue). + type: str + required: true + +EXAMPLES: | + # This is an example used in the microsoft.ad.ldap plugin + + search_base: OU={{ my_ou_variable | microsoft.ad.dn_escape }},DC=domain,DC=com + + # This is an example with the microsoft.ad.user module + + - microsoft.ad.user: + name: MyUser + password: MyPassword123 + state: present + path: OU={{ my_ou_variable | microsoft.ad.dn_escape }},DC=domain,DC=com + +RETURN: + _value: + description: + - The escaped RDN attribute value. + type: string diff --git a/ansible_collections/microsoft/ad/plugins/filter/ldap_converters.py b/ansible_collections/microsoft/ad/plugins/filter/ldap_converters.py index aa7ee669b..0f4c2af5e 100644 --- a/ansible_collections/microsoft/ad/plugins/filter/ldap_converters.py +++ b/ansible_collections/microsoft/ad/plugins/filter/ldap_converters.py @@ -3,6 +3,7 @@ import base64 import datetime +import re import struct import typing as t import uuid @@ -11,6 +12,154 @@ from ansible.errors import AnsibleFilterError from ansible.module_utils.common.collections import is_sequence +_RDN_TYPE_PATTERN = re.compile( + r""" +[\ ]* # Ignore leading spaces +( + ( + # Lead char is a letter, subsequent chars can be numbers or - + [a-zA-Z][a-zA-Z0-9-]* + ) + | + ( + # First number must a decimal without a leading 0 unless 0. + # Must also contain at least another entry separated by '.'. + ([0-9]|[1-9][0-9]+) + ( + \.([0-9]|[1-9][0-9]+) + )+ + ) +) +[\ ]*= # Ignore trailing spaces before the = +""".encode( + "utf-8" + ), + re.VERBOSE, +) + +_RDN_VALUE_HEXSTRING_PATTERN = re.compile( + r""" +[\ ]* # Ignore leading spaces +\# # Starts with '#' +( + ([0-9a-fA-F]{2})+ +) +[\ ]* # Ignore trailing spaces +(?:[+,]|$) # Terminated by '+', ',', or the end of the string +""".encode( + "utf-8" + ), + re.VERBOSE, +) + +_RDN_VALUE_ESCAPE_PATTERN = re.compile( + r""" +( + (?P<literal> + [+,;<>#=\\\"\ ] + ) + | + (?P<hex> + ([0-9a-fA-F]{2}) + ) +) +""".encode( + "utf-8" + ), + re.VERBOSE, +) + + +def _parse_rdn_type(value: memoryview) -> t.Optional[t.Tuple[bytes, int]]: + if match := _RDN_TYPE_PATTERN.match(value): + return match.group(1), len(match.group(0)) + + return None + + +def _parse_rdn_value(value: memoryview) -> t.Optional[t.Tuple[bytes, int, bool]]: + if hex_match := _RDN_VALUE_HEXSTRING_PATTERN.match(value): + full_value = hex_match.group(0) + more_rdns = full_value.endswith(b"+") + + b_value = base64.b16decode(hex_match.group(1).upper()) + return b_value, len(full_value), more_rdns + + # Parsing the string value variant as regex is too complicated due to the + # myriad of rules and escaping so it is done manually. + read = 0 + new_value = bytearray() + found_spaces = 0 + + total_len = len(value) + while read < total_len: + current_value = value[read] + current_char = chr(current_value) + read += 1 + + # We only count the spaces in the middle of the string so we need to + # keep track of how many have been found until the next character. + if current_char == " ": + if new_value: + found_spaces += 1 + + continue + + if current_char in [",", "+"]: + break + + # We can add any spaces we are still tentatively collecting as there's + # a real value after it. + if found_spaces: + new_value += b" " * found_spaces + found_spaces = 0 + + if current_char == "#" and not new_value: + remaining = ( + value[read - 1:].tobytes().decode("utf-8", errors="surrogateescape") + ) + raise AnsibleFilterError( + f"Found leading # for attribute value but does not match hexstring format at '{remaining}'" + ) + + elif current_char in ["\00", '"', ";", "<", ">"]: + remaining = ( + value[read - 1:].tobytes().decode("utf-8", errors="surrogateescape") + ) + raise AnsibleFilterError( + f"Found unescaped character '{current_char}' in attribute value at '{remaining}'" + ) + + elif current_char == "\\": + if escape_match := _RDN_VALUE_ESCAPE_PATTERN.match(value, pos=read): + if literal_value := escape_match.group("literal"): + new_value += literal_value + read += 1 + + else: + new_value += base64.b16decode(escape_match.group("hex").upper()) + read += 2 + + else: + remaining = ( + value[read - 1:] + .tobytes() + .decode("utf-8", errors="surrogateescape") + ) + raise AnsibleFilterError( + f"Found invalid escape sequence in attribute value at '{remaining}" + ) + + else: + new_value.append(current_value) + + if new_value: + return bytes(new_value), read, current_char == "+" + + else: + return None + + def per_sequence(func: t.Callable[[t.Any], t.Any]) -> t.Any: def wrapper(value: t.Any, *args: t.Any, **kwargs: t.Any) -> t.Any: if is_sequence(value): @@ -22,7 +171,10 @@ def per_sequence(func: t.Callable[[t.Any], t.Any]) -> t.Any: @per_sequence -def as_datetime(value: t.Any, format: str = "%Y-%m-%dT%H:%M:%S.%f%z") -> str: +def as_datetime( + value: t.Any, + format: str = "%Y-%m-%dT%H:%M:%S.%f%z", +) -> str: if isinstance(value, bytes): value = value.decode("utf-8") @@ -31,8 +183,14 @@ def as_datetime(value: t.Any, format: str = "%Y-%m-%dT%H:%M:%S.%f%z") -> str: # FILETIME is 100s of nanoseconds since 1601-01-01. As Python does not # support nanoseconds the delta is number of microseconds. + ft_epoch = datetime.datetime( + year=1601, + month=1, + day=1, + tzinfo=datetime.timezone.utc, + ) delta = datetime.timedelta(microseconds=value // 10) - dt = datetime.datetime(year=1601, month=1, day=1, tzinfo=datetime.timezone.utc) + delta + dt = ft_epoch + delta return dt.strftime(format) @@ -77,10 +235,95 @@ def as_sid(value: t.Any) -> str: return f"S-{revision}-{authority}-{'-'.join(sub_authorities)}" +@per_sequence +def dn_escape(value: str) -> str: + """Escapes a DistinguisedName attribute value.""" + escaped_value = [] + + end_idx = len(value) - 1 + for idx, c in enumerate(value): + if ( + # Starting char cannot be ' ' or # + (idx == 0 and c in [" ", "#"]) + # Ending char cannot be ' ' + or (idx == end_idx and c == " ") + # Any of these chars need to be escaped + # These are documented in RFC 4514 + or (c in ['"', "+", ",", ";", "<", ">", "\\"]) + ): + escaped_value.append(rf"\{c}") + + elif c in ["\00", "\n", "\r", "=", "/"]: + # These are extra chars MS says to escape, it must be done using + # the hex syntax + # https://learn.microsoft.com/en-us/previous-versions/windows/desktop/ldap/distinguished-names + escaped_int = ord(c) + escaped_value.append(rf"\{escaped_int:02X}") + + else: + escaped_value.append(c) + + return "".join(escaped_value) + + +@per_sequence +def parse_dn(value: str) -> t.List[t.List[str]]: + """Parses a DistinguishedName and emits a structured object.""" + + # This behaviour is defined in RFC 4514 and while not defined in that RFC + # this will also remove any extra spaces before and after , = and +. + dn: t.List[t.List[str]] = [] + + # This operates on bytes for 2 reasons: + # 1. We can use a memoryview for more efficient slicing + # 2. Attribute value hex escaping is done per byte, we cannot decode + # back to a string until we have the final value. + # surrogateescape is used for all conversions to ensure non-unicode bytes + # are preserved using the escape behaviour in UTF-8. + b_value = value.encode("utf-8", errors="surrogateescape") + b_view = memoryview(b_value) + + while b_view: + rdns: t.List[str] = [] + + while True: + attr_type = _parse_rdn_type(b_view) + if not attr_type: + remaining = b_view.tobytes().decode("utf-8", errors="surrogateescape") + raise AnsibleFilterError( + f"Expecting attribute type in RDN entry from '{remaining}'" + ) + + rdns.append(attr_type[0].decode("utf-8", errors="surrogateescape")) + b_view = b_view[attr_type[1]:] + + attr_value = _parse_rdn_value(b_view) + if not attr_value: + remaining = b_view.tobytes().decode("utf-8", errors="surrogateescape") + raise AnsibleFilterError( + f"Expecting attribute value in RDN entry from '{remaining}'" + ) + + rdns.append(attr_value[0].decode("utf-8", errors="surrogateescape")) + b_view = b_view[attr_value[1]:] + + # If ended with + we want to continue parsing the AVA values + if attr_value[2]: + continue + else: + break + + dn.append(rdns) + + return dn + + class FilterModule: def filters(self) -> t.Dict[str, t.Callable]: return { "as_datetime": as_datetime, "as_guid": as_guid, "as_sid": as_sid, + "dn_escape": dn_escape, + "parse_dn": parse_dn, } diff --git a/ansible_collections/microsoft/ad/plugins/filter/parse_dn.yml b/ansible_collections/microsoft/ad/plugins/filter/parse_dn.yml new file mode 100644 index 000000000..18feacb64 --- /dev/null +++ b/ansible_collections/microsoft/ad/plugins/filter/parse_dn.yml @@ -0,0 +1,79 @@ +# Copyright (c) 2023 Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION: + name: parse_dn + author: + - Jordan Borean (@jborean93) + short_description: Parses an LDAP DistinguishedName string into an object. + version_added: 1.5.0 + seealso: + - ref: microsoft.ad.dn_escape <ansible_collections.microsoft.ad.dn_escape_filter> + description: microsoft.ad.dn_escape filter + - ref: microsoft.ad.ldap <ansible_collections.microsoft.ad.ldap_inventory> + description: microsoft.ad.ldap inventory + description: + - Parses the provided LDAP DistinguishedName (C(DN)) string value into a + structured object. + - The rules for parsing as defined in + L(RFC 4514,https://www.ietf.org/rfc/rfc4514.txt). + - Each DN contains Relative DistinguishedNames (C(RDN)) separated by C(,) and + each RDN can contain multiple attribute type values also known as an + C(AVA). While Microsoft Active Directory DNs can only contain 1 AVA in an + RDN this parser supports multiple AVAs. + - The returned object for each DN will be provided as a list of lists where + the outer list is each RDN component separated by C(,) and the inner list + is each AVA separated by C(=) and C(+). Each RDN entry is guaranteed to + have 2 string values for the AVA type and value but can contain more if the + RDN contains multiple AVAs separated by C(+). + - The parsed RDN attribute values will be unescaped to represent the actual + value rather than the raw string in the DN. + - A DN that is invalid will raise a filter error. + positional: _input + options: + _input: + description: + - The LDAP DistinguishedName string to parse. + type: str + required: true + +EXAMPLES: | + - name: Parses a simple DN + set_fact: + my_dn: '{{ "CN=Foo,DC=domain,DC=com" | microsoft.ad.parse_dn }}' + + # [ + # ["CN", "Foo"], + # ["DC", "domain"], + # ["DC", "com"], + # ] + + - name: Parses a DN with an escaped and multi attribute values + set_fact: + my_dn: '{{ "CN=CA,O=Acme\, Inc.,C=AU+ST=Queensland" | microsoft.ad.parse_dn }}' + + # [ + # ["CN", "CA"], + # ["O", "Acme, Inc."], + # ["C", "AU", "ST", "Queensland"] + # ] + + # Extract the group names the computer is a member of in the ldap inventory + # plugin, for example gets the first RDN value inside the parsed DN. + attributes: + memberOf: + computer_membership: this | microsoft.ad.parse_dn | map(attribute="0.1") + +RETURN: + _value: + description: + - The parsed LDAP DN values. + type: list + elements: list + sample: + - - CN + - Foo + - - DC + - domain + - - DC + - com diff --git a/ansible_collections/microsoft/ad/plugins/inventory/ldap.py b/ansible_collections/microsoft/ad/plugins/inventory/ldap.py index 23ee31d67..0a329daff 100644 --- a/ansible_collections/microsoft/ad/plugins/inventory/ldap.py +++ b/ansible_collections/microsoft/ad/plugins/inventory/ldap.py @@ -8,7 +8,7 @@ short_description: Inventory plugin for Active Directory version_added: 1.1.0 description: - Inventory plugin for Active Directory or other LDAP sources. -- Uses a YAML configuration file that ends with C(microsoft.ad.{yml|yaml}). +- Uses a YAML configuration file that ends with C(microsoft.ad.ldap.{yml|yaml}). - Each host that is added will set the C(inventory_hostname) to the C(name) of the LDAP computer object and C(ansible_host) to the value of the C(dNSHostName) LDAP attribute if set. If the C(dNSHostName) attribute is not @@ -40,8 +40,19 @@ options: filter: description: - The LDAP filter string used to query the computer objects. - - This will be combined with the filter "(objectClass=computer)". + - By default, this will be combined with the filter + "(objectClass=computer)". Use I(filter_without_computer) to override + this behavior and have I(filter) be the only filter used. type: str + filter_without_computer: + description: + - Will not combine the I(filter) value with the filter + "(objectClass=computer)". + - In most cases this should be C(false) but can be set to C(true) to have + the I(filter) value specified be the only filter used. + type: bool + default: false + version_added: '1.3.0' search_base: description: - The LDAP search base to find the computer objects in. @@ -73,6 +84,9 @@ notes: information. - This plugin is a tech preview and the module options are subject to change based on feedback received. +- Unless specified otherwise in the option description, the value specified in + the config file is used as is. Only the LDAP connection options allow using + a Jinja2 template. extends_documentation_fragment: - constructed - microsoft.ad.ldap_connection @@ -114,6 +128,11 @@ auth_protocol: kerberos tls_mode: ldaps ca_cert: /home/user/certs/ldap.pem +# The username and password can be retrieved using a template with a lookup. +# Other connection options can also be set this way, the option description +# tells you whether it can be set to a template. +username: '{{ lookup("ansible.builtin.env", "LDAP_USERNAME") }}' +password: '{{ lookup("ansible.builtin.env", "LDAP_PASSWORD") }}' ############################################## # Search Options # @@ -143,7 +162,10 @@ attributes: comment: host_comment memberOf: - computer_membership: this | map("regex_search", '^CN=(?P<name>.+?)((?<!\\),)', '\g<name>') | flatten + # Gets the value (1) of the first RDN (0) of each memberOf instance (this). + # For example 'CN=Domain Admins,CN=Users,DC=domain,DC=test' + # will be returned as just 'Domain Admins' + computer_membership: this | microsoft.ad.parse_dn | map(attribute="0.1") location: @@ -259,6 +281,7 @@ class InventoryModule(BaseInventoryPlugin, Constructable): groups = self.get_option("groups") keyed_groups = self.get_option("keyed_groups") ldap_filter = self.get_option("filter") + ldap_filter_without_computer = self.get_option("filter_without_computer") search_base = self.get_option("search_base") search_scope = self.get_option("search_scope") strict = self.get_option("strict") @@ -272,26 +295,56 @@ class InventoryModule(BaseInventoryPlugin, Constructable): computer_filter = sansldap.FilterEquality("objectClass", b"computer") final_filter: sansldap.LDAPFilter if ldap_filter: - final_filter = sansldap.FilterAnd( - filters=[ - computer_filter, - sansldap.LDAPFilter.from_string(ldap_filter), - ] - ) + ldap_filter_obj = sansldap.LDAPFilter.from_string(ldap_filter) + + if ldap_filter_without_computer: + final_filter = ldap_filter_obj + else: + final_filter = sansldap.FilterAnd( + filters=[computer_filter, ldap_filter_obj] + ) else: final_filter = computer_filter custom_attributes = self._get_custom_attributes() - attributes = {"name", "dnshostname"}.union([a.lower() for a in custom_attributes.keys()]) + attributes = {"name", "dnshostname"}.union( + [a.lower() for a in custom_attributes.keys()] + ) # If inventory_hostname was defined in compose, set it in the custom # attributes so we can set the hostname before processing the rest of # compose entries. inventory_hostname = compose.pop("inventory_hostname", None) if inventory_hostname: - custom_attributes["inventory_hostname"] = {"inventory_hostname": inventory_hostname} - + custom_attributes["inventory_hostname"] = { + "inventory_hostname": inventory_hostname + } connection_options = self.get_options() + + # These options are in ../doc_fragments/ldap_connection.py + template_fields = { + 'auth_protocol', + 'ca_cert', + 'cert_validation', + 'certificate', + 'certificate_key', + 'certificate_password', + 'connection_timeout', + 'encrypt', + 'password', + 'port', + 'server', + 'tls_mode', + 'username', + } + for option_name, option_value in connection_options.items(): + if option_name in template_fields and self.templar.is_template(option_value): + self.display.vvv(f"Templating option {option_name}") + connection_options[option_name] = self.templar.template( + variable=option_value, + disable_lookups=False, + ) + laps_decryptor = LAPSDecryptor(**connection_options) with create_ldap_connection(**connection_options) as client: schema = LDAPSchema.load_schema(client) @@ -317,9 +370,11 @@ class InventoryModule(BaseInventoryPlugin, Constructable): raw_values = insensitive_info.get(name.lower(), []) values = schema.cast_object(name, raw_values) - host_vars["raw"] = wrap_var([base64.b64encode(r).decode() for r in raw_values]) + host_vars["raw"] = wrap_var( + [base64.b64encode(r).decode() for r in raw_values] + ) - if name.lower() == 'mslaps-encryptedpassword' and raw_values: + if name.lower() == "mslaps-encryptedpassword" and raw_values: host_vars["this"] = laps_decryptor.decrypt(raw_values[0]) else: host_vars["this"] = wrap_var(values) @@ -329,7 +384,9 @@ class InventoryModule(BaseInventoryPlugin, Constructable): composite = self._compose(v, host_vars) except Exception as e: if strict: - raise AnsibleError(f"Could not set {n} for host {host_name}: {e}") from e + raise AnsibleError( + f"Could not set {n} for host {host_name}: {e}" + ) from e continue host_vars[n] = composite @@ -344,9 +401,15 @@ class InventoryModule(BaseInventoryPlugin, Constructable): continue inventory.set_variable(actual_host_name, n, v) - self._set_composite_vars(compose, host_vars, actual_host_name, strict=strict) - self._add_host_to_composed_groups(groups, host_vars, actual_host_name, strict=strict) - self._add_host_to_keyed_groups(keyed_groups, host_vars, actual_host_name, strict=strict) + self._set_composite_vars( + compose, host_vars, actual_host_name, strict=strict + ) + self._add_host_to_composed_groups( + groups, host_vars, actual_host_name, strict=strict + ) + self._add_host_to_keyed_groups( + keyed_groups, host_vars, actual_host_name, strict=strict + ) def _get_custom_attributes(self) -> t.Dict[str, t.Dict[str, str]]: custom_attributes = self.get_option("attributes") @@ -358,7 +421,9 @@ class InventoryModule(BaseInventoryPlugin, Constructable): elif isinstance(info, str): info = {name.replace("-", "_"): info} elif not isinstance(info, dict): - raise AnsibleError(f"Attribute {name} value was {type(info).__name__} but was expecting a dictionary") + raise AnsibleError( + f"Attribute {name} value was {type(info).__name__} but was expecting a dictionary" + ) for var_name in list(info.keys()): var_template = info[var_name] diff --git a/ansible_collections/microsoft/ad/plugins/module_utils/_ADObject.psm1 b/ansible_collections/microsoft/ad/plugins/module_utils/_ADObject.psm1 index 4a7ccf87c..e51c974cb 100644 --- a/ansible_collections/microsoft/ad/plugins/module_utils/_ADObject.psm1 +++ b/ansible_collections/microsoft/ad/plugins/module_utils/_ADObject.psm1 @@ -75,7 +75,7 @@ Function Compare-AnsibleADAttribute { [System.Convert]::ToBase64String($_) } elseif ($_ -is [System.DateTime]) { - $_.ToUniversalTime().ToString('o') + $_.ToUniversalTime().ToFileTimeUtc() } elseif ($_ -is [System.DirectoryServices.ActiveDirectorySecurity]) { $_.GetSecurityDescriptorSddlForm([System.Security.AccessControl.AccessControlSections]::All) @@ -559,9 +559,6 @@ Function Get-AnsibleADObject { elseif ($Identity -match '^.*\@.*\..*$') { $getParams.LDAPFilter = "(userPrincipalName=$($Matches[0]))" } - elseif ($Identity -match '^(?:[^:*?""<>|\/\\]+\\)?(?<username>[^;:""<>|?,=\*\+\\\(\)]{1,20})$') { - $getParams.LDAPFilter = "(sAMAccountName=$($Matches.username))" - } else { try { $sid = New-Object -TypeName System.Security.Principal.SecurityIdentifier -ArgumentList $Identity @@ -574,8 +571,13 @@ Function Get-AnsibleADObject { $getParams.LDAPFilter = "(objectSid=$value)" } catch [System.ArgumentException] { - # Finally fallback to DistinguishedName. - $getParams.Identity = $Identity + if ($Identity -match '^(?:[^:*?""<>|\/\\]+\\)?(?<username>[^;:""<>|?,=\*\+\\\(\)]+)$') { + $getParams.LDAPFilter = "(sAMAccountName=$($Matches.username))" + } + else { + # Finally fallback to DistinguishedName. + $getParams.Identity = $Identity + } } } @@ -685,6 +687,8 @@ Function Invoke-AnsibleADObject { $PostAction ) + $defaultPathSentinel = 'microsoft.ad.default_path' + $spec = @{ options = @{ attributes = @{ @@ -738,7 +742,7 @@ Function Invoke-AnsibleADObject { } $stateRequiredIf = @{ - present = @('name') + present = @() absent = @() } @@ -828,7 +832,7 @@ Function Invoke-AnsibleADObject { } else { $ouPath = $defaultObjectPath - if ($module.Params.path) { + if ($module.Params.path -and $module.Params.path -ne $defaultPathSentinel) { $ouPath = $module.Params.path } "$namePrefix=$($Module.Params.name -replace ',', '\,'),$ouPath" @@ -883,7 +887,7 @@ Function Invoke-AnsibleADObject { # Remove-ADObject -Recursive fails with access is denied, use this # instead to remove the child objects manually - Get-ADObject -Filter * -Properties ProtectedFromAccidentalDeletion -Searchbase $adObject.DistinguishedName | + Get-ADObject -Filter * -Properties ProtectedFromAccidentalDeletion -Searchbase $adObject.DistinguishedName @adParams | Sort-Object -Property { $_.DistinguishedName.Length } -Descending | ForEach-Object -Process { if ($_.ProtectedFromAccidentalDeletion) { @@ -903,15 +907,21 @@ Function Invoke-AnsibleADObject { $objectGuid = $null if (-not $adObject) { + $adName = if ($module.Params.name) { + $module.Params.name + } + else { + $module.Params.identity + } $newParams = @{ Confirm = $false - Name = $module.Params.name + Name = $adName WhatIf = $module.CheckMode PassThru = $true } $objectPath = $null - if ($module.Params.path) { + if ($module.Params.path -and $module.Params.path -ne $defaultPathSentinel) { $objectPath = $path $newParams.Path = $module.Params.path } @@ -924,7 +934,7 @@ Function Invoke-AnsibleADObject { $module.Diff.after = @{ attributes = $diffAttributes.after - name = $module.Params.name + name = $adName path = $objectPath } @@ -959,6 +969,19 @@ Function Invoke-AnsibleADObject { } } + # Only New-ADObject has the -ProtectedFromAccidentialDeletion while + # other cmdlets do not. Check for this and manually run with + # Set-ADObject later if protection is desired. + # https://github.com/ansible-collections/microsoft.ad/issues/47 + $protectFromDeletion = $false + if ( + $newParams.ContainsKey('ProtectedFromAccidentalDeletion') -and + -not $newCommand.Parameters.ContainsKey('ProtectedFromAccidentalDeletion') + ) { + $protectFromDeletion = $newParams.ProtectedFromAccidentalDeletion + $newParams.Remove('ProtectedFromAccidentalDeletion') + } + try { $adObject = & $newCommand @newParams @adParams } @@ -970,12 +993,16 @@ Function Invoke-AnsibleADObject { $module.Result.changed = $true if ($module.CheckMode) { - $objectDN = "$namePrefix=$($module.Params.name -replace ',', '\,'),$objectPath" + $objectDN = "$namePrefix=$($adName -replace ',', '\,'),$objectPath" $objectGuid = [Guid]::Empty # Dummy value for check mode } else { $objectDN = $adObject.DistinguishedName $objectGuid = $adObject.ObjectGUID + + if ($protectFromDeletion) { + $adObject | Set-ADObject @adParams -ProtectedFromAccidentalDeletion $true + } } } else { @@ -1056,16 +1083,21 @@ Function Invoke-AnsibleADObject { } $finalADObject = $null - if ($module.Params.name -cne $objectName) { - $objectName = $module.Params.name + $desiredName = $module.Params.name + if ($desiredName -and $desiredName -cne $objectName) { + $objectName = $desiredName $module.Diff.after.name = $objectName - $finalADObject = Rename-ADObject @commonParams -NewName $objectName + $finalADObject = Rename-ADObject @commonParams @adParams -NewName $objectName $module.Result.changed = $true } - if ($module.Params.path -and $module.Params.path -ne $objectPath) { - $objectPath = $module.Params.path + $desiredPath = $module.Params.path + if ($desiredPath -eq $defaultPathSentinel) { + $desiredPath = $defaultObjectPath + } + if ($desiredPath -and $desiredPath -ne $objectPath) { + $objectPath = $desiredPath $module.Diff.after.path = $objectPath $addProtection = $false @@ -1075,7 +1107,7 @@ Function Invoke-AnsibleADObject { } try { - $finalADObject = Move-ADObject @commonParams -TargetPath $objectPath + $finalADObject = Move-ADObject @commonParams @adParams -TargetPath $objectPath } finally { if ($addProtection) { @@ -1086,6 +1118,15 @@ Function Invoke-AnsibleADObject { $module.Result.changed = $true } + $protectFromDeletion = $null + if ( + $setParams.ContainsKey('ProtectedFromAccidentalDeletion') -and + -not $setCommand.Parameters.ContainsKey('ProtectedFromAccidentalDeletion') + ) { + $protectFromDeletion = $setParams.ProtectedFromAccidentalDeletion + $setParams.Remove('ProtectedFromAccidentalDeletion') + } + if ($setParams.Count) { try { $finalADObject = & $setCommand @commonParams @setParams @adParams @@ -1098,6 +1139,11 @@ Function Invoke-AnsibleADObject { $module.Result.changed = $true } + if ($null -ne $protectFromDeletion) { + $finalADObject = Set-ADObject -ProtectedFromAccidentalDeletion $protectFromDeletion @commonParams @adParams + $module.Result.changed = $true + } + # Won't be set in check mode if ($finalADObject) { $objectDN = $finalADObject.DistinguishedName diff --git a/ansible_collections/microsoft/ad/plugins/modules/computer.py b/ansible_collections/microsoft/ad/plugins/modules/computer.py index 498b882ba..ab336d6b4 100644 --- a/ansible_collections/microsoft/ad/plugins/modules/computer.py +++ b/ansible_collections/microsoft/ad/plugins/modules/computer.py @@ -184,6 +184,8 @@ notes: - See R(win_domain_computer migration,ansible_collections.microsoft.ad.docsite.guide_migration.migrated_modules.win_domain_computer) for help on migrating from M(community.windows.win_domain_computer) to this module. +- This module must be run on a Windows target host with the C(ActiveDirectory) + module installed. extends_documentation_fragment: - microsoft.ad.ad_object - ansible.builtin.action_common_attributes @@ -223,12 +225,12 @@ EXAMPLES = r""" - name: Remove linux computer from Active Directory using a windows machine microsoft.ad.computer: - name: one_linux_server + identity: one_linux_server state: absent - name: Add SPNs to computer microsoft.ad.computer: - name: TheComputer + identity: TheComputer spn: add: - HOST/TheComputer @@ -237,7 +239,7 @@ EXAMPLES = r""" - name: Remove SPNs on the computer microsoft.ad.computer: - name: TheComputer + identity: TheComputer spn: remove: - HOST/TheComputer @@ -246,7 +248,7 @@ EXAMPLES = r""" - name: Set the principals the computer trusts for delegation from microsoft.ad.computer: - name: TheComputer + identity: TheComputer delegates: set: - CN=FileShare,OU=Computers,DC=domain,DC=test diff --git a/ansible_collections/microsoft/ad/plugins/modules/domain.py b/ansible_collections/microsoft/ad/plugins/modules/domain.py index 72d4fc21a..15578f7fd 100644 --- a/ansible_collections/microsoft/ad/plugins/modules/domain.py +++ b/ansible_collections/microsoft/ad/plugins/modules/domain.py @@ -78,6 +78,8 @@ options: Sysvol file will be created. - If not set then the default path is C(%SYSTEMROOT%\SYSVOL). type: path +notes: +- This module must be run on a Windows target host. extends_documentation_fragment: - ansible.builtin.action_common_attributes - ansible.builtin.action_common_attributes.flow diff --git a/ansible_collections/microsoft/ad/plugins/modules/domain_controller.py b/ansible_collections/microsoft/ad/plugins/modules/domain_controller.py index 3ef2488bb..df4641741 100644 --- a/ansible_collections/microsoft/ad/plugins/modules/domain_controller.py +++ b/ansible_collections/microsoft/ad/plugins/modules/domain_controller.py @@ -92,6 +92,7 @@ notes: - It is highly recommended to set I(reboot=true) to have Ansible manage the host reboot phase as the actions done by this module puts the host in a state where it may not be possible for Ansible to reconnect in a subsequent task without a reboot. +- This module must be run on a Windows target host. extends_documentation_fragment: - ansible.builtin.action_common_attributes - ansible.builtin.action_common_attributes.flow diff --git a/ansible_collections/microsoft/ad/plugins/modules/group.py b/ansible_collections/microsoft/ad/plugins/modules/group.py index d34e4584b..9fb28e819 100644 --- a/ansible_collections/microsoft/ad/plugins/modules/group.py +++ b/ansible_collections/microsoft/ad/plugins/modules/group.py @@ -90,6 +90,8 @@ notes: - See R(win_group migration,ansible_collections.microsoft.ad.docsite.guide_migration.migrated_modules.win_domain_group) for help on migrating from M(community.windows.win_domain_group) to this module. +- This module must be run on a Windows target host with the C(ActiveDirectory) + module installed. extends_documentation_fragment: - microsoft.ad.ad_object - ansible.builtin.action_common_attributes @@ -118,12 +120,12 @@ author: EXAMPLES = r""" - name: Ensure a group exists microsoft.ad.group: - name: Cow + identity: Cow scope: global - name: Remove a group microsoft.ad.group: - name: Cow + identity: Cow state: absent - name: Create a group in a custom path diff --git a/ansible_collections/microsoft/ad/plugins/modules/membership.ps1 b/ansible_collections/microsoft/ad/plugins/modules/membership.ps1 index 2b37bcdfd..d2be34e9f 100644 --- a/ansible_collections/microsoft/ad/plugins/modules/membership.ps1 +++ b/ansible_collections/microsoft/ad/plugins/modules/membership.ps1 @@ -207,7 +207,27 @@ if ($state -eq 'domain') { $joinParams.OUPath = $domainOUPath } - Add-Computer @joinParams + try { + Add-Computer @joinParams + } + catch { + $failMsg = [string]$_ + + # The error if the domain_ou_path does not exist is a bit + # vague, we try to catch that specific error type and provide + # a more helpful hint to what is wrong. As the exception does + # not have an error code to check, we compare the Win32 error + # code message with a localized variant for + # ERROR_FILE_NOT_FOUND. .NET Framework does not end with . + # whereas .NET 5+ does so we use regex to match both patterns. + # https://github.com/ansible-collections/microsoft.ad/issues/88 + $fileNotFound = [System.ComponentModel.Win32Exception]::new(2).Message + if ($_.Exception.Message -match ".*$([Regex]::Escape($fileNotFound))\.?`$") { + $failMsg += " Check domain_ou_path is pointing to a valid OU in the target domain." + } + + $module.FailJson($failMsg, $_) + } $module.Result.changed = $true $module.Result.reboot_required = $true diff --git a/ansible_collections/microsoft/ad/plugins/modules/membership.py b/ansible_collections/microsoft/ad/plugins/modules/membership.py index f4d8521cf..87b4c85dc 100644 --- a/ansible_collections/microsoft/ad/plugins/modules/membership.py +++ b/ansible_collections/microsoft/ad/plugins/modules/membership.py @@ -72,6 +72,8 @@ options: description: - When I(state=workgroup), this is the name of the workgroup that the Windows host should be in. type: str +notes: +- This module must be run on a Windows target host. extends_documentation_fragment: - ansible.builtin.action_common_attributes - ansible.builtin.action_common_attributes.flow diff --git a/ansible_collections/microsoft/ad/plugins/modules/object.py b/ansible_collections/microsoft/ad/plugins/modules/object.py index db7c7e5f6..c6396619a 100644 --- a/ansible_collections/microsoft/ad/plugins/modules/object.py +++ b/ansible_collections/microsoft/ad/plugins/modules/object.py @@ -24,6 +24,8 @@ notes: 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. +- This module must be run on a Windows target host with the C(ActiveDirectory) + module installed. extends_documentation_fragment: - microsoft.ad.ad_object - ansible.builtin.action_common_attributes diff --git a/ansible_collections/microsoft/ad/plugins/modules/object_info.ps1 b/ansible_collections/microsoft/ad/plugins/modules/object_info.ps1 index d386417fd..4e304feeb 100644 --- a/ansible_collections/microsoft/ad/plugins/modules/object_info.ps1 +++ b/ansible_collections/microsoft/ad/plugins/modules/object_info.ps1 @@ -46,6 +46,14 @@ $properties = $module.Params.properties $searchBase = $module.Params.search_base $searchScope = $module.Params.search_scope +# Attempt import of ActiveDirectory module +try { + Import-Module -Name ActiveDirectory +} +catch { + $module.FailJson("The ActiveDirectory module failed to load properly: $($_.Exception.Message)", $_) +} + $credential = $null if ($domainUsername) { $credential = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList @( @@ -223,7 +231,9 @@ try { # We run this in a custom PowerShell pipeline so that users of this module can't use any of the variables defined # above in their filter. While the cmdlet won't execute sub expressions we don't want anyone implicitly relying on # a defined variable in this module in case we ever change the name or remove it. - $ps = [PowerShell]::Create() + $iss = [InitialSessionState]::CreateDefault() + $iss.ImportPSModule("ActiveDirectory") + $ps = [PowerShell]::Create($iss) $null = $ps.AddCommand('Get-ADObject').AddParameters($commonParams).AddParameters($getParams) $null = $ps.AddCommand('Select-Object').AddParameter('Property', @('DistinguishedName', 'ObjectGUID')) diff --git a/ansible_collections/microsoft/ad/plugins/modules/object_info.py b/ansible_collections/microsoft/ad/plugins/modules/object_info.py index 0fe2f54ed..0cdcf06a7 100644 --- a/ansible_collections/microsoft/ad/plugins/modules/object_info.py +++ b/ansible_collections/microsoft/ad/plugins/modules/object_info.py @@ -16,12 +16,16 @@ options: domain_password: description: - The password for I(domain_username). + - This can be set under the R(play's module defaults,module_defaults_groups) + under the C(group/microsoft.ad.domain) group. 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. + - This can be set under the R(play's module defaults,module_defaults_groups) + under the C(group/microsoft.ad.domain) group. type: str domain_username: description: @@ -29,6 +33,8 @@ options: - 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. + - This can be set under the R(play's module defaults,module_defaults_groups) + under the C(group/microsoft.ad.domain) group. type: str filter: description: @@ -88,6 +94,8 @@ notes: 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. +- This module must be run on a Windows target host with the C(ActiveDirectory) + module installed. extends_documentation_fragment: - ansible.builtin.action_common_attributes attributes: diff --git a/ansible_collections/microsoft/ad/plugins/modules/offline_join.py b/ansible_collections/microsoft/ad/plugins/modules/offline_join.py index 0b07bc36f..f0c8aa54e 100644 --- a/ansible_collections/microsoft/ad/plugins/modules/offline_join.py +++ b/ansible_collections/microsoft/ad/plugins/modules/offline_join.py @@ -85,6 +85,8 @@ notes: - Generating a new blob will reset the password of the computer object, take care that this isn't called under a computer account that has already been joined. +- This module must be run on a Windows target host with the C(ActiveDirectory) + module installed. seealso: - module: microsoft.ad.domain - module: microsoft.ad.membership diff --git a/ansible_collections/microsoft/ad/plugins/modules/ou.py b/ansible_collections/microsoft/ad/plugins/modules/ou.py index d7ac85007..5d1d60503 100644 --- a/ansible_collections/microsoft/ad/plugins/modules/ou.py +++ b/ansible_collections/microsoft/ad/plugins/modules/ou.py @@ -49,6 +49,8 @@ notes: specified. - See R(win_domain_ou migration,ansible_collections.microsoft.ad.docsite.guide_migration.migrated_modules.win_domain_ou) for help on migrating from M(community.windows.win_domain_ou) to this module. +- This module must be run on a Windows target host with the C(ActiveDirectory) + module installed. extends_documentation_fragment: - microsoft.ad.ad_object - ansible.builtin.action_common_attributes diff --git a/ansible_collections/microsoft/ad/plugins/modules/user.ps1 b/ansible_collections/microsoft/ad/plugins/modules/user.ps1 index d975272c7..267c77627 100644 --- a/ansible_collections/microsoft/ad/plugins/modules/user.ps1 +++ b/ansible_collections/microsoft/ad/plugins/modules/user.ps1 @@ -39,6 +39,7 @@ Function Test-Credential { $failed_codes = @( 0x0000052E, # ERROR_LOGON_FAILURE 0x00000532, # ERROR_PASSWORD_EXPIRED + 0x00000701, # ERROR_ACCOUNT_EXPIRED 0x00000773, # ERROR_PASSWORD_MUST_CHANGE 0x00000533 # ERROR_ACCOUNT_DISABLED ) @@ -278,7 +279,7 @@ $setParams = @{ $SetParams.ServicePrincipalNames.Remove = $res.ToRemove } } - $module.Diff.after.kerberos_encryption_types = @($res.Value | Sort-Object) + $module.Diff.after.spn = @($res.Value | Sort-Object) } } diff --git a/ansible_collections/microsoft/ad/plugins/modules/user.py b/ansible_collections/microsoft/ad/plugins/modules/user.py index 30d1c6412..a3e7d1ecb 100644 --- a/ansible_collections/microsoft/ad/plugins/modules/user.py +++ b/ansible_collections/microsoft/ad/plugins/modules/user.py @@ -104,6 +104,10 @@ options: - To clear all group memberships, use I(set) with an empty list. - Note that users cannot be removed from their principal group (for example, "Domain Users"). Attempting to do so will display a warning. + - Each subkey is set to a list of groups objects to add, remove or + set as the membership of this AD user respectively. A group can be in + the form of a C(distinguishedName), C(objectGUID), C(objectSid), or + C(sAMAccountName). - 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. type: dict @@ -221,7 +225,8 @@ options: - C(always) will always update passwords. - C(on_create) will only set the password for newly created users. - C(when_changed) will only set the password when changed. - - Using C(when_changed) will not work if the account is not enabled. + - Using C(when_changed) will not work if the account is not enabled or is + expired. choices: - always - on_create @@ -244,6 +249,8 @@ options: notes: - See R(win_domain_user migration,ansible_collections.microsoft.ad.docsite.guide_migration.migrated_modules.win_domain_user) for help on migrating from M(community.windows.win_domain_user) to this module. +- This module must be run on a Windows target host with the C(ActiveDirectory) + module installed. extends_documentation_fragment: - microsoft.ad.ad_object - ansible.builtin.action_common_attributes @@ -272,7 +279,7 @@ author: EXAMPLES = r""" - name: Ensure user bob is present with address information microsoft.ad.user: - name: bob + identity: bob firstname: Bob surname: Smith company: BobCo @@ -292,7 +299,7 @@ EXAMPLES = r""" - name: Ensure user bob is created and use custom credentials to create the user microsoft.ad.user: - name: bob + identity: bob firstname: Bob surname: Smith password: B0bP4ssw0rd @@ -303,7 +310,7 @@ EXAMPLES = r""" - name: Ensure user bob is present in OU ou=test,dc=domain,dc=local microsoft.ad.user: - name: bob + identity: bob password: B0bP4ssw0rd state: present path: ou=test,dc=domain,dc=local @@ -314,12 +321,12 @@ EXAMPLES = r""" - name: Ensure user bob is absent microsoft.ad.user: - name: bob + identity: bob state: absent - name: Ensure user has only these spn's defined microsoft.ad.user: - name: liz.kenyon + identity: liz.kenyon spn: set: - MSSQLSvc/us99db-svr95:1433 @@ -327,14 +334,14 @@ EXAMPLES = r""" - name: Ensure user has spn added microsoft.ad.user: - name: liz.kenyon + identity: liz.kenyon spn: add: - MSSQLSvc/us99db-svr95:2433 - name: Ensure user is created with delegates and spn's defined microsoft.ad.user: - name: shmemmmy + identity: shmemmmy password: The3rubberducki33! state: present groups: diff --git a/ansible_collections/microsoft/ad/requirements.txt b/ansible_collections/microsoft/ad/requirements.txt index 9d4f6c3cd..14d4e6cad 100644 --- a/ansible_collections/microsoft/ad/requirements.txt +++ b/ansible_collections/microsoft/ad/requirements.txt @@ -9,6 +9,9 @@ cryptography >= 3.1 # For LDAP SRV lookups. 2.0.0 is when the new API used was introduced. dnspython >= 2.0.0 +# For LDAP LAPS decryption support +dpapi-ng + # For krb5 default_realm lookups krb5 diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/computer/tasks/tests.yml b/ansible_collections/microsoft/ad/tests/integration/targets/computer/tasks/tests.yml index fb4eee366..2a403c3d5 100644 --- a/ansible_collections/microsoft/ad/tests/integration/targets/computer/tasks/tests.yml +++ b/ansible_collections/microsoft/ad/tests/integration/targets/computer/tasks/tests.yml @@ -122,6 +122,7 @@ trusted_for_delegation: true upn: MyComputer@{{ domain_realm }} path: CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + protect_from_deletion: true register: custom_comp - set_fact: @@ -137,6 +138,7 @@ - msDS-AllowedToActOnBehalfOfOtherIdentity - msDS-SupportedEncryptionTypes - objectSid + - ProtectedFromAccidentalDeletion - sAMAccountName - servicePrincipalName - userAccountControl @@ -174,6 +176,7 @@ - custom_comp_actual.objects[0]['msDS-SupportedEncryptionTypes_AnsibleFlags'] == ["AES128_CTS_HMAC_SHA1_96", "AES256_CTS_HMAC_SHA1_96"] - custom_comp_actual.objects[0].sAMAccountName == 'SamMyComputer$' - custom_comp_actual.objects[0].ObjectClass == 'computer' + - custom_comp_actual.objects[0].ProtectedFromAccidentalDeletion == true - custom_comp_actual.objects[0].servicePrincipalName == 'HTTP/MyComputer' - custom_comp_actual.objects[0].userPrincipalName == 'MyComputer@' ~ domain_realm - '"ADS_UF_ACCOUNTDISABLE" in custom_comp_actual.objects[0].userAccountControl_AnsibleFlags' @@ -197,6 +200,7 @@ sam_account_name: MyComputer2$ trusted_for_delegation: false upn: mycomputer@{{ domain_realm }} + protect_from_deletion: false register: change_comp - name: get result of change computer with custom options @@ -207,6 +211,7 @@ - location - msDS-AllowedToActOnBehalfOfOtherIdentity - msDS-SupportedEncryptionTypes + - ProtectedFromAccidentalDeletion - sAMAccountName - userAccountControl - userPrincipalName @@ -235,6 +240,7 @@ - change_comp_actual.objects[0].location == 'comp location' - change_comp_actual.objects[0]['msDS-SupportedEncryptionTypes'] == 20 - change_comp_actual.objects[0]['msDS-SupportedEncryptionTypes_AnsibleFlags'] == ["RC4_HMAC", "AES256_CTS_HMAC_SHA1_96"] + - change_comp_actual.objects[0].ProtectedFromAccidentalDeletion == false - change_comp_actual.objects[0].sAMAccountName == 'MyComputer2$' - change_comp_actual.objects[0].userPrincipalName == 'mycomputer@' ~ domain_realm - '"ADS_UF_ACCOUNTDISABLE" not in change_comp_actual.objects[0].userAccountControl_AnsibleFlags' diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/group/tasks/tests.yml b/ansible_collections/microsoft/ad/tests/integration/targets/group/tasks/tests.yml index bdb1b95b7..b40041b0d 100644 --- a/ansible_collections/microsoft/ad/tests/integration/targets/group/tasks/tests.yml +++ b/ansible_collections/microsoft/ad/tests/integration/targets/group/tasks/tests.yml @@ -88,6 +88,14 @@ - 3 - 4 + - name: create test group with long name + group: + name: MyGroup2-ReallyLongGroupNameHere + state: present + scope: global + path: '{{ ou_info.distinguished_name }}' + register: test_group + - name: fail to find members to add to a group group: name: MyGroup @@ -109,6 +117,7 @@ add: - my_user_1 - '{{ test_users.results[2].sid }}' + - MyGroup2-ReallyLongGroupNameHere register: add_member_check check_mode: true @@ -133,6 +142,7 @@ add: - my_user_1 - '{{ test_users.results[2].sid }}' + - MyGroup2-ReallyLongGroupNameHere register: add_member - name: get result of add members to a group @@ -146,9 +156,10 @@ assert: that: - add_member is changed - - add_member_actual.objects[0].member | length == 2 + - add_member_actual.objects[0].member | length == 3 - test_users.results[0].distinguished_name in add_member_actual.objects[0].member - test_users.results[2].distinguished_name in add_member_actual.objects[0].member + - test_group.distinguished_name in add_member_actual.objects[0].member - name: add members to a group - idempotent group: @@ -158,6 +169,7 @@ add: - user_1@{{ domain_realm }} - '{{ test_users.results[2].object_guid }}' + - MyGroup2-ReallyLongGroupNameHere register: add_member_again - name: assert add members to a group - idempotent @@ -186,7 +198,8 @@ assert: that: - remove_member is changed - - remove_member_actual.objects[0].member == test_users.results[2].distinguished_name + - test_users.results[2].distinguished_name in remove_member_actual.objects[0].member + - test_group.distinguished_name in remove_member_actual.objects[0].member - name: remove member from a group - idempotent group: @@ -226,9 +239,10 @@ assert: that: - add_remove_member is changed - - add_remove_member_actual.objects[0].member | length == 2 + - add_remove_member_actual.objects[0].member | length == 3 - test_users.results[0].distinguished_name in add_remove_member_actual.objects[0].member - test_users.results[1].distinguished_name in add_remove_member_actual.objects[0].member + - test_group.distinguished_name in add_remove_member_actual.objects[0].member - name: set members group: diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/roles/test/tasks/main.yml b/ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/roles/test/tasks/main.yml index 86b6d75e9..7e0bb2e2b 100644 --- a/ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/roles/test/tasks/main.yml +++ b/ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/roles/test/tasks/main.yml @@ -95,6 +95,23 @@ - import_tasks: invoke.yml vars: + scenario: LDAP through lookup templates + inventory: + plugin: microsoft.ad.ldap + server: !unsafe '{{ lookup("ansible.builtin.env", "LDAP_SERVER") }}' + username: !unsafe '{{ lookup("ansible.builtin.env", "LDAP_USERNAME") }}' + password: !unsafe '{{ lookup("ansible.builtin.env", "LDAP_PASSWORD") }}' + environment: + LDAP_SERVER: '{{ ldap_server }}' + LDAP_USERNAME: '{{ ldap_user }}' + LDAP_PASSWORD: '{{ ldap_pass }}' + +- name: assert LDAP through lookup templates + assert: + that: *default-assertion + +- import_tasks: invoke.yml + vars: scenario: LDAPS inventory: plugin: microsoft.ad.ldap @@ -434,7 +451,7 @@ nothing_member: this_member: this raw_member: raw - computer_membership: this | map("regex_search", '^CN=(?P<name>.+?)((?<!\\),)', '\g<name>') | flatten + computer_membership: this | microsoft.ad.parse_dn | map(attribute="0.1") compose: host_var: computer_sid groups: diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/membership/ansible.cfg b/ansible_collections/microsoft/ad/tests/integration/targets/membership/ansible.cfg index 3a986973e..50093ac61 100644 --- a/ansible_collections/microsoft/ad/tests/integration/targets/membership/ansible.cfg +++ b/ansible_collections/microsoft/ad/tests/integration/targets/membership/ansible.cfg @@ -1,3 +1,4 @@ [defaults] inventory = inventory.yml retry_files_enabled = False +callback_result_format = yaml diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/membership/tasks/main.yml b/ansible_collections/microsoft/ad/tests/integration/targets/membership/tasks/main.yml index e4fa96c8e..f66985da9 100644 --- a/ansible_collections/microsoft/ad/tests/integration/targets/membership/tasks/main.yml +++ b/ansible_collections/microsoft/ad/tests/integration/targets/membership/tasks/main.yml @@ -26,6 +26,23 @@ Get-ADComputer -Filter { Name -ne 'DC' } -Properties DistinguishedName, Name, Enabled | Select-Object -Property DistinguishedName, Name, Enabled +- name: join domain invalid OU + membership: + dns_domain_name: '{{ domain_realm }}' + domain_admin_user: '{{ domain_user_upn }}' + domain_admin_password: '{{ domain_password }}' + domain_ou_path: CN=Invalid,{{ domain_dn_base }} + state: domain + reboot: true + ignore_errors: true + register: join_domain_invalid_ou + +- name: assert join domain invalid OU + assert: + that: + - join_domain_invalid_ou is failed + - join_domain_invalid_ou.msg.endswith('Check domain_ou_path is pointing to a valid OU in the target domain.') + - name: join domain - check mode membership: dns_domain_name: '{{ domain_realm }}' diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/object/tasks/tests.yml b/ansible_collections/microsoft/ad/tests/integration/targets/object/tasks/tests.yml index b642ce6eb..b18160926 100644 --- a/ansible_collections/microsoft/ad/tests/integration/targets/object/tasks/tests.yml +++ b/ansible_collections/microsoft/ad/tests/integration/targets/object/tasks/tests.yml @@ -441,6 +441,59 @@ - move_ou_actual.objects[0].DistinguishedName == 'OU=TestOU 2,' ~ sub_ous.results[0].distinguished_name - move_ou_actual.objects[0].ProtectedFromAccidentalDeletion == true +- name: do not move object in non default path without path - check + object: + name: TestOU 2 + identity: '{{ sub_ous.results[1].object_guid }}' + type: organizationalUnit + attributes: + set: + description: Test comment + register: dont_move_no_path_check + check_mode: true + +- name: get result of do not move object in non default path without path - check + object_info: + identity: '{{ sub_ous.results[1].object_guid }}' + properties: + - description + register: dont_move_no_path_check_actual + +- name: assert do not move object in non default path without path - check + assert: + that: + - dont_move_no_path_check is changed + - dont_move_no_path_check.distinguished_name == 'OU=TestOU 2,' ~ sub_ous.results[0].distinguished_name + - dont_move_no_path_check_actual.objects[0].Name == 'TestOU 2' + - dont_move_no_path_check_actual.objects[0].DistinguishedName == 'OU=TestOU 2,' ~ sub_ous.results[0].distinguished_name + - dont_move_no_path_check_actual.objects[0].Description == None + +- name: do not move object in non default path without path + object: + name: TestOU 2 + identity: '{{ sub_ous.results[1].object_guid }}' + type: organizationalUnit + attributes: + set: + description: Test comment + register: dont_move_no_path + +- name: get result of do not move object in non default path without path + object_info: + identity: '{{ sub_ous.results[1].object_guid }}' + properties: + - description + register: dont_move_no_path_actual + +- name: assert do not move object in non default path without path - check + assert: + that: + - dont_move_no_path is changed + - dont_move_no_path.distinguished_name == 'OU=TestOU 2,' ~ sub_ous.results[0].distinguished_name + - dont_move_no_path_actual.objects[0].Name == 'TestOU 2' + - dont_move_no_path_actual.objects[0].DistinguishedName == 'OU=TestOU 2,' ~ sub_ous.results[0].distinguished_name + - dont_move_no_path_actual.objects[0].Description == 'Test comment' + - name: remove object that is protected from deletion - check object: name: My, Container @@ -1444,3 +1497,57 @@ assert: that: - not unset_normal_again is changed + +- name: move object back into the default path - check + object: + name: My, Container + identity: '{{ object_identity }}' + type: container + path: microsoft.ad.default_path + register: move_into_default_check + check_mode: true + +- name: get result of move object back into the default path - check + object_info: + identity: '{{ object_identity }}' + register: move_into_default_check_actual + +- name: assert move object back into the default path - check + assert: + that: + - move_into_default_check is changed + - move_into_default_check.distinguished_name == 'CN=My\, Container,' ~ setup_domain_info.output[0].defaultNamingContext + - move_into_default_check_actual.objects[0].DistinguishedName == 'CN=My\, Container,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext + +- name: move object back into the default path + object: + name: My, Container + identity: '{{ object_identity }}' + type: container + path: microsoft.ad.default_path + register: move_into_default + +- name: get result of move object back into the default path + object_info: + identity: '{{ object_identity }}' + register: move_into_default_actual + +- name: assert move object back into the default path + assert: + that: + - move_into_default is changed + - move_into_default.distinguished_name == 'CN=My\, Container,' ~ setup_domain_info.output[0].defaultNamingContext + - move_into_default_actual.objects[0].DistinguishedName == 'CN=My\, Container,' ~ setup_domain_info.output[0].defaultNamingContext + +- name: move object back into the default path - idempotent + object: + name: My, Container + identity: '{{ object_identity }}' + type: container + path: microsoft.ad.default_path + register: move_into_default_again + +- name: assert move object back into the default path - idempotent + assert: + that: + - not move_into_default_again is changed diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/user/tasks/tests.yml b/ansible_collections/microsoft/ad/tests/integration/targets/user/tasks/tests.yml index e06c54959..98718da6f 100644 --- a/ansible_collections/microsoft/ad/tests/integration/targets/user/tasks/tests.yml +++ b/ansible_collections/microsoft/ad/tests/integration/targets/user/tasks/tests.yml @@ -177,11 +177,125 @@ that: - not move_user_again is changed +- name: update user not in default path by identity - check + user: + name: MyUser2 + identity: '{{ object_sid }}' + firstname: first name + register: dont_move_no_path_check + check_mode: true + +- name: get result of update user not in default path by identity - check + object_info: + identity: '{{ object_identity }}' + properties: + - givenName + register: dont_move_no_path_check_actual + check_mode: true + +- name: assert update user not in default path by identity - check + assert: + that: + - dont_move_no_path_check is changed + - dont_move_no_path_check.distinguished_name == 'CN=MyUser2,' ~ setup_domain_info.output[0].defaultNamingContext + - dont_move_no_path_check_actual.objects[0].DistinguishedName == 'CN=MyUser2,' ~ setup_domain_info.output[0].defaultNamingContext + - dont_move_no_path_check_actual.objects[0].Name == 'MyUser2' + - dont_move_no_path_check_actual.objects[0].givenName == None + +- name: update user not in default path by identity + user: + name: MyUser2 + identity: '{{ object_sid }}' + firstname: first name + register: dont_move_no_path + +- name: get result of update user not in default path by identity + object_info: + identity: '{{ object_identity }}' + properties: + - givenName + register: dont_move_no_path_actual + check_mode: true + +- name: assert update user not in default path by identity - check + assert: + that: + - dont_move_no_path is changed + - dont_move_no_path.distinguished_name == 'CN=MyUser2,' ~ setup_domain_info.output[0].defaultNamingContext + - dont_move_no_path_actual.objects[0].DistinguishedName == 'CN=MyUser2,' ~ setup_domain_info.output[0].defaultNamingContext + - dont_move_no_path_actual.objects[0].Name == 'MyUser2' + - dont_move_no_path_actual.objects[0].givenName == 'first name' + +- name: update user without name + user: + identity: MyUser + firstname: first name + register: check_by_identity + +- name: assert update user without name + assert: + that: + - not check_by_identity is changed + - check_by_identity.distinguished_name == 'CN=MyUser2,' ~ setup_domain_info.output[0].defaultNamingContext + +- name: move user back - check + user: + name: MyUser + identity: MyUser + path: microsoft.ad.default_path + register: move_with_path_sentinel_check + check_mode: true + +- name: get result of move user back - check + object_info: + identity: '{{ object_identity }}' + properties: + - sAMAccountName + register: move_with_path_sentinel_check_actual + +- name: assert move user back - check + assert: + that: + - move_with_path_sentinel_check is changed + - move_with_path_sentinel_check.distinguished_name == 'CN=MyUser,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext + - move_with_path_sentinel_check_actual.objects[0].DistinguishedName == 'CN=MyUser2,' ~ setup_domain_info.output[0].defaultNamingContext + - move_with_path_sentinel_check_actual.objects[0].Name == 'MyUser2' + - move_with_path_sentinel_check_actual.objects[0].sAMAccountName == 'MyUser' + - name: move user back user: name: MyUser - identity: MyUser # By sAMAccountName - path: CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + identity: MyUser + path: microsoft.ad.default_path + register: move_with_path_sentinel + +- name: get result of move user back + object_info: + identity: '{{ object_identity }}' + properties: + - sAMAccountName + register: move_with_path_sentinel_actual + +- name: assert move user back + assert: + that: + - move_with_path_sentinel is changed + - move_with_path_sentinel.distinguished_name == 'CN=MyUser,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext + - move_with_path_sentinel_actual.objects[0].DistinguishedName == 'CN=MyUser,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext + - move_with_path_sentinel_actual.objects[0].Name == 'MyUser' + - move_with_path_sentinel_actual.objects[0].sAMAccountName == 'MyUser' + +- name: move user back - idempotent + user: + name: MyUser + identity: MyUser + path: microsoft.ad.default_path + register: move_with_path_sentinel_again + +- name: assert move user back - idempotent + assert: + that: + - not move_with_path_sentinel_again is changed - name: update password from blank - skip for on_create user: @@ -274,6 +388,29 @@ - always_update_password is changed - always_update_password_actual.objects[0].pwdLastSet > change_pass_actual.objects[0].pwdLastSet +- name: expire account for subsequent password check + user: + name: MyUser + attributes: + set: + accountExpires: + type: date_time + value: '2000-01-01T00:00:00.0000000Z' + +# There's no way to validate a password on an expired account, this will +# result in a change even if the password is the same +- name: update password for expired account + user: + name: MyUser + password: Password123! + update_password: when_changed + register: update_password_on_expired_account + +- name: assert update password for expired account + assert: + that: + - update_password_on_expired_account is changed + - name: remove user - check user: name: MyUser @@ -392,6 +529,7 @@ password_never_expires: true path: '{{ setup_domain_info.output[0].defaultNamingContext }}' postal_code: 4000 + protect_from_deletion: false sam_account_name: MyUserSam spn: set: @@ -404,6 +542,9 @@ attributes: set: comment: My comment + accountExpires: + type: date_time + value: '3023-07-31T00:00:00.0000000Z' register: create_user_check check_mode: true @@ -441,6 +582,7 @@ password_never_expires: true path: '{{ setup_domain_info.output[0].defaultNamingContext }}' postal_code: 4000 + protect_from_deletion: false sam_account_name: MyUserSam spn: set: @@ -453,6 +595,9 @@ attributes: set: comment: My comment + accountExpires: + type: date_time + value: '3023-07-31T00:00:00.0000000Z' register: create_user - set_fact: @@ -463,6 +608,7 @@ object_info: identity: '{{ object_identity }}' properties: + - accountExpires - c - comment - company @@ -476,6 +622,7 @@ - objectSid - postalcode - primaryGroupID + - ProtectedFromAccidentalDeletion - pwdLastSet - sAMAccountName - servicePrincipalName @@ -512,6 +659,7 @@ - create_user_actual.objects[0].Description == 'User Description' - create_user_actual.objects[0].DisplayName == 'User Name' - create_user_actual.objects[0].DistinguishedName == 'CN=MyUser,' ~ setup_domain_info.output[0].defaultNamingContext + - create_user_actual.objects[0].accountExpires == 448921440000000000 - create_user_actual.objects[0].c == 'au' - create_user_actual.objects[0].comment == 'My comment' - create_user_actual.objects[0].company == 'Red Hat' @@ -522,6 +670,7 @@ - create_user_actual.objects[0].memberOf == 'CN=Domain Admins,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext - create_user_actual.objects[0].postalcode == '4000' - create_user_actual.objects[0].primaryGroupID == 513 # Domain Users + - create_user_actual.objects[0].ProtectedFromAccidentalDeletion == false - create_user_actual.objects[0].pwdLastSet > 0 - create_user_actual.objects[0].sAMAccountName == 'MyUserSam' - create_user_actual.objects[0].servicePrincipalName == 'HTTP/MyUser' @@ -555,6 +704,7 @@ password_never_expires: true path: '{{ setup_domain_info.output[0].defaultNamingContext }}' postal_code: 4000 + protect_from_deletion: false sam_account_name: MyUserSam spn: set: @@ -568,6 +718,9 @@ attributes: set: comment: My comment + accountExpires: + type: date_time + value: '3023-07-31T00:00:00.0000000Z' register: create_user_again - name: assert create user with extra info - idempotent @@ -575,6 +728,27 @@ that: - not create_user_again is changed +- name: update user by identity + user: + identity: MyUserSam + postal_code: 4001 + register: update_by_identity + +- name: get result of update user by identity + object_info: + identity: '{{ object_identity }}' + properties: + - postalcode + register: update_by_identity_actual + +- name: assert create user with extra info + assert: + that: + - update_by_identity is changed + - update_by_identity_actual.objects | length == 1 + - update_by_identity_actual.objects[0].DistinguishedName == 'CN=MyUser,' ~ setup_domain_info.output[0].defaultNamingContext + - update_by_identity_actual.objects[0].postalcode == '4001' + - name: update user settings - check user: name: MyUser @@ -609,6 +783,9 @@ attributes: set: comment: My Comment + accountExpires: + type: date_time + value: '3023-07-31T00:00:00.0000001Z' register: update_user_check check_mode: true @@ -616,6 +793,7 @@ object_info: identity: '{{ object_identity }}' properties: + - accountExpires - c - comment - company @@ -629,6 +807,7 @@ - objectSid - postalcode - primaryGroupID + - ProtectedFromAccidentalDeletion - pwdLastSet - sAMAccountName - servicePrincipalName @@ -649,6 +828,7 @@ - update_user_check_actual.objects[0].Description == 'User Description' - update_user_check_actual.objects[0].DisplayName == 'User Name' - update_user_check_actual.objects[0].DistinguishedName == 'CN=MyUser,' ~ setup_domain_info.output[0].defaultNamingContext + - update_user_check_actual.objects[0].accountExpires == 448921440000000000 - update_user_check_actual.objects[0].c == 'au' - update_user_check_actual.objects[0].comment == 'My comment' - update_user_check_actual.objects[0].company == 'Red Hat' @@ -657,8 +837,9 @@ - update_user_check_actual.objects[0].mail == 'user@EMAIL.COM' # Domain Users is the primaryGroupID entry - update_user_check_actual.objects[0].memberOf == 'CN=Domain Admins,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext - - update_user_check_actual.objects[0].postalcode == '4000' + - update_user_check_actual.objects[0].postalcode == '4001' - update_user_check_actual.objects[0].primaryGroupID == 513 # Domain Users + - update_user_check_actual.objects[0].ProtectedFromAccidentalDeletion == false - update_user_check_actual.objects[0].pwdLastSet > 0 - update_user_check_actual.objects[0].sAMAccountName == 'MyUserSam' - update_user_check_actual.objects[0].servicePrincipalName == 'HTTP/MyUser' @@ -703,12 +884,16 @@ attributes: set: comment: My Comment + accountExpires: + type: date_time + value: '3023-07-31T00:00:00.0000001Z' register: update_user - name: get result of update user settings object_info: identity: '{{ object_identity }}' properties: + - accountExpires - c - comment - company @@ -757,6 +942,7 @@ - update_user_actual.objects[0].Description == 'User description' - update_user_actual.objects[0].DisplayName == 'User name' - update_user_actual.objects[0].DistinguishedName == 'CN=MyUser,' ~ setup_domain_info.output[0].defaultNamingContext + - update_user_actual.objects[0].accountExpires == 448921440000000001 - update_user_actual.objects[0].c == 'us' - update_user_actual.objects[0].comment == 'My Comment' - update_user_actual.objects[0].company == 'Ansible' @@ -1063,3 +1249,54 @@ that: - spn_add is changed - spn_add_actual.objects[0].servicePrincipalName == ['HTTP/fake', 'HTTP/host.domain:8080', 'HTTP/host'] + +- name: remove user for next test + user: + identity: '{{ object_identity }}' + state: absent + +- name: create user by identity - check + user: + identity: UserId + password: Password123 + state: present + register: create_user_by_id_check + check_mode: true + +- name: get result of create user by identity - check + object_info: + ldap_filter: (sAMAccountName=MyUser) + register: create_user_by_id_actual_check + +- name: assert create user by identity - check + assert: + that: + - create_user_by_id_check is changed + - create_user_by_id_check.distinguished_name == 'CN=UserId,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext + - create_user_by_id_actual_check.objects == [] + +- name: create user by identity + user: + identity: UserId + password: Password123 + state: present + register: create_user_by_id + +- set_fact: + object_identity: '{{ create_user_by_id.object_guid }}' + +- name: get result for create user by identity + object_info: + identity: '{{ object_identity }}' + properties: + - sAMAccountName + register: create_user_by_id_actual + +- name: assert create user by identity + assert: + that: + - create_user_by_id is changed + - create_user_by_id.distinguished_name == 'CN=UserId,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext + - create_user_by_id_actual.objects[0].DistinguishedName == create_user_by_id.distinguished_name + - create_user_by_id_actual.objects[0].Name == 'UserId' + - create_user_by_id_actual.objects[0].sAMAccountName == 'UserId' diff --git a/ansible_collections/microsoft/ad/tests/requirements.yml b/ansible_collections/microsoft/ad/tests/requirements.yml index f5ed6c435..81463ee9f 100644 --- a/ansible_collections/microsoft/ad/tests/requirements.yml +++ b/ansible_collections/microsoft/ad/tests/requirements.yml @@ -1,2 +1,2 @@ collections: -- name: ansible.windows + - name: ansible.windows diff --git a/ansible_collections/microsoft/ad/tests/sanity/ignore-2.13.txt b/ansible_collections/microsoft/ad/tests/sanity/ignore-2.13.txt deleted file mode 100644 index e69de29bb..000000000 --- a/ansible_collections/microsoft/ad/tests/sanity/ignore-2.13.txt +++ /dev/null diff --git a/ansible_collections/microsoft/ad/tests/sanity/ignore-2.12.txt b/ansible_collections/microsoft/ad/tests/sanity/ignore-2.17.txt index e69de29bb..e69de29bb 100644 --- a/ansible_collections/microsoft/ad/tests/sanity/ignore-2.12.txt +++ b/ansible_collections/microsoft/ad/tests/sanity/ignore-2.17.txt diff --git a/ansible_collections/microsoft/ad/tests/unit/compat/__init__.py b/ansible_collections/microsoft/ad/tests/unit/compat/__init__.py deleted file mode 100644 index e69de29bb..000000000 --- a/ansible_collections/microsoft/ad/tests/unit/compat/__init__.py +++ /dev/null diff --git a/ansible_collections/microsoft/ad/tests/unit/compat/mock.py b/ansible_collections/microsoft/ad/tests/unit/compat/mock.py deleted file mode 100644 index 3dcd2687f..000000000 --- a/ansible_collections/microsoft/ad/tests/unit/compat/mock.py +++ /dev/null @@ -1,42 +0,0 @@ -# (c) 2014, Toshio Kuratomi <tkuratomi@ansible.com> -# -# This file is part of Ansible -# -# Ansible is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Ansible is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with Ansible. If not, see <http://www.gnu.org/licenses/>. - -# Make coding more python3-ish -from __future__ import (absolute_import, division, print_function) -__metaclass__ = type - -''' -Compat module for Python3.x's unittest.mock module -''' - -# Python 2.7 - -# Note: Could use the pypi mock library on python3.x as well as python2.x. It -# is the same as the python3 stdlib mock library - -try: - # Allow wildcard import because we really do want to import all of mock's - # symbols into this compat shim - # pylint: disable=wildcard-import,unused-wildcard-import - from unittest.mock import * -except ImportError: - # Python 2 - # pylint: disable=wildcard-import,unused-wildcard-import - try: - from mock import * - except ImportError: - print('You need the mock library installed on python2.x to run tests') diff --git a/ansible_collections/microsoft/ad/tests/unit/mock/__init__.py b/ansible_collections/microsoft/ad/tests/unit/mock/__init__.py deleted file mode 100644 index e69de29bb..000000000 --- a/ansible_collections/microsoft/ad/tests/unit/mock/__init__.py +++ /dev/null diff --git a/ansible_collections/microsoft/ad/tests/unit/mock/loader.py b/ansible_collections/microsoft/ad/tests/unit/mock/loader.py deleted file mode 100644 index e5dff78c1..000000000 --- a/ansible_collections/microsoft/ad/tests/unit/mock/loader.py +++ /dev/null @@ -1,116 +0,0 @@ -# (c) 2012-2014, Michael DeHaan <michael.dehaan@gmail.com> -# -# This file is part of Ansible -# -# Ansible is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Ansible is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with Ansible. If not, see <http://www.gnu.org/licenses/>. - -# Make coding more python3-ish -from __future__ import (absolute_import, division, print_function) -__metaclass__ = type - -import os - -from ansible.errors import AnsibleParserError -from ansible.parsing.dataloader import DataLoader -from ansible.module_utils._text import to_bytes, to_text - - -class DictDataLoader(DataLoader): - - def __init__(self, file_mapping=None): - file_mapping = {} if file_mapping is None else file_mapping - assert type(file_mapping) == dict - - super(DictDataLoader, self).__init__() - - self._file_mapping = file_mapping - self._build_known_directories() - self._vault_secrets = None - - def load_from_file(self, path, cache=True, unsafe=False): - path = to_text(path) - if path in self._file_mapping: - return self.load(self._file_mapping[path], path) - return None - - # TODO: the real _get_file_contents returns a bytestring, so we actually convert the - # unicode/text it's created with to utf-8 - def _get_file_contents(self, file_name): - path = to_text(file_name) - if path in self._file_mapping: - return (to_bytes(self._file_mapping[path]), False) - else: - raise AnsibleParserError("file not found: %s" % path) - - def path_exists(self, path): - path = to_text(path) - return path in self._file_mapping or path in self._known_directories - - def is_file(self, path): - path = to_text(path) - return path in self._file_mapping - - def is_directory(self, path): - path = to_text(path) - return path in self._known_directories - - def list_directory(self, path): - ret = [] - path = to_text(path) - for x in (list(self._file_mapping.keys()) + self._known_directories): - if x.startswith(path): - if os.path.dirname(x) == path: - ret.append(os.path.basename(x)) - return ret - - def is_executable(self, path): - # FIXME: figure out a way to make paths return true for this - return False - - def _add_known_directory(self, directory): - if directory not in self._known_directories: - self._known_directories.append(directory) - - def _build_known_directories(self): - self._known_directories = [] - for path in self._file_mapping: - dirname = os.path.dirname(path) - while dirname not in ('/', ''): - self._add_known_directory(dirname) - dirname = os.path.dirname(dirname) - - def push(self, path, content): - rebuild_dirs = False - if path not in self._file_mapping: - rebuild_dirs = True - - self._file_mapping[path] = content - - if rebuild_dirs: - self._build_known_directories() - - def pop(self, path): - if path in self._file_mapping: - del self._file_mapping[path] - self._build_known_directories() - - def clear(self): - self._file_mapping = dict() - self._known_directories = [] - - def get_basedir(self): - return os.getcwd() - - def set_vault_secrets(self, vault_secrets): - self._vault_secrets = vault_secrets diff --git a/ansible_collections/microsoft/ad/tests/unit/mock/path.py b/ansible_collections/microsoft/ad/tests/unit/mock/path.py deleted file mode 100644 index 4f46ed913..000000000 --- a/ansible_collections/microsoft/ad/tests/unit/mock/path.py +++ /dev/null @@ -1,8 +0,0 @@ -from __future__ import (absolute_import, division, print_function) -__metaclass__ = type - -from ansible_collections.microsoft.ad.tests.unit.compat.mock import MagicMock -from ansible.utils.path import unfrackpath - - -mock_unfrackpath_noop = MagicMock(spec_set=unfrackpath, side_effect=lambda x, *args, **kwargs: x) diff --git a/ansible_collections/microsoft/ad/tests/unit/mock/procenv.py b/ansible_collections/microsoft/ad/tests/unit/mock/procenv.py deleted file mode 100644 index 8652d2689..000000000 --- a/ansible_collections/microsoft/ad/tests/unit/mock/procenv.py +++ /dev/null @@ -1,90 +0,0 @@ -# (c) 2016, Matt Davis <mdavis@ansible.com> -# (c) 2016, Toshio Kuratomi <tkuratomi@ansible.com> -# -# This file is part of Ansible -# -# Ansible is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Ansible is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with Ansible. If not, see <http://www.gnu.org/licenses/>. - -# Make coding more python3-ish -from __future__ import (absolute_import, division, print_function) -__metaclass__ = type - -import sys -import json - -from contextlib import contextmanager -from io import BytesIO, StringIO -from ansible_collections.microsoft.ad.tests.unit.compat import unittest -from ansible.module_utils.six import PY3 -from ansible.module_utils._text import to_bytes - - -@contextmanager -def swap_stdin_and_argv(stdin_data='', argv_data=tuple()): - """ - context manager that temporarily masks the test runner's values for stdin and argv - """ - real_stdin = sys.stdin - real_argv = sys.argv - - if PY3: - fake_stream = StringIO(stdin_data) - fake_stream.buffer = BytesIO(to_bytes(stdin_data)) - else: - fake_stream = BytesIO(to_bytes(stdin_data)) - - try: - sys.stdin = fake_stream - sys.argv = argv_data - - yield - finally: - sys.stdin = real_stdin - sys.argv = real_argv - - -@contextmanager -def swap_stdout(): - """ - context manager that temporarily replaces stdout for tests that need to verify output - """ - old_stdout = sys.stdout - - if PY3: - fake_stream = StringIO() - else: - fake_stream = BytesIO() - - try: - sys.stdout = fake_stream - - yield fake_stream - finally: - sys.stdout = old_stdout - - -class ModuleTestCase(unittest.TestCase): - def setUp(self, module_args=None): - if module_args is None: - module_args = {'_ansible_remote_tmp': '/tmp', '_ansible_keep_remote_files': False} - - args = json.dumps(dict(ANSIBLE_MODULE_ARGS=module_args)) - - # unittest doesn't have a clean place to use a context manager, so we have to enter/exit manually - self.stdin_swap = swap_stdin_and_argv(stdin_data=args) - self.stdin_swap.__enter__() - - def tearDown(self): - # unittest doesn't have a clean place to use a context manager, so we have to enter/exit manually - self.stdin_swap.__exit__(None, None, None) diff --git a/ansible_collections/microsoft/ad/tests/unit/mock/vault_helper.py b/ansible_collections/microsoft/ad/tests/unit/mock/vault_helper.py deleted file mode 100644 index dcce9c784..000000000 --- a/ansible_collections/microsoft/ad/tests/unit/mock/vault_helper.py +++ /dev/null @@ -1,39 +0,0 @@ -# Ansible is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Ansible is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with Ansible. If not, see <http://www.gnu.org/licenses/>. - -# Make coding more python3-ish -from __future__ import (absolute_import, division, print_function) -__metaclass__ = type - -from ansible.module_utils._text import to_bytes - -from ansible.parsing.vault import VaultSecret - - -class TextVaultSecret(VaultSecret): - '''A secret piece of text. ie, a password. Tracks text encoding. - - The text encoding of the text may not be the default text encoding so - we keep track of the encoding so we encode it to the same bytes.''' - - def __init__(self, text, encoding=None, errors=None, _bytes=None): - super(TextVaultSecret, self).__init__() - self.text = text - self.encoding = encoding or 'utf-8' - self._bytes = _bytes - self.errors = errors or 'strict' - - @property - def bytes(self): - '''The text encoded with encoding, unless we specifically set _bytes.''' - return self._bytes or to_bytes(self.text, encoding=self.encoding, errors=self.errors) diff --git a/ansible_collections/microsoft/ad/tests/unit/mock/yaml_helper.py b/ansible_collections/microsoft/ad/tests/unit/mock/yaml_helper.py deleted file mode 100644 index 1ef172159..000000000 --- a/ansible_collections/microsoft/ad/tests/unit/mock/yaml_helper.py +++ /dev/null @@ -1,124 +0,0 @@ -from __future__ import (absolute_import, division, print_function) -__metaclass__ = type - -import io -import yaml - -from ansible.module_utils.six import PY3 -from ansible.parsing.yaml.loader import AnsibleLoader -from ansible.parsing.yaml.dumper import AnsibleDumper - - -class YamlTestUtils(object): - """Mixin class to combine with a unittest.TestCase subclass.""" - def _loader(self, stream): - """Vault related tests will want to override this. - - Vault cases should setup a AnsibleLoader that has the vault password.""" - return AnsibleLoader(stream) - - def _dump_stream(self, obj, stream, dumper=None): - """Dump to a py2-unicode or py3-string stream.""" - if PY3: - return yaml.dump(obj, stream, Dumper=dumper) - else: - return yaml.dump(obj, stream, Dumper=dumper, encoding=None) - - def _dump_string(self, obj, dumper=None): - """Dump to a py2-unicode or py3-string""" - if PY3: - return yaml.dump(obj, Dumper=dumper) - else: - return yaml.dump(obj, Dumper=dumper, encoding=None) - - def _dump_load_cycle(self, obj): - # Each pass though a dump or load revs the 'generation' - # obj to yaml string - string_from_object_dump = self._dump_string(obj, dumper=AnsibleDumper) - - # wrap a stream/file like StringIO around that yaml - stream_from_object_dump = io.StringIO(string_from_object_dump) - loader = self._loader(stream_from_object_dump) - # load the yaml stream to create a new instance of the object (gen 2) - obj_2 = loader.get_data() - - # dump the gen 2 objects directory to strings - string_from_object_dump_2 = self._dump_string(obj_2, - dumper=AnsibleDumper) - - # The gen 1 and gen 2 yaml strings - self.assertEqual(string_from_object_dump, string_from_object_dump_2) - # the gen 1 (orig) and gen 2 py object - self.assertEqual(obj, obj_2) - - # again! gen 3... load strings into py objects - stream_3 = io.StringIO(string_from_object_dump_2) - loader_3 = self._loader(stream_3) - obj_3 = loader_3.get_data() - - string_from_object_dump_3 = self._dump_string(obj_3, dumper=AnsibleDumper) - - self.assertEqual(obj, obj_3) - # should be transitive, but... - self.assertEqual(obj_2, obj_3) - self.assertEqual(string_from_object_dump, string_from_object_dump_3) - - def _old_dump_load_cycle(self, obj): - '''Dump the passed in object to yaml, load it back up, dump again, compare.''' - stream = io.StringIO() - - yaml_string = self._dump_string(obj, dumper=AnsibleDumper) - self._dump_stream(obj, stream, dumper=AnsibleDumper) - - yaml_string_from_stream = stream.getvalue() - - # reset stream - stream.seek(0) - - loader = self._loader(stream) - # loader = AnsibleLoader(stream, vault_password=self.vault_password) - obj_from_stream = loader.get_data() - - stream_from_string = io.StringIO(yaml_string) - loader2 = self._loader(stream_from_string) - # loader2 = AnsibleLoader(stream_from_string, vault_password=self.vault_password) - obj_from_string = loader2.get_data() - - stream_obj_from_stream = io.StringIO() - stream_obj_from_string = io.StringIO() - - if PY3: - yaml.dump(obj_from_stream, stream_obj_from_stream, Dumper=AnsibleDumper) - yaml.dump(obj_from_stream, stream_obj_from_string, Dumper=AnsibleDumper) - else: - yaml.dump(obj_from_stream, stream_obj_from_stream, Dumper=AnsibleDumper, encoding=None) - yaml.dump(obj_from_stream, stream_obj_from_string, Dumper=AnsibleDumper, encoding=None) - - yaml_string_stream_obj_from_stream = stream_obj_from_stream.getvalue() - yaml_string_stream_obj_from_string = stream_obj_from_string.getvalue() - - stream_obj_from_stream.seek(0) - stream_obj_from_string.seek(0) - - if PY3: - yaml_string_obj_from_stream = yaml.dump(obj_from_stream, Dumper=AnsibleDumper) - yaml_string_obj_from_string = yaml.dump(obj_from_string, Dumper=AnsibleDumper) - else: - yaml_string_obj_from_stream = yaml.dump(obj_from_stream, Dumper=AnsibleDumper, encoding=None) - yaml_string_obj_from_string = yaml.dump(obj_from_string, Dumper=AnsibleDumper, encoding=None) - - assert yaml_string == yaml_string_obj_from_stream - assert yaml_string == yaml_string_obj_from_stream == yaml_string_obj_from_string - assert (yaml_string == yaml_string_obj_from_stream == yaml_string_obj_from_string == yaml_string_stream_obj_from_stream == - yaml_string_stream_obj_from_string) - assert obj == obj_from_stream - assert obj == obj_from_string - assert obj == yaml_string_obj_from_stream - assert obj == yaml_string_obj_from_string - assert obj == obj_from_stream == obj_from_string == yaml_string_obj_from_stream == yaml_string_obj_from_string - return {'obj': obj, - 'yaml_string': yaml_string, - 'yaml_string_from_stream': yaml_string_from_stream, - 'obj_from_stream': obj_from_stream, - 'obj_from_string': obj_from_string, - 'yaml_string_obj_from_string': yaml_string_obj_from_string} diff --git a/ansible_collections/microsoft/ad/tests/unit/modules/__init__.py b/ansible_collections/microsoft/ad/tests/unit/modules/__init__.py deleted file mode 100644 index e69de29bb..000000000 --- a/ansible_collections/microsoft/ad/tests/unit/modules/__init__.py +++ /dev/null diff --git a/ansible_collections/microsoft/ad/tests/unit/modules/utils.py b/ansible_collections/microsoft/ad/tests/unit/modules/utils.py deleted file mode 100644 index 8c9633ea9..000000000 --- a/ansible_collections/microsoft/ad/tests/unit/modules/utils.py +++ /dev/null @@ -1,50 +0,0 @@ -from __future__ import (absolute_import, division, print_function) -__metaclass__ = type - -import json - -from ansible_collections.microsoft.ad.tests.unit.compat import unittest -from ansible_collections.microsoft.ad.tests.unit.compat.mock import patch -from ansible.module_utils import basic -from ansible.module_utils._text import to_bytes - - -def set_module_args(args): - if '_ansible_remote_tmp' not in args: - args['_ansible_remote_tmp'] = '/tmp' - if '_ansible_keep_remote_files' not in args: - args['_ansible_keep_remote_files'] = False - - args = json.dumps({'ANSIBLE_MODULE_ARGS': args}) - basic._ANSIBLE_ARGS = to_bytes(args) - - -class AnsibleExitJson(Exception): - pass - - -class AnsibleFailJson(Exception): - pass - - -def exit_json(*args, **kwargs): - if 'changed' not in kwargs: - kwargs['changed'] = False - raise AnsibleExitJson(kwargs) - - -def fail_json(*args, **kwargs): - kwargs['failed'] = True - raise AnsibleFailJson(kwargs) - - -class ModuleTestCase(unittest.TestCase): - - def setUp(self): - self.mock_module = patch.multiple(basic.AnsibleModule, exit_json=exit_json, fail_json=fail_json) - self.mock_module.start() - self.mock_sleep = patch('time.sleep') - self.mock_sleep.start() - set_module_args({}) - self.addCleanup(self.mock_module.stop) - self.addCleanup(self.mock_sleep.stop) diff --git a/ansible_collections/microsoft/ad/tests/unit/plugins/filter/test_ldap_converters.py b/ansible_collections/microsoft/ad/tests/unit/plugins/filter/test_ldap_converters.py index 923d30b31..362e76b4a 100644 --- a/ansible_collections/microsoft/ad/tests/unit/plugins/filter/test_ldap_converters.py +++ b/ansible_collections/microsoft/ad/tests/unit/plugins/filter/test_ldap_converters.py @@ -8,7 +8,13 @@ import uuid import pytest from ansible.errors import AnsibleFilterError -from ansible_collections.microsoft.ad.plugins.filter.ldap_converters import as_sid, as_guid, as_datetime +from ansible_collections.microsoft.ad.plugins.filter.ldap_converters import ( + as_sid, + as_guid, + as_datetime, + dn_escape, + parse_dn, +) @pytest.mark.parametrize("type", ["int", "str", "bytes"]) @@ -37,7 +43,10 @@ def test_as_datetime_with_format() -> None: def test_as_datetime_from_list() -> None: actual = as_datetime([133220025750000000, 133220025751000020]) - assert actual == ["2023-02-27T20:16:15.000000+0000", "2023-02-27T20:16:15.100002+0000"] + assert actual == [ + "2023-02-27T20:16:15.000000+0000", + "2023-02-27T20:16:15.100002+0000", + ] @pytest.mark.parametrize("type", ["str", "bytes"]) @@ -83,10 +92,147 @@ def test_as_sid_from_list() -> None: def test_as_sid_too_little_data_auth_count() -> None: - with pytest.raises(AnsibleFilterError, match="Raw SID bytes must be at least 8 bytes long"): + with pytest.raises( + AnsibleFilterError, match="Raw SID bytes must be at least 8 bytes long" + ): as_sid(b"\x00\x00\x00\x00") def test_as_sid_too_little_data_sub_authorities() -> None: with pytest.raises(AnsibleFilterError, match="Not enough data to unpack SID"): as_sid(b"\x01\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00") + + +@pytest.mark.parametrize( + "value, expected", + [ + ("Sue, Grabbit and Runn", "Sue\\, Grabbit and Runn"), + ("Before\rAfter", "Before\\0DAfter"), + ("Docs, Adatum", "Docs\\, Adatum"), + ("foo,bar", "foo\\,bar"), + ("foo+bar", "foo\\+bar"), + ('foo"bar', 'foo\\"bar'), + ("foo\\bar", "foo\\\\bar"), + ("foo<bar", "foo\\<bar"), + ("foo>bar", "foo\\>bar"), + ("foo;bar", "foo\\;bar"), + (" foo bar", "\\ foo bar"), + ("#foo bar", "\\#foo bar"), + ("# foo bar", "\\# foo bar"), + ("foo bar ", "foo bar\\ "), + ("foo bar ", "foo bar \\ "), + ("foo bar #", "foo bar #"), + ("foo\00bar", "foo\\00bar"), + ("foo\nbar", "foo\\0Abar"), + ("foo\rbar", "foo\\0Dbar"), + ("foo=bar", "foo\\3Dbar"), + ("foo/bar", "foo\\2Fbar"), + ], +) +def test_dn_escape(value: str, expected: str) -> None: + actual = dn_escape(value) + assert actual == expected + + +@pytest.mark.parametrize( + "value, expected", + [ + ( + "", + [], + ), + ( + "CN=foo", + [["CN", "foo"]], + ), + ( + r"CN=foo,DC=bar", + [["CN", "foo"], ["DC", "bar"]], + ), + ( + r"CN=foo, DC=bar", + [["CN", "foo"], ["DC", "bar"]], + ), + ( + r"CN=foo , DC=bar", + [["CN", "foo"], ["DC", "bar"]], + ), + ( + r"CN=foo , DC=bar", + [["CN", "foo"], ["DC", "bar"]], + ), + ( + r"UID=jsmith,DC=example,DC=net", + [["UID", "jsmith"], ["DC", "example"], ["DC", "net"]], + ), + ( + r"OU=Sales+CN=J. Smith,DC=example,DC=net", + [["OU", "Sales", "CN", "J. Smith"], ["DC", "example"], ["DC", "net"]], + ), + ( + r"OU=Sales + CN=J. Smith,DC=example,DC=net", + [["OU", "Sales", "CN", "J. Smith"], ["DC", "example"], ["DC", "net"]], + ), + ( + r"CN=James \"Jim\" Smith\, III,DC=example,DC=net", + [["CN", 'James "Jim" Smith, III'], ["DC", "example"], ["DC", "net"]], + ), + ( + r"CN=Before\0dAfter,DC=example,DC=net", + [["CN", "Before\rAfter"], ["DC", "example"], ["DC", "net"]], + ), + ( + r"1.3.6.1.4.1.1466.0=#FE04024869", + [["1.3.6.1.4.1.1466.0", "\udcfe\x04\x02Hi"]], + ), + ( + r"1.3.6.1.4.1.1466.0 = #FE04024869", + [["1.3.6.1.4.1.1466.0", "\udcfe\x04\x02Hi"]], + ), + ( + r"CN=Lu\C4\8Di\C4\87", + [["CN", "Lučić"]], + ), + ], +) +def test_parse_dn(value: str, expected: t.List[str]) -> None: + actual = parse_dn(value) + + assert actual == expected + + +def test_parse_dn_invalid_attribute_type() -> None: + expected = "Expecting attribute type in RDN entry from 'foo_invalid=test'" + with pytest.raises(AnsibleFilterError, match=expected): + parse_dn("foo_invalid=test") + + +def test_parse_dn_no_attribute_value() -> None: + expected = "Expecting attribute value in RDN entry from ''" + with pytest.raises(AnsibleFilterError, match=expected): + parse_dn("foo=") + + +def test_parse_dn_no_value_after_ava_delimiter() -> None: + expected = "Expecting attribute type in RDN entry from ''" + with pytest.raises(AnsibleFilterError, match=expected): + parse_dn("foo=bar+") + + +def test_parse_dn_unescaped_hash() -> None: + expected = "Found leading # for attribute value but does not match hexstring format at '#bar'" + with pytest.raises(AnsibleFilterError, match=expected): + parse_dn("foo=#bar") + + +@pytest.mark.parametrize("c", ["\00", '"', ";", "<", ">"]) +def test_parse_dn_unescaped_special_char(c: str) -> None: + expected = f"Found unescaped character '{c}' in attribute value at '{c}value'" + with pytest.raises(AnsibleFilterError, match=expected): + parse_dn(f"foo=test{c}value") + + +def test_parse_dn_invalid_attr_value_escape() -> None: + expected = r"Found invalid escape sequence in attribute value at '\\1z" + with pytest.raises(AnsibleFilterError, match=expected): + parse_dn("foo=bar \\1z") diff --git a/ansible_collections/microsoft/ad/tests/utils/shippable/lint.sh b/ansible_collections/microsoft/ad/tests/utils/shippable/lint.sh new file mode 100755 index 000000000..12b5b4cd2 --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/utils/shippable/lint.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +set -o pipefail -eux + +# This is aligned with the galaxy-importer used by AH +# https://github.com/ansible/galaxy-importer/blob/d4b5e6d12088ba452f129f4824bd049be5543358/setup.cfg#L22C4-L22C33 +python -m pip install \ + 'ansible-lint>=6.2.2,<=6.14.3' + +ansible-lint |