diff options
Diffstat (limited to 'ansible_collections/microsoft')
173 files changed, 22476 insertions, 0 deletions
diff --git a/ansible_collections/microsoft/ad/.azure-pipelines/azure-pipelines.yml b/ansible_collections/microsoft/ad/.azure-pipelines/azure-pipelines.yml new file mode 100644 index 000000000..18ecd1b75 --- /dev/null +++ b/ansible_collections/microsoft/ad/.azure-pipelines/azure-pipelines.yml @@ -0,0 +1,160 @@ +trigger: + batch: true + branches: + include: + - main + - stable-* + +pr: + autoCancel: true + branches: + include: + - main + - stable-* + +schedules: + - cron: 0 9 * * * + displayName: Nightly + always: true + branches: + include: + - main + - stable-* + +variables: + - name: checkoutPath + value: ansible_collections/microsoft/ad + - name: coverageBranches + value: main + - name: pipelinesCoverage + value: coverage-powershell + - name: entryPoint + value: tests/utils/shippable/shippable.sh + - name: fetchDepth + value: 0 + +resources: + containers: + - container: default + image: quay.io/ansible/azure-pipelines-test-container:3.0.0 + +pool: Standard + +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 + - stage: Ansible_devel + displayName: Ansible devel + dependsOn: + - Dependencies + jobs: + - template: templates/matrix.yml + parameters: + nameFormat: '{0}' + testFormat: 'devel/{0}' + targets: + - name: Sanity + test: sanity + - name: Units + test: units + - stage: Ansible_2_15 + displayName: Ansible 2.15 + dependsOn: + - Dependencies + jobs: + - template: templates/matrix.yml + parameters: + nameFormat: '{0}' + testFormat: '2.15/{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 + dependsOn: + - Dependencies + jobs: + - template: templates/matrix.yml + parameters: + nameFormat: '{0}' + testFormat: '2.13/{0}' + targets: + - name: Sanity + test: sanity + - name: Units + test: units + - stage: Ansible_2_12 + displayName: Ansible 2.12 + dependsOn: + - Dependencies + jobs: + - template: templates/matrix.yml + parameters: + nameFormat: '{0}' + testFormat: '2.12/{0}' + targets: + - name: Sanity + test: sanity + - name: Units + test: units + - stage: Windows + displayName: Windows + dependsOn: + - Dependencies + jobs: + - template: templates/matrix.yml + parameters: + nameFormat: Server {0} + testFormat: devel/windows/{0} + targets: + - test: 2016 + - test: 2019 + - test: 2022 + groups: + - 1 + - stage: Summary + condition: succeededOrFailed() + dependsOn: + - Ansible_devel + - 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/.azure-pipelines/scripts/aggregate-coverage.sh b/ansible_collections/microsoft/ad/.azure-pipelines/scripts/aggregate-coverage.sh new file mode 100755 index 000000000..f3113dd0a --- /dev/null +++ b/ansible_collections/microsoft/ad/.azure-pipelines/scripts/aggregate-coverage.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Aggregate code coverage results for later processing. + +set -o pipefail -eu + +agent_temp_directory="$1" + +PATH="${PWD}/bin:${PATH}" + +mkdir "${agent_temp_directory}/coverage/" + +options=(--venv --venv-system-site-packages --color -v) + +ansible-test coverage combine --export "${agent_temp_directory}/coverage/" "${options[@]}" + +if ansible-test coverage analyze targets generate --help >/dev/null 2>&1; then + # Only analyze coverage if the installed version of ansible-test supports it. + # Doing so allows this script to work unmodified for multiple Ansible versions. + ansible-test coverage analyze targets generate "${agent_temp_directory}/coverage/coverage-analyze-targets.json" "${options[@]}" +fi diff --git a/ansible_collections/microsoft/ad/.azure-pipelines/scripts/combine-coverage.py b/ansible_collections/microsoft/ad/.azure-pipelines/scripts/combine-coverage.py new file mode 100755 index 000000000..506ade646 --- /dev/null +++ b/ansible_collections/microsoft/ad/.azure-pipelines/scripts/combine-coverage.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python +""" +Combine coverage data from multiple jobs, keeping the data only from the most recent attempt from each job. +Coverage artifacts must be named using the format: "Coverage $(System.JobAttempt) {StableUniqueNameForEachJob}" +The recommended coverage artifact name format is: Coverage $(System.JobAttempt) $(System.StageDisplayName) $(System.JobDisplayName) +Keep in mind that Azure Pipelines does not enforce unique job display names (only names). +It is up to pipeline authors to avoid name collisions when deviating from the recommended format. +""" + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import os +import re +import shutil +import sys + + +def main(): + """Main program entry point.""" + source_directory = sys.argv[1] + + if '/ansible_collections/' in os.getcwd(): + output_path = "tests/output" + else: + output_path = "test/results" + + destination_directory = os.path.join(output_path, 'coverage') + + if not os.path.exists(destination_directory): + os.makedirs(destination_directory) + + jobs = {} + count = 0 + + for name in os.listdir(source_directory): + match = re.search('^Coverage (?P<attempt>[0-9]+) (?P<label>.+)$', name) + label = match.group('label') + attempt = int(match.group('attempt')) + jobs[label] = max(attempt, jobs.get(label, 0)) + + for label, attempt in jobs.items(): + name = 'Coverage {attempt} {label}'.format(label=label, attempt=attempt) + source = os.path.join(source_directory, name) + source_files = os.listdir(source) + + for source_file in source_files: + source_path = os.path.join(source, source_file) + destination_path = os.path.join(destination_directory, source_file + '.' + label) + print('"%s" -> "%s"' % (source_path, destination_path)) + shutil.copyfile(source_path, destination_path) + count += 1 + + print('Coverage file count: %d' % count) + print('##vso[task.setVariable variable=coverageFileCount]%d' % count) + print('##vso[task.setVariable variable=outputPath]%s' % output_path) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/microsoft/ad/.azure-pipelines/scripts/process-results.sh b/ansible_collections/microsoft/ad/.azure-pipelines/scripts/process-results.sh new file mode 100755 index 000000000..f3f1d1bae --- /dev/null +++ b/ansible_collections/microsoft/ad/.azure-pipelines/scripts/process-results.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Check the test results and set variables for use in later steps. + +set -o pipefail -eu + +if [[ "$PWD" =~ /ansible_collections/ ]]; then + output_path="tests/output" +else + output_path="test/results" +fi + +echo "##vso[task.setVariable variable=outputPath]${output_path}" + +if compgen -G "${output_path}"'/junit/*.xml' > /dev/null; then + echo "##vso[task.setVariable variable=haveTestResults]true" +fi + +if compgen -G "${output_path}"'/bot/ansible-test-*' > /dev/null; then + echo "##vso[task.setVariable variable=haveBotResults]true" +fi + +if compgen -G "${output_path}"'/coverage/*' > /dev/null; then + echo "##vso[task.setVariable variable=haveCoverageData]true" +fi diff --git a/ansible_collections/microsoft/ad/.azure-pipelines/scripts/publish-codecov.sh b/ansible_collections/microsoft/ad/.azure-pipelines/scripts/publish-codecov.sh new file mode 100755 index 000000000..6d184f0b8 --- /dev/null +++ b/ansible_collections/microsoft/ad/.azure-pipelines/scripts/publish-codecov.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Upload code coverage reports to codecov.io. +# Multiple coverage files from multiple languages are accepted and aggregated after upload. +# Python coverage, as well as PowerShell and Python stubs can all be uploaded. + +set -o pipefail -eu + +output_path="$1" + +curl --silent --show-error https://ansible-ci-files.s3.us-east-1.amazonaws.com/codecov/codecov.sh > codecov.sh + +for file in "${output_path}"/reports/coverage*.xml; do + name="${file}" + name="${name##*/}" # remove path + name="${name##coverage=}" # remove 'coverage=' prefix if present + name="${name%.xml}" # remove '.xml' suffix + + bash codecov.sh \ + -f "${file}" \ + -n "${name}" \ + -X coveragepy \ + -X gcov \ + -X fix \ + -X search \ + -X xcode \ + || echo "Failed to upload code coverage report to codecov.io: ${file}" +done diff --git a/ansible_collections/microsoft/ad/.azure-pipelines/scripts/report-coverage.sh b/ansible_collections/microsoft/ad/.azure-pipelines/scripts/report-coverage.sh new file mode 100755 index 000000000..050464be3 --- /dev/null +++ b/ansible_collections/microsoft/ad/.azure-pipelines/scripts/report-coverage.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Generate code coverage reports for uploading to Azure Pipelines and codecov.io. + +set -o pipefail -eu + +PATH="${PWD}/bin:${PATH}" + +if ! ansible-test --help >/dev/null 2>&1; then + # Install the devel version of ansible-test for generating code coverage reports. + # This is only used by Ansible Collections, which are typically tested against multiple Ansible versions (in separate jobs). + # Since a version of ansible-test is required that can work the output from multiple older releases, the devel version is used. + pip install https://github.com/ansible/ansible/archive/devel.tar.gz --disable-pip-version-check +fi + +# Generate stubs using docker (if supported) otherwise fall back to using a virtual environment instead. +# The use of docker is required when Powershell code is present, but Ansible 2.12 was the first version to support --docker with coverage. +ansible-test coverage xml --stub --docker --color -v || ansible-test coverage xml --stub --venv --venv-system-site-packages --color -v diff --git a/ansible_collections/microsoft/ad/.azure-pipelines/scripts/run-tests.sh b/ansible_collections/microsoft/ad/.azure-pipelines/scripts/run-tests.sh new file mode 100755 index 000000000..a947fdf01 --- /dev/null +++ b/ansible_collections/microsoft/ad/.azure-pipelines/scripts/run-tests.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Configure the test environment and run the tests. + +set -o pipefail -eu + +entry_point="$1" +test="$2" +read -r -a coverage_branches <<< "$3" # space separated list of branches to run code coverage on for scheduled builds + +export COMMIT_MESSAGE +export COMPLETE +export COVERAGE +export IS_PULL_REQUEST + +if [ "${SYSTEM_PULLREQUEST_TARGETBRANCH:-}" ]; then + IS_PULL_REQUEST=true + COMMIT_MESSAGE=$(git log --format=%B -n 1 HEAD^2) +else + IS_PULL_REQUEST= + COMMIT_MESSAGE=$(git log --format=%B -n 1 HEAD) +fi + +COMPLETE= +COVERAGE= + +if [ "${BUILD_REASON}" = "Schedule" ]; then + COMPLETE=yes + + if printf '%s\n' "${coverage_branches[@]}" | grep -q "^${BUILD_SOURCEBRANCHNAME}$"; then + COVERAGE=yes + fi +fi + +"${entry_point}" "${test}" 2>&1 | "$(dirname "$0")/time-command.py" diff --git a/ansible_collections/microsoft/ad/.azure-pipelines/scripts/time-command.py b/ansible_collections/microsoft/ad/.azure-pipelines/scripts/time-command.py new file mode 100755 index 000000000..5e8eb8d4c --- /dev/null +++ b/ansible_collections/microsoft/ad/.azure-pipelines/scripts/time-command.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python +"""Prepends a relative timestamp to each input line from stdin and writes it to stdout.""" + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import sys +import time + + +def main(): + """Main program entry point.""" + start = time.time() + + sys.stdin.reconfigure(errors='surrogateescape') + sys.stdout.reconfigure(errors='surrogateescape') + + for line in sys.stdin: + seconds = time.time() - start + sys.stdout.write('%02d:%02d %s' % (seconds // 60, seconds % 60, line)) + sys.stdout.flush() + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/microsoft/ad/.azure-pipelines/templates/coverage.yml b/ansible_collections/microsoft/ad/.azure-pipelines/templates/coverage.yml new file mode 100644 index 000000000..4d381c6d6 --- /dev/null +++ b/ansible_collections/microsoft/ad/.azure-pipelines/templates/coverage.yml @@ -0,0 +1,42 @@ +# This template adds a job for processing code coverage data. +# It will upload results to Azure Pipelines and codecov.io. +# Use it from a job stage that completes after all other jobs have completed. +# This can be done by placing it in a separate summary stage that runs after the test stage(s) have completed. + +jobs: + - job: Coverage + displayName: Code Coverage + container: default + workspace: + clean: all + steps: + - checkout: self + fetchDepth: $(fetchDepth) + path: $(checkoutPath) + - task: DownloadPipelineArtifact@2 + displayName: Download Coverage Data + inputs: + path: coverage/ + patterns: "Coverage */*=coverage.combined" + - bash: .azure-pipelines/scripts/combine-coverage.py coverage/ + displayName: Combine Coverage Data + - bash: .azure-pipelines/scripts/report-coverage.sh + displayName: Generate Coverage Report + condition: gt(variables.coverageFileCount, 0) + - task: PublishCodeCoverageResults@1 + inputs: + codeCoverageTool: Cobertura + # Azure Pipelines only accepts a single coverage data file. + # That means only Python or PowerShell coverage can be uploaded, but not both. + # Set the "pipelinesCoverage" variable to determine which type is uploaded. + # Use "coverage" for Python and "coverage-powershell" for PowerShell. + summaryFileLocation: "$(outputPath)/reports/$(pipelinesCoverage).xml" + # Override the root (sources) path specified in the coverage XML files. + # This allows coverage to be reported in Azure Pipelines even if the report was generated in a container. + pathToSources: "$(Agent.BuildDirectory)/$(checkoutPath)" + displayName: Publish to Azure Pipelines + condition: gt(variables.coverageFileCount, 0) + - bash: .azure-pipelines/scripts/publish-codecov.sh "$(outputPath)" + displayName: Publish to codecov.io + condition: gt(variables.coverageFileCount, 0) + continueOnError: true diff --git a/ansible_collections/microsoft/ad/.azure-pipelines/templates/matrix.yml b/ansible_collections/microsoft/ad/.azure-pipelines/templates/matrix.yml new file mode 100644 index 000000000..4e9555dd3 --- /dev/null +++ b/ansible_collections/microsoft/ad/.azure-pipelines/templates/matrix.yml @@ -0,0 +1,55 @@ +# This template uses the provided targets and optional groups to generate a matrix which is then passed to the test template. +# If this matrix template does not provide the required functionality, consider using the test template directly instead. + +parameters: + # A required list of dictionaries, one per test target. + # Each item in the list must contain a "test" or "name" key. + # Both may be provided. If one is omitted, the other will be used. + - name: targets + type: object + + # An optional list of values which will be used to multiply the targets list into a matrix. + # Values can be strings or numbers. + - name: groups + type: object + default: [] + + # An optional format string used to generate the job name. + # - {0} is the name of an item in the targets list. + - name: nameFormat + type: string + default: "{0}" + + # An optional format string used to generate the test name. + # - {0} is the name of an item in the targets list. + - name: testFormat + type: string + default: "{0}" + + # An optional format string used to add the group to the job name. + # {0} is the formatted name of an item in the targets list. + # {{1}} is the group -- be sure to include the double "{{" and "}}". + - name: nameGroupFormat + type: string + default: "{0} - {{1}}" + + # An optional format string used to add the group to the test name. + # {0} is the formatted test of an item in the targets list. + # {{1}} is the group -- be sure to include the double "{{" and "}}". + - name: testGroupFormat + type: string + default: "{0}/{{1}}" + +jobs: + - template: test.yml + parameters: + jobs: + - ${{ if eq(length(parameters.groups), 0) }}: + - ${{ each target in parameters.targets }}: + - name: ${{ format(parameters.nameFormat, coalesce(target.name, target.test)) }} + test: ${{ format(parameters.testFormat, coalesce(target.test, target.name)) }} + - ${{ if not(eq(length(parameters.groups), 0)) }}: + - ${{ each group in parameters.groups }}: + - ${{ each target in parameters.targets }}: + - name: ${{ format(format(parameters.nameGroupFormat, parameters.nameFormat), coalesce(target.name, target.test), group) }} + test: ${{ format(format(parameters.testGroupFormat, parameters.testFormat), coalesce(target.test, target.name), group) }} diff --git a/ansible_collections/microsoft/ad/.azure-pipelines/templates/test.yml b/ansible_collections/microsoft/ad/.azure-pipelines/templates/test.yml new file mode 100644 index 000000000..4f859c981 --- /dev/null +++ b/ansible_collections/microsoft/ad/.azure-pipelines/templates/test.yml @@ -0,0 +1,53 @@ +# This template uses the provided list of jobs to create test one or more test jobs. +# It can be used directly if needed, or through the matrix template. + +parameters: + # A required list of dictionaries, one per test job. + # Each item in the list must contain a "job" and "name" key. + - name: jobs + type: object + +jobs: + - ${{ each job in parameters.jobs }}: + - job: test_${{ replace(replace(replace(replace(job.test, '/', '_'), '.', '_'), '-', '_'), '@', '_') }} + displayName: ${{ job.name }} + container: default + workspace: + clean: all + steps: + - checkout: self + fetchDepth: $(fetchDepth) + path: $(checkoutPath) + - task: DownloadPipelineArtifact@2 + inputs: + artifact: CollectionRequirements + path: $(Agent.TempDirectory) + - bash: | + sudo chown -R "$(whoami)" "${PWD}/../../../ansible_collections" + cp -R "$(Agent.TempDirectory)/ansible_collections"/. "${PWD}/../../../ansible_collections" + displayName: Set up dependencies + - bash: .azure-pipelines/scripts/run-tests.sh "$(entryPoint)" "${{ job.test }}" "$(coverageBranches)" + displayName: Run Tests + - bash: .azure-pipelines/scripts/process-results.sh + condition: succeededOrFailed() + displayName: Process Results + - bash: .azure-pipelines/scripts/aggregate-coverage.sh "$(Agent.TempDirectory)" + condition: eq(variables.haveCoverageData, 'true') + displayName: Aggregate Coverage Data + - task: PublishTestResults@2 + condition: eq(variables.haveTestResults, 'true') + inputs: + testResultsFiles: "$(outputPath)/junit/*.xml" + displayName: Publish Test Results + - task: PublishPipelineArtifact@1 + condition: eq(variables.haveBotResults, 'true') + displayName: Publish Bot Results + inputs: + targetPath: "$(outputPath)/bot/" + artifactName: "Bot $(System.JobAttempt) $(System.StageDisplayName) $(System.JobDisplayName)" + - task: PublishPipelineArtifact@1 + condition: eq(variables.haveCoverageData, 'true') + displayName: Publish Coverage Data + inputs: + targetPath: "$(Agent.TempDirectory)/coverage/" + artifactName: "Coverage $(System.JobAttempt) $(System.StageDisplayName) $(System.JobDisplayName)" diff --git a/ansible_collections/microsoft/ad/.github/workflows/docs-pr.yml b/ansible_collections/microsoft/ad/.github/workflows/docs-pr.yml new file mode 100644 index 000000000..3b89bc6a7 --- /dev/null +++ b/ansible_collections/microsoft/ad/.github/workflows/docs-pr.yml @@ -0,0 +1,67 @@ +name: Collection Docs +concurrency: + group: docs-pr-${{ github.head_ref }} + cancel-in-progress: true +on: + pull_request_target: + types: [opened, synchronize, reopened, closed] + +env: + GHP_BASE_URL: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }} + +jobs: + # Validation job runs a strict build to ensure it will fail CI on any mistakes. + validate-docs: + permissions: + contents: read + name: Validate Ansible Docs + if: github.event.action != 'closed' + uses: ansible-community/github-docs-build/.github/workflows/_shared-docs-build-push.yml@main + with: + artifact-upload: false + init-lenient: false + init-fail-on-error: true + build-ref: refs/pull/${{ github.event.number }}/merge + + # The build job runs with the most lenient settings to ensure the best chance of getting + # a rendered docsite that can be looked at. + build-docs: + permissions: + contents: read + name: Build Ansible Docs + uses: ansible-community/github-docs-build/.github/workflows/_shared-docs-build-pr.yml@main + with: + init-lenient: true + init-fail-on-error: false + + comment: + permissions: + pull-requests: write + runs-on: ubuntu-latest + needs: [build-docs] + name: PR comments + steps: + - name: PR comment + uses: ansible-community/github-docs-build/actions/ansible-docs-build-comment@main + with: + body-includes: '## Docs Build' + reactions: heart + action: ${{ needs.build-docs.outputs.changed != 'true' && 'remove' || '' }} + on-closed-action: remove + on-merged-action: remove + body: | + ## Docs Build 📝 + + Thank you for contribution!✨ + + The docsite for **this PR** is available for download as an artifact from this run: + ${{ needs.build-docs.outputs.artifact-url }} + + You can compare to the docs for the `main` branch here: + ${{ env.GHP_BASE_URL }}/branch/main + + File changes: + + ${{ needs.build-docs.outputs.diff-files-rendered }} + + ${{ needs.build-docs.outputs.diff-rendered }} diff --git a/ansible_collections/microsoft/ad/.github/workflows/docs-push.yml b/ansible_collections/microsoft/ad/.github/workflows/docs-push.yml new file mode 100644 index 000000000..b9cef6529 --- /dev/null +++ b/ansible_collections/microsoft/ad/.github/workflows/docs-push.yml @@ -0,0 +1,35 @@ +name: Collection Docs +concurrency: + group: docs-push-${{ github.sha }} + cancel-in-progress: true +on: + push: + branches: + - main + tags: + - '*' + schedule: + - cron: '0 13 * * *' + +jobs: + build-docs: + permissions: + contents: read + name: Build Ansible Docs + uses: ansible-community/github-docs-build/.github/workflows/_shared-docs-build-push.yml@main + with: + init-lenient: false + init-fail-on-error: true + + publish-docs-gh-pages: + # use to prevent running on forks + if: github.repository == 'ansible-collections/microsoft.ad' + permissions: + contents: write + needs: [build-docs] + name: Publish Ansible Docs + uses: ansible-community/github-docs-build/.github/workflows/_shared-docs-build-publish-gh-pages.yml@main + with: + artifact-name: ${{ needs.build-docs.outputs.artifact-name }} + secrets: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/ansible_collections/microsoft/ad/.gitignore b/ansible_collections/microsoft/ad/.gitignore new file mode 100644 index 000000000..f77a1cf67 --- /dev/null +++ b/ansible_collections/microsoft/ad/.gitignore @@ -0,0 +1,396 @@ + +# Created by https://www.gitignore.io/api/git,linux,pydev,python,windows,pycharm+all,jupyternotebook,vim,webstorm,emacs,dotenv +# Edit at https://www.gitignore.io/?templates=git,linux,pydev,python,windows,pycharm+all,jupyternotebook,vim,webstorm,emacs,dotenv + +### dotenv ### +.env + +### Emacs ### +# -*- mode: gitignore; -*- +*~ +\#*\# +/.emacs.desktop +/.emacs.desktop.lock +*.elc +auto-save-list +tramp +.\#* + +# Org-mode +.org-id-locations +*_archive + +# flymake-mode +*_flymake.* + +# eshell files +/eshell/history +/eshell/lastdir + +# elpa packages +/elpa/ + +# reftex files +*.rel + +# AUCTeX auto folder +/auto/ + +# cask packages +.cask/ +dist/ + +# Flycheck +flycheck_*.el + +# server auth directory +/server/ + +# projectiles files +.projectile + +# directory configuration +.dir-locals.el + +# network security +/network-security.data + + +### Git ### +# Created by git for backups. To disable backups in Git: +# $ git config --global mergetool.keepBackup false +*.orig + +# Created by git when using merge tools for conflicts +*.BACKUP.* +*.BASE.* +*.LOCAL.* +*.REMOTE.* +*_BACKUP_*.txt +*_BASE_*.txt +*_LOCAL_*.txt +*_REMOTE_*.txt + +#!! ERROR: jupyternotebook is undefined. Use list command to see defined gitignore types !!# + +### Linux ### + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### PyCharm+all ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### PyCharm+all Patch ### +# Ignores the whole .idea folder and all .iml files +# See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360 + +.idea/ + +# Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023 + +*.iml +modules.xml +.idea/misc.xml +*.ipr + +# Sonarlint plugin +.idea/sonarlint + +### pydev ### +.pydevproject + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# Mr Developer +.mr.developer.cfg +.project + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +### Vim ### +# Swap +[._]*.s[a-v][a-z] +[._]*.sw[a-p] +[._]s[a-rt-v][a-z] +[._]ss[a-gi-z] +[._]sw[a-p] + +# Session +Session.vim +Sessionx.vim + +# Temporary +.netrwhist +# Auto-generated tag files +tags +# Persistent undo +[._]*.un~ + +### WebStorm ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff + +# Generated files + +# Sensitive or high-churn files + +# Gradle + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake + +# Mongo Explorer plugin + +# File-based project format + +# IntelliJ + +# mpeltonen/sbt-idea plugin + +# JIRA plugin + +# Cursive Clojure plugin + +# Crashlytics plugin (for Android Studio and IntelliJ) + +# Editor-based Rest Client + +# Android studio 3.1+ serialized cache file + +### WebStorm Patch ### +# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 + +# *.iml +# modules.xml +# .idea/misc.xml +# *.ipr + +# Sonarlint plugin +.idea/**/sonarlint/ + +# SonarQube Plugin +.idea/**/sonarIssues.xml + +# Markdown Navigator plugin +.idea/**/markdown-navigator.xml +.idea/**/markdown-navigator/ + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# End of https://www.gitignore.io/api/git,linux,pydev,python,windows,pycharm+all,jupyternotebook,vim,webstorm,emacs,dotenv + +# Changelog cache files +changelogs/.plugin-cache.yaml + +# ansible-test ignores +tests/integration/inventory* +tests/integration/targets/domain_controller/.vagrant +tests/integration/targets/membership/.vagrant +tests/output/
\ No newline at end of file diff --git a/ansible_collections/microsoft/ad/CHANGELOG.rst b/ansible_collections/microsoft/ad/CHANGELOG.rst new file mode 100644 index 000000000..1cd66e250 --- /dev/null +++ b/ansible_collections/microsoft/ad/CHANGELOG.rst @@ -0,0 +1,75 @@ +================================================ +Ansible Microsoft Active Directory Release Notes +================================================ + +.. contents:: Topics + + +v1.2.0 +====== + +Release Summary +--------------- + +Release summary for v1.2.0 + +Minor Changes +------------- + +- microsoft.ad.debug_ldap_client - Add ``dpapi_ng`` to list of packages checked +- microsoft.ad.ldap - Add support for decrypting LAPS encrypted password +- microsoft.ad.ldap - Allow setting LDAP connection and authentication options through environment variables - https://github.com/ansible-collections/microsoft.ad/issues/34 + +Deprecated Features +------------------- + +- Deprecating support for Server 2012 and Server 2012 R2. These OS versions are reaching End of Life status from Microsoft and support for using them in Ansible are nearing its end. + +Bugfixes +-------- + +- group - Fix idempotency check when ``scope: domainlocal`` is set - https://github.com/ansible-collections/microsoft.ad/issues/31 +- microsoft.ad.group - ensure the ``scope`` and ``category`` values are checked as case insensitive to avoid changes when not needed - https://github.com/ansible-collections/microsoft.ad/issues/31 + +v1.1.0 +====== + +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 +-------- + +- microsoft.ad.user - Fix setting ``password_expired`` when creating a new user - https://github.com/ansible-collections/microsoft.ad/issues/25 + +New Plugins +----------- + +Filter +~~~~~~ + +- as_datetime - Converts an LDAP value to a datetime string +- as_guid - Converts an LDAP value to a GUID string +- as_sid - Converts an LDAP value to a Security Identifier string + +Inventory +~~~~~~~~~ + +- ldap - Inventory plugin for Active Directory + +New Modules +----------- + +- debug_ldap_client - Get host information for debugging LDAP connections + +v1.0.0 +====== + +Release Summary +--------------- + +This is the first release of the ``microsoft.ad`` Ansible collection which contains modules that can be used to managed a Microsoft Active Directory environment. diff --git a/ansible_collections/microsoft/ad/CODE_OF_CONDUCT.md b/ansible_collections/microsoft/ad/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..0164155b8 --- /dev/null +++ b/ansible_collections/microsoft/ad/CODE_OF_CONDUCT.md @@ -0,0 +1,3 @@ +# Community Code of Conduct + +Please see the official [Ansible Community Code of Conduct](https://docs.ansible.com/ansible/latest/community/code_of_conduct.html). diff --git a/ansible_collections/microsoft/ad/COPYING b/ansible_collections/microsoft/ad/COPYING new file mode 100644 index 000000000..10926e87f --- /dev/null +++ b/ansible_collections/microsoft/ad/COPYING @@ -0,0 +1,675 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/> + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + <one line to give the program's name and a brief idea of what it does.> + Copyright (C) <year> <name of author> + + This program 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. + + This program 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 this program. If not, see <http://www.gnu.org/licenses/>. + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + <program> Copyright (C) <year> <name of author> + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +<http://www.gnu.org/licenses/>. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +<http://www.gnu.org/philosophy/why-not-lgpl.html>. + diff --git a/ansible_collections/microsoft/ad/FILES.json b/ansible_collections/microsoft/ad/FILES.json new file mode 100644 index 000000000..872564d57 --- /dev/null +++ b/ansible_collections/microsoft/ad/FILES.json @@ -0,0 +1,1748 @@ +{ + "files": [ + { + "name": ".", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": ".azure-pipelines", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": ".azure-pipelines/scripts", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": ".azure-pipelines/scripts/aggregate-coverage.sh", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "820353ffde6fd3ad655118772547549d84ccf0a7ba951e8fb1325f912ef640a0", + "format": 1 + }, + { + "name": ".azure-pipelines/scripts/combine-coverage.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "e34d4e863a65b9f53c4ca8ae37655858969898a949e050e9cb3cb0d5f02342d0", + "format": 1 + }, + { + "name": ".azure-pipelines/scripts/process-results.sh", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "c03d7273fe58882a439b6723e92ab89f1e127772b5ce35aa67c546dd62659741", + "format": 1 + }, + { + "name": ".azure-pipelines/scripts/publish-codecov.sh", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "70c795c8dbca2534b7909b17911630b7afaa693bbd7154e63a51340bc8b28dad", + "format": 1 + }, + { + "name": ".azure-pipelines/scripts/report-coverage.sh", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "ac95befdb1df1de3c24fc33d13860ab728c85717e7b0a3ff44efd2de69b39760", + "format": 1 + }, + { + "name": ".azure-pipelines/scripts/run-tests.sh", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "cb08a3ec5715b00d476ae6d63ca22e11a9ad8887239439937d2a7ea342e5a623", + "format": 1 + }, + { + "name": ".azure-pipelines/scripts/time-command.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "0232f415efeb583ddff907c058986963b775441eaf129d7162aee0acb0d36834", + "format": 1 + }, + { + "name": ".azure-pipelines/templates", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": ".azure-pipelines/templates/coverage.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "825e1b353858bee17c0d2635dde8587db161cc8b64ba5f0ee3cf5f93d3665316", + "format": 1 + }, + { + "name": ".azure-pipelines/templates/matrix.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "4fb0d3ffb2125d5806c7597e4f9d4b2af69cf8c337e9d57803081eddd4a6b081", + "format": 1 + }, + { + "name": ".azure-pipelines/templates/test.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "c0e4fe43b02900122ba055fff4a117b553a37ed5797b66162487fed41d9881c3", + "format": 1 + }, + { + "name": ".azure-pipelines/azure-pipelines.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "7399f9af28ee502b0427a620c8a10226bd48e7dc9fd30b861496e4ec348ea630", + "format": 1 + }, + { + "name": ".github", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": ".github/workflows", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": ".github/workflows/docs-pr.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "391d92c0465a8e47effc66a9cf324f29d0c4e2a930f88a51e3b59f293f3bd5af", + "format": 1 + }, + { + "name": ".github/workflows/docs-push.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "88c9ec3537c09586daa9a9a0a3e8492183583e0027b2b30bec8479976fc7a336", + "format": 1 + }, + { + "name": "changelogs", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "changelogs/fragments", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "changelogs/fragments/.keep", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "format": 1 + }, + { + "name": "changelogs/changelog.yaml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "befa64184037082f1e72a8b49a73d517b83de70d910e77d52223c808df295dee", + "format": 1 + }, + { + "name": "changelogs/config.yaml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "9295eb29396e7bddcfbbf9a29d55e74967d93d81e1746acffff3a125ca673f0f", + "format": 1 + }, + { + "name": "docs", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "docs/docsite", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "docs/docsite/rst", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "docs/docsite/rst/guide_attributes.rst", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "a42f374f72fec7ecdb4cb7a7d110cede6a29fe62263e3dc48e078b2f1803d9e5", + "format": 1 + }, + { + "name": "docs/docsite/rst/guide_ldap_connection.rst", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "53537bac987f50ddddd23033632dd28e77022b88f4dcb00a0c3b93ed7c07b925", + "format": 1 + }, + { + "name": "docs/docsite/rst/guide_ldap_inventory.rst", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "6b909ba926c43f767ff77a5f47dc6a3bc942d8ecfcda07ddfb4f53fdf1d1a4e6", + "format": 1 + }, + { + "name": "docs/docsite/rst/guide_list_values.rst", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "9feb54a92891d63c91cfa084d64d5bc486afce9e726f07227cdeec3fb94f738e", + "format": 1 + }, + { + "name": "docs/docsite/rst/guide_migration.rst", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "fe1668d6d2b28633ee9e78394327f846f028e7448901df8040633c5900be9cd4", + "format": 1 + }, + { + "name": "docs/docsite/extra-docs.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "66b108d94b81a74952e688b940e53c14f7b8b8576ae03e2eca26b0a534398a17", + "format": 1 + }, + { + "name": "docs/docsite/links.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "7ff3fa1af7839cec431231c6c99a99b3f26abb71fd8274be688637d1df900e88", + "format": 1 + }, + { + "name": "meta", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "meta/runtime.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "630851c61449d7c47813d060147d38bed3d3bc3737c32cede676adef5e68cfb6", + "format": 1 + }, + { + "name": "plugins", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "plugins/action", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "plugins/action/__init__.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "format": 1 + }, + { + "name": "plugins/action/debug_ldap_client.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "1688b0ec07534b5afd3ef63991d32ffe19ddfb712f40048ad28d6814a0c22b7c", + "format": 1 + }, + { + "name": "plugins/action/domain.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "6e989c9f1f4ecd67419de47b000a05fced5165a4f741d3fdf1bd5bfb9bae7d81", + "format": 1 + }, + { + "name": "plugins/action/domain_controller.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "6e989c9f1f4ecd67419de47b000a05fced5165a4f741d3fdf1bd5bfb9bae7d81", + "format": 1 + }, + { + "name": "plugins/action/membership.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "3176e29f1620f61680295e854d0f3d9d7d6a0a8e0bf6cf337712aaeb5c569cd4", + "format": 1 + }, + { + "name": "plugins/doc_fragments", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "plugins/doc_fragments/ad_object.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "b1c50c545cbd6c458b8973bfd48d8ee5e3f6852544d54d900b12557603f4fe88", + "format": 1 + }, + { + "name": "plugins/doc_fragments/ldap_connection.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "a75d7898bffabee8ee47c0bf9a350cd1b3967f8fe866fa5e61f7ce91e36b198d", + "format": 1 + }, + { + "name": "plugins/filter", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "plugins/filter/as_datetime.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "f8dccb8976ae2accdbd2069278deedae239bfda4202d86740d2f686dfb5a03f1", + "format": 1 + }, + { + "name": "plugins/filter/as_guid.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "ebffb3dd6af47aa0c718e040a2409b8683a20976d5fff127763fbc49890138f4", + "format": 1 + }, + { + "name": "plugins/filter/as_sid.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "3f9cc11cd5913ef72931ea2a99c5ed6c8201a01c36c0711c3560e292d7a47b66", + "format": 1 + }, + { + "name": "plugins/filter/ldap_converters.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "d7b6b285070d2f08396625bebca108d2b7b37589f0fee1e19cf29f5b91976637", + "format": 1 + }, + { + "name": "plugins/inventory", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "plugins/inventory/__init__.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "format": 1 + }, + { + "name": "plugins/inventory/ldap.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "4c66b492ae0f756b8072b735a504d0fe86a4bde9e6643263905b373d964a83f7", + "format": 1 + }, + { + "name": "plugins/module_utils", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "plugins/module_utils/_ADObject.psm1", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "907b6fddd20ac29beb5da47804bc36390a28c96aaf21d7dd234b0b427a4f4d5c", + "format": 1 + }, + { + "name": "plugins/modules", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "plugins/modules/computer.ps1", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "54ab2fb411ef15a43c2304f1c9b5f720b727d07c0148db65ff07e9a1a812b57a", + "format": 1 + }, + { + "name": "plugins/modules/computer.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "e80c917f2d27024de019aab613539e06393c2b091fc28d4d6f8d8ff4d2f2fc12", + "format": 1 + }, + { + "name": "plugins/modules/debug_ldap_client.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "e9a60118a94ad625894e615a19a5e2653349cd09176fff3036a968d687b06e1c", + "format": 1 + }, + { + "name": "plugins/modules/domain.ps1", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "2960ca5201277ac075b879a24114ee604c84b7354d308692feda8f467926615a", + "format": 1 + }, + { + "name": "plugins/modules/domain.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "e9d147e546b0106819f8701a8a042a16a90f096230bb3e3b45c2c7897427fa06", + "format": 1 + }, + { + "name": "plugins/modules/domain_controller.ps1", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "8db3753ab81069f9a1eab6de1ae817a69ff8e12c7746b7ed64fe2b42f11a8dd4", + "format": 1 + }, + { + "name": "plugins/modules/domain_controller.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "4ca6e453049d7aa6db5c6308c9ca27776643f79eebe7c25acb2483ccd09d99e9", + "format": 1 + }, + { + "name": "plugins/modules/group.ps1", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "ff138faeab60823fe1deb45c9a40b44691f47710ac074fa62a3f7370536307ac", + "format": 1 + }, + { + "name": "plugins/modules/group.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "0d5798ef2f80469552235dc54c41425fa1d39f3aea8def7eb73b8983ec75e1bb", + "format": 1 + }, + { + "name": "plugins/modules/membership.ps1", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "338694bd0ef4b7fb67f57c53f5fbfcacdb8ff15cda7a0ecdb60840b1dfed427a", + "format": 1 + }, + { + "name": "plugins/modules/membership.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "69f0ff59fcbc0b522cd92a603ba7cf6f7e40367d2a85bbfb9091951f5b332969", + "format": 1 + }, + { + "name": "plugins/modules/object.ps1", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "c76b5a815608b200bd5112b8196fd09800d010283a85b105a4aa7c25b09622f6", + "format": 1 + }, + { + "name": "plugins/modules/object.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "339532821d58909eba0a0e3282523c56f84daea36859a4c821f52cf667188deb", + "format": 1 + }, + { + "name": "plugins/modules/object_info.ps1", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "befcb40f361de744801e9d13e00c54db5c7a4fd89737c1739523dd50eb17deeb", + "format": 1 + }, + { + "name": "plugins/modules/object_info.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "e92106b16dad378e7a3f05d4ab069fca10fe13c06c70ba3fdf75b5f5e9cbcfab", + "format": 1 + }, + { + "name": "plugins/modules/offline_join.ps1", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "ccfed79a3703f486dd116dff9b602e7261a1ff64d513800aa15e31ba5bf0a52b", + "format": 1 + }, + { + "name": "plugins/modules/offline_join.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "626c3ae19c6e13a972a48487f32a96dcacdfe29d9da43dec0263d6e6576cc224", + "format": 1 + }, + { + "name": "plugins/modules/ou.ps1", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "890945f581fbfb1f7a2a9f1d8e9960ec70ca19e731f8fa90e5b91dc52b484e24", + "format": 1 + }, + { + "name": "plugins/modules/ou.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "334d42e543c9b9c917acbdb626474aa16d323ef6dc91b75b502b06441c87b5eb", + "format": 1 + }, + { + "name": "plugins/modules/user.ps1", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "63ffbeed02bfea6595f388ef80f4ddb85df345075a6eaf0842df3c8bf618cb08", + "format": 1 + }, + { + "name": "plugins/modules/user.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "945c8482ca609248030b2c6785564f46b8be956956686091470991645bf86395", + "format": 1 + }, + { + "name": "plugins/plugin_utils", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "plugins/plugin_utils/_ldap", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "plugins/plugin_utils/_ldap/__init__.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "ad0f00bc721225328b0f97c6bc395ae2806c467d7a6a93cb314df565fda57d69", + "format": 1 + }, + { + "name": "plugins/plugin_utils/_ldap/_authentication.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "7cbaa9ff03da75c480f58524fef3521ca22cfbeadfd8c30f7b120101e95ba9e3", + "format": 1 + }, + { + "name": "plugins/plugin_utils/_ldap/_certificate.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "da99e82078e82b3018cddd742d63141225dc16d6e02b3daf3a00e9f858060acd", + "format": 1 + }, + { + "name": "plugins/plugin_utils/_ldap/_lookup.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "1858d282c91e805ec71833c9599d2ebbe6d3ed0fd7bde990bec7b3cded23b743", + "format": 1 + }, + { + "name": "plugins/plugin_utils/_ldap/client.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "56375a268f8daa0390e975af95e39fa7d3a81068ee2b1cf68ac13b309430f1ba", + "format": 1 + }, + { + "name": "plugins/plugin_utils/_ldap/laps.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "cebbbab803af4de70436d729715b1e7e1b5883c343c57835b75b5b59e03b412e", + "format": 1 + }, + { + "name": "plugins/plugin_utils/_ldap/schema.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "fd5d1d3d098d93a6b8cda09f9c02ed92aaec02d19db227e5177b710f964eef60", + "format": 1 + }, + { + "name": "plugins/plugin_utils/__init__.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "format": 1 + }, + { + "name": "plugins/plugin_utils/_module_with_reboot.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "86be9d66a96d18ba18bd5b65ba042f4b68fcb60283b7fecd6243deac572c4f50", + "format": 1 + }, + { + "name": "plugins/plugin_utils/_reboot.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "43d3eb09ce0d547129e900ffbd4c47380e85dd74b540f0f548da9ffdd3f6c9e5", + "format": 1 + }, + { + "name": "tests", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/computer", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/computer/meta", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/computer/meta/main.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "0da663766985b62e49d9614c40b6955f6cc637ed6bc06172c30373f4ac1939a3", + "format": 1 + }, + { + "name": "tests/integration/targets/computer/tasks", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/computer/tasks/main.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "4f2685ee21832488a926c284bf1935ea8a2ee6635149be446891c217d810e499", + "format": 1 + }, + { + "name": "tests/integration/targets/computer/tasks/tests.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "7b6720028a85a9a96681ee25cb42546a3a3949e752c08153cd7cad349207b589", + "format": 1 + }, + { + "name": "tests/integration/targets/computer/aliases", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "dc703b2aafe0e1f7932183c6e082324f41e33d5c7f52daa9451093099293a682", + "format": 1 + }, + { + "name": "tests/integration/targets/debug_ldap_client", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/debug_ldap_client/tasks", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/debug_ldap_client/tasks/main.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "84343c539ad7201a5331e9bb261415de9aba24258e5a209694adeb3b70564114", + "format": 1 + }, + { + "name": "tests/integration/targets/debug_ldap_client/aliases", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "dc703b2aafe0e1f7932183c6e082324f41e33d5c7f52daa9451093099293a682", + "format": 1 + }, + { + "name": "tests/integration/targets/domain", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/domain/meta", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/domain/meta/main.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "986c5ec301a2150e04f0722571f8ed636fab09e92dc2db4b0ad3371a3b95896c", + "format": 1 + }, + { + "name": "tests/integration/targets/domain/tasks", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/domain/tasks/main.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "ae2385e433266b26f3bab4c1611fb8c36a69a7de6088310c7e729b6ee2509736", + "format": 1 + }, + { + "name": "tests/integration/targets/domain/tasks/test.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "4f86e593e66cecd7a57e1561cd3559f3c380c1bbf49ec28473cda9de18725b6b", + "format": 1 + }, + { + "name": "tests/integration/targets/domain/aliases", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "dc703b2aafe0e1f7932183c6e082324f41e33d5c7f52daa9451093099293a682", + "format": 1 + }, + { + "name": "tests/integration/targets/domain_controller", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/domain_controller/tasks", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/domain_controller/tasks/main.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "3ba719753c737f0a66c76a965e22251cc15c3145cf093052e0a9a6ef78a431db", + "format": 1 + }, + { + "name": "tests/integration/targets/domain_controller/README.md", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "896ce7cc6eb7d3e1b8fe21d6767a910ff45681b07761e21e9b98d1eea1a5d08f", + "format": 1 + }, + { + "name": "tests/integration/targets/domain_controller/Vagrantfile", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "5bed4aabaf614b96ef6d9f6a89bf0873ee8c6d36faa1ccd12121527b74986d3f", + "format": 1 + }, + { + "name": "tests/integration/targets/domain_controller/aliases", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "dde1e703301f0d11990651509d20f81e97e080514a7f551493082b247a106901", + "format": 1 + }, + { + "name": "tests/integration/targets/domain_controller/ansible.cfg", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "a5afe6b77e8ad47ec4f53c1c38e7fee337f993a92256b1283cf4cdd51aea7567", + "format": 1 + }, + { + "name": "tests/integration/targets/domain_controller/inventory.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "ee31b8cee13c86e8346379bd1e19ebe611e6b69fecdb1ee57228ecf893b4150b", + "format": 1 + }, + { + "name": "tests/integration/targets/domain_controller/setup.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "46067c501422c328070fcb868b8545297cd12359a66e8e2abfa9c13f6344b414", + "format": 1 + }, + { + "name": "tests/integration/targets/domain_controller/test.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "66b99196cb688609659cd9d0db1124f44667b82b18e91585741e249563a95f50", + "format": 1 + }, + { + "name": "tests/integration/targets/group", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/group/meta", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/group/meta/main.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "0da663766985b62e49d9614c40b6955f6cc637ed6bc06172c30373f4ac1939a3", + "format": 1 + }, + { + "name": "tests/integration/targets/group/tasks", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/group/tasks/main.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "9160cd9ae21fd15b5498f925d0451063bb0eefa61a2cd52d7fc2a61ce031dda6", + "format": 1 + }, + { + "name": "tests/integration/targets/group/tasks/tests.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "e9a9d54179215e7639c947d075997844a0eaf1c09eb1de56ae94dc1f38c3ba88", + "format": 1 + }, + { + "name": "tests/integration/targets/group/aliases", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "dc703b2aafe0e1f7932183c6e082324f41e33d5c7f52daa9451093099293a682", + "format": 1 + }, + { + "name": "tests/integration/targets/inventory_ldap", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/inventory_ldap/roles", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/inventory_ldap/roles/setup_certificate", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/inventory_ldap/roles/setup_certificate/files", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/inventory_ldap/roles/setup_certificate/files/generate_cert.sh", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "3924b3c82aa2c8ee26cffcbf1d5cc5c59607e02f1342389f7e525cf0c9e27e56", + "format": 1 + }, + { + "name": "tests/integration/targets/inventory_ldap/roles/setup_certificate/handlers", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/inventory_ldap/roles/setup_certificate/handlers/main.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "0599fadaee910f452c68426d5d5bd3ec57424a26e94e37b67878fcde10920f53", + "format": 1 + }, + { + "name": "tests/integration/targets/inventory_ldap/roles/setup_certificate/tasks", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/inventory_ldap/roles/setup_certificate/tasks/main.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "9eae29e24eadb5fe0dde1f5c5540b31ab974d8ddca4d24098477d96b41e6eadd", + "format": 1 + }, + { + "name": "tests/integration/targets/inventory_ldap/roles/test", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/inventory_ldap/roles/test/tasks", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/inventory_ldap/roles/test/tasks/invoke.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "9fde27b75d27c25f9ba64b8339487cb18d9c0cb0cb43c1bedf499eb2aa2506ac", + "format": 1 + }, + { + "name": "tests/integration/targets/inventory_ldap/roles/test/tasks/main.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "55f188a5f6be5795223b7457054a2bae83449e65d723148807d45a8cdfc39366", + "format": 1 + }, + { + "name": "tests/integration/targets/inventory_ldap/aliases", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "53106e09406a952209f4513bad201d054bcf6e604d44776d9e1c163414029f5e", + "format": 1 + }, + { + "name": "tests/integration/targets/inventory_ldap/main.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "daaa090b72ae48582a792c9b5e091871d1859882f359aed4662b10b4de2dd15d", + "format": 1 + }, + { + "name": "tests/integration/targets/inventory_ldap/runme.sh", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "107f53d8b977a1e5364cd28695f76bfd325d3121bcbf2032369de7125ebd4c86", + "format": 1 + }, + { + "name": "tests/integration/targets/membership", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/membership/tasks", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/membership/tasks/main.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "13948a7fc5cdb40bbb3bd8ffddfc7b4f2cb8a69334ff7c81bef8bcc138904684", + "format": 1 + }, + { + "name": "tests/integration/targets/membership/README.md", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "696adc7e258add1142b30cacf8b90d1256a832e8b3ae00c4bd424d28d0055547", + "format": 1 + }, + { + "name": "tests/integration/targets/membership/Vagrantfile", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "5bed4aabaf614b96ef6d9f6a89bf0873ee8c6d36faa1ccd12121527b74986d3f", + "format": 1 + }, + { + "name": "tests/integration/targets/membership/aliases", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "77319bc13cb3c9beefee283ef38eb571f34ef9f12f05f5fdf2a089e83ef3b552", + "format": 1 + }, + { + "name": "tests/integration/targets/membership/ansible.cfg", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "a5afe6b77e8ad47ec4f53c1c38e7fee337f993a92256b1283cf4cdd51aea7567", + "format": 1 + }, + { + "name": "tests/integration/targets/membership/inventory.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "cf87c8019a2c20f41693100ed18cb27848efa14dcdc087dec3dced9ff1654932", + "format": 1 + }, + { + "name": "tests/integration/targets/membership/setup.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "46067c501422c328070fcb868b8545297cd12359a66e8e2abfa9c13f6344b414", + "format": 1 + }, + { + "name": "tests/integration/targets/membership/test.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "712db591d9c0dbc0991bc67489d6bccd6a0bfb55509cf7ce5a283bedd085ab0a", + "format": 1 + }, + { + "name": "tests/integration/targets/object", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/object/defaults", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/object/defaults/main.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "aad5e669be49182cfc267be0bd38ae247f9e90b7fe59a550c9a03c8089c8abcb", + "format": 1 + }, + { + "name": "tests/integration/targets/object/meta", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/object/meta/main.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "0da663766985b62e49d9614c40b6955f6cc637ed6bc06172c30373f4ac1939a3", + "format": 1 + }, + { + "name": "tests/integration/targets/object/tasks", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/object/tasks/main.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "1f773ee3b4160cdf90fd6d6ff951cae303d1fbe619635fb702ae6902a98d5442", + "format": 1 + }, + { + "name": "tests/integration/targets/object/tasks/tests.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "b462b1e4acdd0d3f2bc0edf8e3144222e6c7a329f94cd0ceded45146f78808a8", + "format": 1 + }, + { + "name": "tests/integration/targets/object/aliases", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "dc703b2aafe0e1f7932183c6e082324f41e33d5c7f52daa9451093099293a682", + "format": 1 + }, + { + "name": "tests/integration/targets/object_info", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/object_info/handlers", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/object_info/handlers/main.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "8885caa2143e632ff11e8ce726c3e7bd8b167561b69a8a2ae62b2ff70b4644e6", + "format": 1 + }, + { + "name": "tests/integration/targets/object_info/meta", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/object_info/meta/main.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "0da663766985b62e49d9614c40b6955f6cc637ed6bc06172c30373f4ac1939a3", + "format": 1 + }, + { + "name": "tests/integration/targets/object_info/tasks", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/object_info/tasks/main.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "0d81c1751c7ef0e913a8a7fe45bfa36854fdf90cd1cdaeced9d2e431b4b02c08", + "format": 1 + }, + { + "name": "tests/integration/targets/object_info/aliases", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "dc703b2aafe0e1f7932183c6e082324f41e33d5c7f52daa9451093099293a682", + "format": 1 + }, + { + "name": "tests/integration/targets/offline_join", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/offline_join/meta", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/offline_join/meta/main.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "0da663766985b62e49d9614c40b6955f6cc637ed6bc06172c30373f4ac1939a3", + "format": 1 + }, + { + "name": "tests/integration/targets/offline_join/tasks", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/offline_join/tasks/main.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "53aea8716df7c78918240b7dd50fb92b7c6fea5c0101255b79e9b2abd3f2a7d5", + "format": 1 + }, + { + "name": "tests/integration/targets/offline_join/tasks/tests.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "cc709c2cca11f0e76ae7693fb179f300c02b2c279f4cfaf48ffa957b637f1986", + "format": 1 + }, + { + "name": "tests/integration/targets/offline_join/aliases", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "dc703b2aafe0e1f7932183c6e082324f41e33d5c7f52daa9451093099293a682", + "format": 1 + }, + { + "name": "tests/integration/targets/ou", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/ou/meta", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/ou/meta/main.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "0da663766985b62e49d9614c40b6955f6cc637ed6bc06172c30373f4ac1939a3", + "format": 1 + }, + { + "name": "tests/integration/targets/ou/tasks", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/ou/tasks/main.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "9a7e08b271dbd7e19901d6abc18463931c4a1a290caa1841bc2ff68da36329c2", + "format": 1 + }, + { + "name": "tests/integration/targets/ou/tasks/tests.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "7af0a8c25fd6ca592956f33ad115cdc1641ca5346ac85a103b8accf1cb5d40ee", + "format": 1 + }, + { + "name": "tests/integration/targets/ou/aliases", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "dc703b2aafe0e1f7932183c6e082324f41e33d5c7f52daa9451093099293a682", + "format": 1 + }, + { + "name": "tests/integration/targets/setup_domain", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/setup_domain/defaults", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/setup_domain/defaults/main.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "91d85dfd2a8b4e522f9e834d4f6a6fefd35b983a273027bf360cb3c03eda071d", + "format": 1 + }, + { + "name": "tests/integration/targets/setup_domain/tasks", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/setup_domain/tasks/main.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "62cf4506bab0896011c52847382cbf30c471358af6cf395073cfc70df9d89ede", + "format": 1 + }, + { + "name": "tests/integration/targets/setup_domain/tasks/setup.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "1eda14d5ec3666c461fc36120a7ebf00e566932c0ef4807a884a85c27eca6936", + "format": 1 + }, + { + "name": "tests/integration/targets/user", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/user/meta", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/user/meta/main.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "0da663766985b62e49d9614c40b6955f6cc637ed6bc06172c30373f4ac1939a3", + "format": 1 + }, + { + "name": "tests/integration/targets/user/tasks", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/user/tasks/main.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "41d7371fe7bd605c1209097306973af088eebec304b42db53c38501d15540ebd", + "format": 1 + }, + { + "name": "tests/integration/targets/user/tasks/tests.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "1d07f872d4a225641a65b58c26a14aa936f77138eff1c8ad0b5a833861282e1a", + "format": 1 + }, + { + "name": "tests/integration/targets/user/aliases", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "dc703b2aafe0e1f7932183c6e082324f41e33d5c7f52daa9451093099293a682", + "format": 1 + }, + { + "name": "tests/integration/windows-integration.controller.requirements.txt", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "c1511cbe17947a028657532cd820d1a3083e017b1d778d29a33926dc448ee3d1", + "format": 1 + }, + { + "name": "tests/sanity", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "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", + "chksum_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "format": 1 + }, + { + "name": "tests/sanity/ignore-2.15.txt", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "format": 1 + }, + { + "name": "tests/sanity/ignore-2.16.txt", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "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", + "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", + "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, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/unit/plugins/filter", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/unit/plugins/filter/__init__.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "format": 1 + }, + { + "name": "tests/unit/plugins/filter/test_ldap_converters.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "38a2a3ea0ebbfc3239d208f4311344fb010549cd3a7654efe3909d1d64197e65", + "format": 1 + }, + { + "name": "tests/unit/plugins/plugin_utils", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/unit/plugins/plugin_utils/_ldap", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/unit/plugins/plugin_utils/_ldap/__init__.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "format": 1 + }, + { + "name": "tests/unit/plugins/plugin_utils/_ldap/test_certificate.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "326a84a81c57d91b8cc959425be61f32cdcbd0c1bc1172c31d35efd91a7cea8f", + "format": 1 + }, + { + "name": "tests/unit/plugins/plugin_utils/_ldap/test_laps.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "e0af303cb8627951a1d0c56b50b8cbb2ca001e219f70eaff8fe7627bfa0cceaf", + "format": 1 + }, + { + "name": "tests/unit/plugins/plugin_utils/_ldap/test_schema.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "d101d59e0489b0302a66ce0bc719b088543244d42dbbe52947eacab55f509544", + "format": 1 + }, + { + "name": "tests/unit/plugins/plugin_utils/__init__.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "format": 1 + }, + { + "name": "tests/unit/plugins/__init__.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "format": 1 + }, + { + "name": "tests/unit/__init__.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "format": 1 + }, + { + "name": "tests/unit/conftest.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "d53fa7efbf09b1639ac13de398a0bc326fdb0ee3d45643782d0b232d838cf277", + "format": 1 + }, + { + "name": "tests/unit/requirements.txt", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "8fc5167ed8e62407d8478aea33da189361bb9973a0a38d32426f044a92acef81", + "format": 1 + }, + { + "name": "tests/utils", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/utils/shippable", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/utils/shippable/sanity.sh", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "0583bfdf73fd78acd74f91917fb5ef6e9c49949a0868917624d0469bca4d9e08", + "format": 1 + }, + { + "name": "tests/utils/shippable/shippable.sh", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "76c4bc006b7727d1e7769327498ba6ea55249f00c503a582bca2af5e2ba949c5", + "format": 1 + }, + { + "name": "tests/utils/shippable/units.sh", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "56861f0a1bb5277793d5fa6b53a696e360069bf30d6aeac27d7b2d297965de6f", + "format": 1 + }, + { + "name": "tests/utils/shippable/windows.sh", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "a4ba589649e222c8edece983838c0601cf3309505cfb243a8461add3066c8e24", + "format": 1 + }, + { + "name": "tests/requirements.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "b79e882cd4f6bc6d9f6ba519dfd6fda8cc029782089d00f7b85ed24f4bb50bb7", + "format": 1 + }, + { + "name": ".gitignore", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "0039fe591d9f12f0fb24c7a9a50745d96627f3a8a4cb73d536801dcc220e9b3b", + "format": 1 + }, + { + "name": "CHANGELOG.rst", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "84b11100ae6883e41c8ebeed2d1ecf0eda432b7e69bbe30389bcb7059902ac0d", + "format": 1 + }, + { + "name": "CODE_OF_CONDUCT.md", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "14ec928234a7ed52bf8b458d31e4862335111e477e4dbe7fb543686c24115140", + "format": 1 + }, + { + "name": "COPYING", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "0ae0485a5bd37a63e63603596417e4eb0e653334fa6c7f932ca3a0e85d4af227", + "format": 1 + }, + { + "name": "README.md", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "6f7c024d3f1ddee4c8684eeb67277f01dbd3aec9105868865865ec647c871de4", + "format": 1 + }, + { + "name": "bindep.txt", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "17d9295ac76c5832dd2b6c5cd7d896887c2f61a37094f20ffa84750a52ed40b6", + "format": 1 + }, + { + "name": "codecov.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "26db67130ad8012ae45796321b9a7b101e2d24087658d4cdbc8e4acce5c1e9ac", + "format": 1 + }, + { + "name": "requirements.txt", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "b95f76f543c0b502c7d27048d57217466973c61406419674148d46c30718fa35", + "format": 1 + } + ], + "format": 1 +}
\ No newline at end of file diff --git a/ansible_collections/microsoft/ad/MANIFEST.json b/ansible_collections/microsoft/ad/MANIFEST.json new file mode 100644 index 000000000..6bf9c8e98 --- /dev/null +++ b/ansible_collections/microsoft/ad/MANIFEST.json @@ -0,0 +1,32 @@ +{ + "collection_info": { + "namespace": "microsoft", + "name": "ad", + "version": "1.2.0", + "authors": [ + "Jordan Borean @jborean93", + "Matt Davis @nitzmahone" + ], + "readme": "README.md", + "tags": [ + "active_directory", + "windows" + ], + "description": "Ansible collection for Active Directory management\n", + "license": [], + "license_file": "COPYING", + "dependencies": {}, + "repository": "https://github.com/ansible-collections/microsoft.ad", + "documentation": "https://docs.ansible.com/ansible/devel/collections/microsoft/ad", + "homepage": "https://github.com/ansible-collections/microsoft.ad", + "issues": "https://github.com/ansible-collections/microsoft.ad/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc" + }, + "file_manifest_file": { + "name": "FILES.json", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "48f8bf20d14aee4afc464d2b0f6937fdf921a633aeb35294baa728d08ba7d2d2", + "format": 1 + }, + "format": 1 +}
\ No newline at end of file diff --git a/ansible_collections/microsoft/ad/README.md b/ansible_collections/microsoft/ad/README.md new file mode 100644 index 000000000..2136ab761 --- /dev/null +++ b/ansible_collections/microsoft/ad/README.md @@ -0,0 +1,112 @@ +# Ansible Collection: microsoft.ad + +[![Build Status](https://dev.azure.com/ansible/microsoft.ad/_apis/build/status/CI?branchName=main)](https://dev.azure.com/ansible/microsoft.ad/_build/latest?definitionId=24&branchName=main) +[![codecov](https://codecov.io/gh/ansible-collections/microsoft.ad/branch/main/graph/badge.svg)](https://codecov.io/gh/ansible-collections/microsoft.ad) + +The `microsoft.ad` collection includes the plugins supported by Ansible to help the management of Microsoft Active Directory. + +## Ansible version compatibility + +This collection has been tested against following Ansible versions: **>=2.12**. + +Plugins and modules within a collection may be tested with only specific Ansible versions. +A collection may contain metadata that identifies these versions. +PEP440 is the schema used to describe the versions of Ansible. + +## Collection Documentation + +Browsing the [**latest** collection documentation](https://docs.ansible.com/ansible/latest/collections/microsoft/ad) will show docs for the _latest version released in the Ansible package_ not the latest version of the collection released on Galaxy. + +Browsing the [**devel** collection documentation](https://docs.ansible.com/ansible/devel/collections/microsoft/ad) shows docs for the _latest version released on Galaxy_. + +We also separately publish [**latest commit** collection documentation](https://ansible-collections.github.io/microsoft.ad/branch/main/) which shows docs for the _latest commit in the `main` branch_. + +If you use the Ansible package and don't update collections independently, use **latest**, if you install or update this collection directly from Galaxy, use **devel**. If you are looking to contribute, use **latest commit**. + +## Release Policy + +This collection follows semantic versioning (`major`.`minor`.`patch`) which in short means: + +* A `patch` release can only contain bug fixes +* A `minor` release can contain bug fixes, features, and new deprecations +* A `major` release can contain bug fixes, features, new deprecations, removal of features, and other breaking changes + +Deprecated features can be removed only 2 years after the deprecation warning was added. +Once a deprecation warning has lasted for 2 years, they will be removed in the next `major` release. + +## Installation and Usage + +### Installing the Collection from Ansible Galaxy + +Before using the Active Directory collection, you need to install it with the `ansible-galaxy` CLI: + + ansible-galaxy collection install microsoft.ad + +You can also include it in a `requirements.yml` file and install it via `ansible-galaxy collection install -r requirements.yml` using the format: + +```yaml +collections: +- name: microsoft.ad +``` + +## Contributing to this collection + +We welcome community contributions to this collection. If you find problems, please open an issue or create a PR against the [Ansible Active Directory collection repository](https://github.com/ansible-collections/microsoft.ad). See [Contributing to Ansible-maintained collections](https://docs.ansible.com/ansible/devel/community/contributing_maintained_collections.html#contributing-maintained-collections) for details. + +See [Developing modules for Windows](https://docs.ansible.com/ansible/latest/dev_guide/developing_modules_general_windows.html#developing-modules-general-windows) for specifics on Windows modules. + +You can also join us on the ``#ansible-windows`` [libera.chat](https://libera.chat/) IRC channel. + +See the [Ansible Community Guide](https://docs.ansible.com/ansible/latest/community/index.html) for details on contributing to Ansible. + + +### Code of Conduct +This collection follows the Ansible project's +[Code of Conduct](https://docs.ansible.com/ansible/devel/community/code_of_conduct.html). +Please read and familiarize yourself with this document. + + +### Testing with `ansible-test` + +The `tests` directory contains configuration for running sanity and integration tests using [`ansible-test`](https://docs.ansible.com/ansible/latest/dev_guide/testing_integration.html). + +You can run the collection's test suites with the commands: + + ansible-test sanity --docker + ansible-test windows-integration --docker + + +## Publishing New Version + +The current process for publishing new versions of the Microsoft AD Collection is done through a tagged release which triggers a Zuul run. Before the tag is set, the following steps must be done. + +* Update `galaxy.yml` with the new version for the collection. +* 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` + * 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 + * The tag is the version number itself, and should not start with anything + * This will trigger a build and publish the collection to AH and Galaxy + * The Zuul job progress will be listed [here](https://ansible.softwarefactory-project.io/zuul/builds?project=ansible-collections%2Fmicrosoft.ad&skip=0) + +After the version is published, verify it exists on the [Active Directory Galaxy page](https://galaxy.ansible.com/microsoft/ad). + + +## More Information + +For more information about Ansible's Windows integration, join the `#ansible-windows` channel on [libera.chat](https://libera.chat/) IRC, and browse the resources in the [Windows Working Group](https://github.com/ansible/community/wiki/Windows) Community wiki page. + +- [Ansible Collection overview](https://github.com/ansible-collections/overview) +- [Ansible User guide](https://docs.ansible.com/ansible/latest/user_guide/index.html) +- [Ansible Developer guide](https://docs.ansible.com/ansible/latest/dev_guide/index.html) +- [Ansible Community code of conduct](https://docs.ansible.com/ansible/latest/community/code_of_conduct.html) + + +## License + +GNU General Public License v3.0 or later + +See [COPYING](COPYING) to see the full text. diff --git a/ansible_collections/microsoft/ad/bindep.txt b/ansible_collections/microsoft/ad/bindep.txt new file mode 100644 index 000000000..9ceb238b8 --- /dev/null +++ b/ansible_collections/microsoft/ad/bindep.txt @@ -0,0 +1,10 @@ +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +# Needed for the krb5/gssapi libraries +gcc [compile platform:rpm] +krb5-devel [compile platform:rpm] +krb5-libs [platform:rpm] +python3-devel [compile platform:rpm] +python39-devel [compile platform:centos-8 platform:rhel-8] diff --git a/ansible_collections/microsoft/ad/changelogs/changelog.yaml b/ansible_collections/microsoft/ad/changelogs/changelog.yaml new file mode 100644 index 000000000..37b1f8113 --- /dev/null +++ b/ansible_collections/microsoft/ad/changelogs/changelog.yaml @@ -0,0 +1,68 @@ +ancestor: null +releases: + 1.0.0: + changes: + release_summary: This is the first release of the ``microsoft.ad`` Ansible collection + which contains modules that can be used to managed a Microsoft Active Directory + environment. + fragments: + - release-summary.yml + release_date: '2023-02-15' + 1.1.0: + changes: + bugfixes: + - microsoft.ad.user - Fix setting ``password_expired`` when creating a new user + - https://github.com/ansible-collections/microsoft.ad/issues/25 + 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. + + ' + fragments: + - release-1.1.0.yml + - user-password-expired.yml + modules: + - description: Get host information for debugging LDAP connections + name: debug_ldap_client + namespace: '' + plugins: + filter: + - description: Converts an LDAP value to a datetime string + name: as_datetime + namespace: null + - description: Converts an LDAP value to a GUID string + name: as_guid + namespace: null + - description: Converts an LDAP value to a Security Identifier string + name: as_sid + namespace: null + inventory: + - description: Inventory plugin for Active Directory + name: ldap + namespace: null + release_date: '2023-05-02' + 1.2.0: + changes: + bugfixes: + - 'group - Fix idempotency check when ``scope: domainlocal`` is set - https://github.com/ansible-collections/microsoft.ad/issues/31' + - microsoft.ad.group - ensure the ``scope`` and ``category`` values are checked + as case insensitive to avoid changes when not needed - https://github.com/ansible-collections/microsoft.ad/issues/31 + deprecated_features: + - Deprecating support for Server 2012 and Server 2012 R2. These OS versions + are reaching End of Life status from Microsoft and support for using them + in Ansible are nearing its end. + minor_changes: + - microsoft.ad.debug_ldap_client - Add ``dpapi_ng`` to list of packages checked + - microsoft.ad.ldap - Add support for decrypting LAPS encrypted password + - microsoft.ad.ldap - Allow setting LDAP connection and authentication options + through environment variables - https://github.com/ansible-collections/microsoft.ad/issues/34 + release_summary: Release summary for v1.2.0 + fragments: + - group-case-sensitivity-check.yml + - group-domainlocal-scopeinfo.yml + - ldap-env-var.yml + - ldap-laps-decryption.yml + - release_summary.yml + - server2012.yml + release_date: '2023-06-14' diff --git a/ansible_collections/microsoft/ad/changelogs/config.yaml b/ansible_collections/microsoft/ad/changelogs/config.yaml new file mode 100644 index 000000000..fb1af5440 --- /dev/null +++ b/ansible_collections/microsoft/ad/changelogs/config.yaml @@ -0,0 +1,29 @@ +changelog_filename_template: ../CHANGELOG.rst +changelog_filename_version_depth: 0 +changes_file: changelog.yaml +changes_format: combined +keep_fragments: false +mention_ancestor: true +new_plugins_after_name: removed_features +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 +title: Ansible Microsoft Active Directory +trivial_section_name: trivial diff --git a/ansible_collections/microsoft/ad/changelogs/fragments/.keep b/ansible_collections/microsoft/ad/changelogs/fragments/.keep new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/ansible_collections/microsoft/ad/changelogs/fragments/.keep diff --git a/ansible_collections/microsoft/ad/codecov.yml b/ansible_collections/microsoft/ad/codecov.yml new file mode 100644 index 000000000..e77553e59 --- /dev/null +++ b/ansible_collections/microsoft/ad/codecov.yml @@ -0,0 +1,10 @@ +--- +ignore: +- .azure-pipelines/* +- tests/unit/compat/* +- tests/unit/mock/* +- tests/unit/**/conftest.py +- tests/unit/conftest.py + +fixes: +- "/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 new file mode 100644 index 000000000..e69755e97 --- /dev/null +++ b/ansible_collections/microsoft/ad/docs/docsite/extra-docs.yml @@ -0,0 +1,13 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# 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 diff --git a/ansible_collections/microsoft/ad/docs/docsite/links.yml b/ansible_collections/microsoft/ad/docs/docsite/links.yml new file mode 100644 index 000000000..7349c5f6b --- /dev/null +++ b/ansible_collections/microsoft/ad/docs/docsite/links.yml @@ -0,0 +1,25 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +edit_on_github: + repository: ansible-collections/microsoft.ad + branch: main + path_prefix: '' + +extra_links: +- 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' + irc_channels: + - 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 diff --git a/ansible_collections/microsoft/ad/docs/docsite/rst/guide_attributes.rst b/ansible_collections/microsoft/ad/docs/docsite/rst/guide_attributes.rst new file mode 100644 index 000000000..ee53dce64 --- /dev/null +++ b/ansible_collections/microsoft/ad/docs/docsite/rst/guide_attributes.rst @@ -0,0 +1,312 @@ +.. _ansible_collections.microsoft.ad.docsite.guide_attributes: + +**************** +Attributes guide +**************** + +A common use case for modules in this collection is to manage various Active Directory objects, such as users, groups, computers, and more. Some of these options are exposed as direct module options but other attributes might need to be set through the ``attributes`` option common to most modules in this collection. + +.. contents:: + :local: + :depth: 1 + +.. _ansible_collections.microsoft.ad.docsite.guide_attributes.ldap_attributes: + +LDAP Attributes +=============== + +One core component of Microsoft's Active Directory (``AD``) is a Lightweight Directory Access Protocol (``LDAP``) database. This database contains all the information relevant to an AD environment such as users, computers, organizational units, and more. Each object contains a dynamic set of attributes to describe the object and conform to a schema. For example users contain attributes like ``firstName``, ``country``, ``sAMAccountName`` to describe the object itself. Microsoft document all the builtin attributes in AD in their `AD Attribute Schema <https://learn.microsoft.com/en-us/windows/win32/adschema/attributes-all>`_. For example the `SAM-Account-Name attribute <https://learn.microsoft.com/en-us/windows/win32/adschema/a-samaccountname>`_ contains the metadata around this attribute. It includes fields like: + +* ``Ldap-Display-Name`` - The LDAP display name +* ``Syntax`` - The underlying value type that the attribute stores +* ``System-Only`` - Whether the attribute is set by the system, effectively making it read only +* ``Is-Single-Value`` - Whether the attribute value is a single value or an array/list of values + +The ``Ldap-Display-Name`` is the attribute name/key that is referenced by the Ansible module. For example to manage the ``SAM-Account-Name`` attribute, it would be referenced by the key ``sAMAccountName``. Each attribute has at least 1 value associated with it, but some attributes can have multiple values. For example ``sAMAccountName`` is a ``Is-Single-Value`` attribute so only has one value but ``userCert`` can contain multiple values. The ``Active Directory Users and Computers`` snap-in (or ``dsa.msc``) can be used to view these LDAP attributes in the advanced mode. This is useful for seeing existing values as well as what attributes can be set on an object. + +The LDAP schema in AD can also be extended to add custom attributes for an organization. These custom attributes are also supported in the modules in this collection. To get the LDAP schema information for attributes, the following can be run in PowerShell: + +.. code-block:: PowerShell + + Function Get-AttributeMetadata { + [CmdletBinding()] + param ([Parameter(ValueFromPipeline)][string[]]$Name) + + begin { + $schema = (Get-ADRootDSE -Properties subschemaSubentry).subschemaSubentry + $getParams = @{ + SearchBase = $schema + LDAPFilter = '(objectClass=*)' + Properties = 'attributeTypes' + } + $attributes = (Get-ADObject @getParams).attributeTypes + $queried = $false + } + + process { + foreach ($n in $Name) { + $queried = $true + $attributes | Where-Object { + $_ -like "*Name '$n'*" + } + } + } + + end { + if (-not $queried) { + $attributes + } + } + } + + # Display all attributes + Get-AttributeMetadata + + # Get specific attributes + Get-AttributeMetadata -Name sAMAccountName, o, objectGuid + +The output is in the format:: + + ( $ATTRIBUTE_OID NAME '$ATTRIBUTE_NAME' SYNTAX '$TYPE_OID' [SINGLE-VALUE|NO-USER-MODIFICATION] ) + +The ``$TYPE_OID`` specifies the value type that can be used for this attribute. search the OID online for more information. The ``SINGLE-VALUE`` specifies if the attribute can only store 1 value. The ``NO-USER-MODIFICATION`` specifies if the attribute is read only and cannot be set. + +The last example outputs:: + + ( 1.2.840.113556.1.4.221 NAME 'sAMAccountName' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE ) + ( 2.5.4.10 NAME 'o' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' ) + ( 1.2.840.113556.1.4.2 NAME 'objectGUID' SYNTAX '1.3.6.1.4.1.1466.115.121.1.40' SINGLE-VALUE NO-USER-MODIFICATION ) + +This shows the ``sAMAccountName`` is a string that can only have 1 value. The ``o`` attribute is also a string but can store multiple values. The ``objectGUID`` is a byte array value that can only have 1 value and is also read only. + +.. _ansible_collections.microsoft.ad.docsite.guide_attributes.setting_attributes: + +Setting Attributes +================== + +Each module that manages an Active Directory object will have an ``attributes`` option which is used to configure LDAP attributes directly. The dictionary accepts three keys: + +* ``add`` - Adds the attribute values if not present +* ``remove`` - Removes the attribute values if present +* ``set`` - Replaces the existing attribute values with the ones specified. + +Each of these keys contain a dictionary value where the keys are the LDAP attribute names by ``ldapDisplayName`` and their values to set. As an LDAP attribute can contain multiple values, the values specified can either be a single value or a list of values, for example: + +.. code-block:: yaml + + - microsoft.ad.user: + name: MyUser + state: present + attributes: + set: + comment: My Comment + extensionName: + - Extension Value 1 + - Extension Value 2 + - Extension Value 3 + +The above example will set the ``comment`` LDAP attribute of the ``MyUser`` object to the value specified. It will also ensure the ``extensionName`` attribute is set to those three values, removing any other value if present. + +The ``add`` key can be used to ensure the LDAP attribute values specified are added to the Attribute value list. The opposite is true for attributes under the ``remove`` key. Any attributes there will have the values specified removed if they are present on the attribute in question. For example: + +.. code-block:: yaml + + - microsoft.ad.user: + name: MyUser + state: present + attributes: + add: + extensionName: + - Extension Value 1 + - Extension Value 3 + remove: + extensionName: + - Extension Value 2 + +The above example will ensure the ``extensionName`` has the values ``Extension Value 1``, ``Extension Value 3`` and remove ``Extension Value 2`` if it is set. Because ``set`` was not used, any existing values will not be touched unless they are in the ``remove`` entry. + +.. note:: + Only use LDAP attributes that can contain multiple values with ``add`` or ``remove``. Using a ``Is-Single-Value`` attribute will result in undefined behaviour. + +To clear an attribute value, define the attribute under ``set`` and set the value to either null (``~``) or an empty list. For example + +.. code-block:: yaml + + - microsoft.ad.user: + name: MyUser + state: present + attributes: + set: + # Null can either be represented by no value + # or with tilde (~) + comment: ~ + company: + extensionName: [] + +This task will ensure the ``comment``, ``company``, and ``extensionName`` attributes are cleared of any value. + +.. _ansible_collections.microsoft.ad.docsite.guide_attributes.attribute_types: + +Attribute Types +=============== + +There are a few different value types that can be stored in an attribute. +The common types are: + +* Strings +* Integers +* Booleans +* Byte Arrays +* Dates +* Security Descriptors + +Setting a string, integer, or boolean value through an Ansible task is simply done through the YAML syntax, for example: + +.. code-block:: yaml + + string: This is a string + integer: 1 + boolean: true + +.. note:: + Strings are compared in a case sensitive operation, that is ``"String" != "string"``. + +These simple types can also be represented by a dictionary with the keys ``type`` and ``value``. The type key can be set to one of the following: + +* ``bool`` - Value is casted to a boolean +* ``bytes`` - Value is decoded as a base64 string +* ``date_time`` - Value is decoded as an ISO 8601 datetime string +* ``int`` - Value is decoded as an integer +* ``security_descriptor`` - Value is decoded as a SDDL string +* ``string`` - Value is casted to a string +* ``raw`` - Value is used as is - this is the default type used + +This looks like the following: + +.. code-block:: yaml + + - microsoft.ad.user: + name: MyUser + state: present + attributes: + set: + # comment: A raw value that is a string + comment: + type: raw + value: A string + + # userAccountControl: 1234 + userAccountControl: + type: int + value: 1234 + + # extensionName: ['Value 1', 'Value 2'] + extensionName: + - type: raw + value: Value 1 + - type: raw + value: Value 2 + +The complex dictionary value with the ``type`` and ``value`` structure is only really needed for the more complex types listed below. If omitted the value is treated as ``type: raw``. + +Byte Arrays +----------- + +As raw bytes cannot be expressed in YAML, to set an attribute with a byte array value the following format is used: + +.. code-block:: yaml + + - microsoft.ad.user: + name: MyUser + state: present + attributes: + set: + # Attribute with single value + dsaSignature: + type: bytes + value: YmluYXJ5 + # Attribute with multiple values + userCertificate: + - type: bytes + value: Zm9vYmFy + - type: bytes + value: YmFyZm9v + +The value specified here is the bytes encoded as a base64 string. + +The :ref:`ansible.builtin.b64encode filter <ansible_collections.ansible.builtin.b64encode_filter>` can be used to encode strings on the fly, and the :ref:`ansible.builtin.file lookup <ansible_collections.ansible.builtin.file_lookup>` could be used to read data from a file. + + +.. code-block:: yaml + + - vars: + sig_data: "{{ lookup('ansible.builtin.file', '/path/to/my/sig') }}" + microsoft.ad.user: + name: MyUser + state: present + attributes: + set: + # Attribute with single value + dsaSignature: + type: bytes + value: "{{ sig_data | ansible.builtin.b64encode }}" + + +Dates +----- + +Attributes with datetime values are technically integer values but represent a point in time. For ease of use, these entries can be represented as an ISO 8601 extended format datetime and will be internally represented by the integer value. To specify an attribute value in the datetime format, use the same dictionary value structure as above but set the ``type`` to ``date_time``. For example: + +.. code-block:: yaml + + - microsoft.ad.user: + name: MyUser + state: present + attributes: + set: + dateAttributeSingleValue: + type: date_time + value: '2019-09-07T15:50:00+00:00' + dateAttributeMultipleValue: + - type: date_time + value: '2019-09-07T15:50:00Z' + - type: date_time + value: '2019-09-07T11:50:00-04:00' + +Internally the datetime is converted to the UTC time and converted to the number of 100 nanosecond increments since 1601-01-01. This PowerShell snippet shows what is happening internally to get the integer value: + +.. code-block:: PowerShell + + $dt = '2019-09-07T15:50:00Z' + $dtVal = [DateTimeOffset]::ParseExact( + $dt, + [string[]]@("yyyy-MM-dd'T'HH:mm:ss.FFFFFFFK"), + [System.Globalization.CultureInfo]::InvariantCulture, + [System.Globalization.DateTimeStyles]::AssumeUniversal) + $dtVal.UtcDateTime.ToFileTimeUtc() + +.. note:: If no timezone is specified, it is assumed to be in UTC. + +Security Descriptors +-------------------- + +A security descriptor is stored as a byte array in the attribute but the ``security_descriptor`` type can be used to more conveniently represent this value in a playbook. The value specified is the `Security Descriptor Definition Language <https://learn.microsoft.com/en-us/windows/win32/secauthz/security-descriptor-definition-language>`_ (``SDDL``). This string is internally converted to the byte array needed to set the SDDL. An example of setting an attribute of this type is: + +.. code-block:: yaml + + - microsoft.ad.user: + name: MyUser + state: present + attributes: + set: + nTSecurityDescriptor: + type: security_descriptor + value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;WD) + +SDDL strings can be quite complex so building them manually is ill-advised. It is recommended to build a test object in the ``Active Directory Users and Computers`` snap-in (or ``dsa.msc``) and set the security as needed in the ``Security`` tab. From there the SDDL string can be retrieved by doing the following: + +.. code-block:: PowerShell + + $dn = 'CN=ObjectName,DC=domain,DC=test' + $obj = Get-ADObject -Identity $dn -Properties nTSecurityDescriptor + $obj.nTSecurityDescriptor.GetSecurityDescriptorSddlForm('All') diff --git a/ansible_collections/microsoft/ad/docs/docsite/rst/guide_ldap_connection.rst b/ansible_collections/microsoft/ad/docs/docsite/rst/guide_ldap_connection.rst new file mode 100644 index 000000000..60755f00c --- /dev/null +++ b/ansible_collections/microsoft/ad/docs/docsite/rst/guide_ldap_connection.rst @@ -0,0 +1,236 @@ +.. _ansible_collections.microsoft.ad.docsite.guide_ldap_connection: + +********************* +LDAP Connection guide +********************* + +This guide covers information about communicating with an LDAP server, like Microsoft Active Directory, from the Ansible host. Unlike Windows hosts, there are no builtin mechanisms to communicate and authenticate with an LDAP server, so the plugins that run on the Ansible host require some extra configuration to get working. + +.. note:: + This guide covers LDAP communication from the Ansible host. This does not apply to the modules that run on the remote Windows hosts. + +.. contents:: + :local: + :depth: 1 + +.. _ansible_collections.microsoft.ad.docsite.guide_ldap_connection.requirements: + +Requirements +============ + +The LDAP connection code requires the `sansldap <https://pypi.org/project/sansldap/>`_ and `pyspnego <https://pypi.org/project/pyspnego/>`_ libraries. They can be installed using ``pip`` with: + +.. code-block:: shell-session + + $ python3 -m pip install --user \ + 'pyspnego >= 0.8.0' + sansldap + +.. note:: + This guide assumes ``python3`` is the same Python that Ansible uses, see ``ansible --version`` for details on the Python version/location. + +There are also optional dependencies to provide extra features + ++-------------------------+-----------------------------+ +| Feature | Package | ++=========================+=============================+ +| Kerberos Authentication | pyspnego[kerberos] >= 0.8.0 | ++-------------------------+-----------------------------+ +| Server Lookups | dnspython | ++-------------------------+-----------------------------+ +| LAPS Decryption | dpapi-ng | ++-------------------------+-----------------------------+ + +To install all the optional features run: + +.. code-block:: shell-session + + $ python3 -m pip install --user \ + dnspython \ + dpapi-ng \ + 'pyspnego[kerberos] >= 0.8.0' + +The Kerberos authentication components require the Kerberos system libraries to be present. For RPM based systems, these are: + +.. code-block:: shell-session + + $ dnf install gcc python3-devel krb5-libs krb5-devel + +Other Linux distributions require the same packages listed above but they are likely listed under different names than what ``dnf`` uses. + +The :ref:`microsoft.ad.debug_ldap_client <ansible_collections.microsoft.ad.debug_ldap_client_module>`. action plugin can be used to debug the Ansible host setup and its LDAP capabilities. It includes details such as: + +* The Python packages related to LDAP that are installed, or import failure messages if not installed +* The Kerberos host and credential cache information if the Kerberos extras are installed +* The SRV lookup information if ``dnspython`` and Kerberos extras are installed + +To use this module simply run + +.. code-block:: shell-session + + $ ansible localhost -m microsoft.ad.debug_ldap_client + + +.. _ansible_collections.microsoft.ad.docsite.guide_ldap_connection.connection_options: + +Connection options +================== + +Connecting to a Microsoft Active Directory or LDAP server requires information like the domain controller hostname, port, whether to use LDAPS or StartTLS, and authentication information. Some of this information can be retrieved based on the Ansible host environment but can also be manually specified through the plugin options. These options include: + ++---------------+--------------------------------+---------------------------------------------+ +| Option | Default | Purpose | ++===============+================================+=============================================+ +| server | Server lookup through Kerberos | The LDAP server hostname | ++---------------+--------------------------------+---------------------------------------------+ +| port | 389 or 686 if tls_mode=ldaps | The LDAP port | ++---------------+--------------------------------+---------------------------------------------+ +| tls_mode | LDAPS if port=686 else None | TLS details - LDAP, LDAP + StartTLS, LDAPS | ++---------------+--------------------------------+---------------------------------------------+ +| auth_protocol | Negotiate | Authentication protocol | ++---------------+--------------------------------+---------------------------------------------+ +| username | None | Attempts to use Kerberos cache if available | ++---------------+--------------------------------+---------------------------------------------+ +| password | None | Attempts to use Kerberos cache if available | ++---------------+--------------------------------+---------------------------------------------+ + +The server lookup details are described below. The port defaults to ``389`` unless ``tls_mode: ldaps`` is specified. The TLS mode defaults to ``ldaps`` if the port is explicitly set to ``686`` otherwise it defaults to ``389``. The authentication protocol defaults to ``negotiate`` while attempting to use the implicit credential if it's available. + + +.. _ansible_collections.microsoft.ad.docsite.guide_ldap_connection.server_lookup: + +Server lookup +------------- + +If no server option was explicitly set, the plugin will attempt to lookup the LDAP server based on the current environment configuration. This is only possible if: + +* The ``dnspython`` Python package is installed +* The ``pyspnego[kerberos]`` Python package for Kerberos is installed +* The underlying Kerberos library has a ``default_realm`` set in the `MIT krb5.conf <https://web.mit.edu/kerberos/krb5-latest/doc/admin/host_config.html#default-realm>`_ + +If none of the above are true, the connection will fail and an explicit server must be supplied. If all the requirements are satisfied this is the server lookup workflow: + +* The ``default_realm`` of the local Kerberos configuration is retrieved +* A DNS SRV lookup is done for the record ``_ldap._tcp.dc._msdcs.{{ default_realm }}`` +* The DNS records are sorted by priority and weight and the first is selected +* The hostname and port on the selected SRV record are used for the lookup + +.. note:: + If an explicit port is specified, it will take priority over the port returned by the SRV record. + + +.. _ansible_collections.microsoft.ad.docsite.guide_ldap_connection.authentication: + +Authentication +============== + +A critical component of LDAP connections is how the user authenticates itself to the server. The following authentication mechanisms are supported: + ++----------------+---------------------+----------------------------+ +| Authentication | Supports Encryption | Implicit Credential | ++================+=====================+============================+ +| simple | No - TLS needed | Yes - Appears as Anonymous | ++----------------+---------------------+----------------------------+ +| certificate | Yes | No | ++----------------+---------------------+----------------------------+ +| negotiate | Yes | Yes - With Kerberos | ++----------------+---------------------+----------------------------+ +| kerberos | Yes | Yes | ++----------------+---------------------+----------------------------+ +| ntlm | Yes | No | ++----------------+---------------------+----------------------------+ + +Unless otherwise specified, the default authentication protocol used is ``negotiate`` which relies on the ``pyspnego`` library. See :ref:`requirements <ansible_collections.microsoft.ad.docsite.guide_ldap_connection.requirements>` for more information on how to install this requirement. + +Any protocol that does not support encryption must either be used with LDAPS, StartTLS, or they must explicitly disable the encryption checks with the ``encrypt: false`` option. Disabling encryption is not recommended as it will send the credentials without any protection and any of the data exchanged can be seen by anyone. It also requires the target server to allow unencrypted connections as they can reject such connections. + +Implicit credential support documents whether the authentication protocol can authenticate without an explicit ``username`` and ``password`` specified. Currently only ``simple`` and ``negotiate/kerberos`` supports implicit credentials. See each protocol section for more details. + +Simple +------ + +Simple authentication is the most basic authentication protocol supported. It works by sending the username and password in plaintext to the server, similar to HTTP Basic authentication. Microsoft AD requires the username to be the ``sAMAccountName`` or ``userPrincipalName`` of the account but other LDAP implementations require the LDAP ``distinguishedName``. While it is possible to do an anonymous bind when no username or password is specified, it is likely the server will reject any search operations unless it is authenticated with an actual users credentials. Simple authentication is not allowed over a connection that is not protected by TLS. It is possible to allow simple authentication over such connections by disabling the encryption check but this is not recommended. + +.. warning:: + Simple authentication should be avoided unless TLS is used, either through LDAPS or StartTLS. Failure to use use LDAPS will expose the credentials used during the authentication and the subsequent data unprotected from eavesdropping or tampering. + + +Certificate +----------- + +Certificate authentication uses TLS client authentication as part of the TLS handshake to authenticate the user to the host. As it is part of the TLS handshake, it can only be used over an LDAPS connection or with StartTLS. It uses a certificate and certificate key of the user to authenticate as. There are three options that can be used to specify a client certificate and key to use for authentication: + +* ``certificate`` - The certificate, and optionally bundled key +* ``certificate_key`` - The certificate key if not bundled in ``certificate`` +* ``certificate_password`` - The password used to decrypt the certificate key + +The ``certificate`` and ``certificate_key`` can either be a file path to the certificate and key or they can be a string of the PEM encoded certificate/key. The ``certificate`` file path can be a PEM, DER, or PKCS12/PFX encoded certificate with optional key bundle whereas the ``certificate_key`` file path can be a PEM or DER encoded key. If the key inside the PEM, DER, or PKCS12/PFX content is encrypted, the ``certificate_password`` can be used to specify the password used to decrypt the key. + +.. note:: + Setting these options are dependent on the plugin itself, the keys here reflect the option name and not necessarily Ansible variables that can be set and read automatically by a plugin. + + +Negotiate +--------- + +Negotiate authentication is the default authentication protocol used by LDAP connections. It is a combination of both ``kerberos`` and ``ntlm`` with the client negotiating which one to use. It will favor ``kerberos`` if it is available and fallback to ``ntlm`` if not. The ``pyspnego`` Python package provides ``negotiate`` with just ``ntlm`` support, ``kerberos`` support is provided by the ``pyspnego[kerberos]`` extras option. See :ref:`requirements <ansible_collections.microsoft.ad.docsite.guide_ldap_connection.requirements>` for more information on how to install this requirement. + +Kerberos +-------- + +Kerberos authentication is a modern authentication protocol supported by Microsoft AD servers and is the preferred protocol for authentication. It is only available if the ``pyspnego[kerberos]`` extras package is installed and the host has been configured properly. Typically this configuration is done through the `/etc/krb5.conf <https://web.mit.edu/kerberos/krb5-latest/doc/admin/conf_files/krb5_conf.html>`_ file on the system. This guide will not go into configuring the host's Kerberos settings as it is environment specific. + +A good way to ensure the host has been configured to use Kerberos correctly is to ensure the following commands work: + +.. code-block:: shell-session + + $ python -c "import krb5" + $ kinit username@DOMAIN.REALM + $ kvno ldap/dc.domain.realm + +.. note:: + The ``kvno`` command is an MIT krb5 specific command, it is not available on hosts that use Heimdal krb5 like macOS. + +The ``python`` command ensures the required Python libraries have been installed. The ``kinit`` command will retrieve a Kerberos ticket for the user specified and the ``kvno`` command will attempt to retrieve a service ticket for the service principal name (SPN) requested. If both commands work then there is a good chance Kerberos authentication will work with the LDAP connection. + +Using the ``kinit`` command it is possible to set up a credential cache for Ansible to use for authentication. By having a credential retrieved using ``kinit``, it is possible to authenticate with the LDAP server without any explicit username and password set in Ansible. It is still possible to use Kerberos with explicit credentials. + +NTLM +---- + +NTLM authentication is a simple authentication protocol that can be used by itself or as part of the ``negotiate`` fallback if ``kerberos`` is unavailable. Unlike ``kerberos`` support, it does not normally support implicit credentials so typically needs an explicit username and password specified to be used. It requires no extra host configuration and should work once ``pyspnego`` has been installed. + +.. warning:: + While NTLM does support encryption it is considered weak by modern standards. It is recommended to only use NTLM with an LDAPS or StartTLS connection where the stronger encryption and server checks provided by TLS mitigate the weaknesses in NTLM. + + +.. _ansible_collections.microsoft.ad.docsite.guide_ldap_connection.cert_validation: + +Certificate validation +====================== + +Using LDAPS or LDAP over StartTLS will perform a TLS handshake which by default has the client attempting to validate the certificate presented by the server. If the certificate chain cannot be trusted, or the hostname does not match the one being requested the connection will fail with an error indicating why. The default trust store location is dependent on the Python configuration and what SSL library it has been linked to. Typically it would be the OS' default trust store but when in doubt the following Python code can be used to verify the LDAPS certificate. Make sure to change ``hostname`` to the hostname of the LDAP server that should be tested. + +.. code-block:: python + + import socket + import ssl + + hostname = 'dc.domain.com' + port = 636 + context = ssl.create_default_context() + + with socket.create_connection((hostname, port)) as sock: + with context.wrap_socket(sock, server_hostname=hostname) as ssock: + print(ssock.version()) + +The ``ca_cert`` connection option can be used to set an explicit CA bundle to use for verification. This is useful if the CA bundle is not part of the OS store but located somewhere else on the filesystem. The value can be in the form of: + +* a file path to a PEM or DER encoded bundle of certificates +* A directory path that contains several CA certificates in the PEM format following an OpenSSL specific layout as document by `CApath <https://www.openssl.org/docs/manmaster/man3/SSL_CTX_load_verify_locations.html>`_ +* A string containing PEM encoded certificates + +It is also possible to disable certificate verification using the ``cert_validation`` connection option. The default is ``always`` but can be set to ``ignore`` to disable all checks or ``ignore_hostname`` to disable just the hostname check. This can be useful for test environments that use self signed certificates but it should not be used in a production environment. + +.. warning:: + Disabling certificate validation removes a lot of the benefits that TLS offers. There is no way to verify the target server is who it says that it is. 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 new file mode 100644 index 000000000..798775b21 --- /dev/null +++ b/ansible_collections/microsoft/ad/docs/docsite/rst/guide_ldap_inventory.rst @@ -0,0 +1,452 @@ +.. _ansible_collections.microsoft.ad.docsite.guide_ldap_inventory: + +******************** +LDAP Inventory guide +******************** + +This guide covers information about the LDAP inventory plugin included in this collection. This inventory plugin can be used to build an inventory from an LDAP server source, like Microsoft Active Directory. + +.. contents:: + :local: + :depth: 1 + +.. _ansible_collections.microsoft.ad.docsite.guide_ldap_inventory.connection_info: + +Connection info +=============== + +Details on how to configure an LDAP connection can be found under :ref:`the LDAP connection guide <ansible_collections.microsoft.ad.docsite.guide_ldap_connection>`. Each of the connection options described by the plugin documentation are specified in the inventory yml configuration file like the below. + +.. code-block:: yaml + + plugin: microsoft.ad.ldap + + # LDAP connection options can be defined in the yaml config. + auth_protocol: simple + username: UserName + password: MyPassword123 + tls_mode: ldaps + + +.. _ansible_collections.microsoft.ad.docsite.guide_ldap_inventory.attributes: + +Attributes +========== + +The LDAP inventory plugin can be used to set custom facts for each host it retrieves based on the computer object's LDAP attributes. Retrieving custom attributes is done through the ``attributes`` option in the inventory plugin definition. The value is set to one of the three following types: + +* Empty string or null +* A template string +* A dictionary + +.. note:: + While an individual attribute can only be set to one of these types, it is possible to use the different value types for different attributes. + +It is also possible to use the ``compose`` inventory option to use the builtin compose templating provided by inventory plugins but the LDAP attributes must first be requested through the ``attributes`` option and referenced in the ``compose`` template through the host fact the ``attributes`` set it on. + +Empty string or null +-------------------- + +.. code-block:: yaml + + attributes: + comment: + objectSid: '' + ms-Mcs-AdmPwd: + +In this case each of the attribute values will be set as a host fact as they are coerced by the LDAP schema, see :ref:`value types and templating <ansible_collections.microsoft.ad.docsite.guide_ldap_inventory.value_types>`. The name of each fact will be based on the attribute name with ``-`` being replaced by ``_``. In the above example the host facts ``comment``, ``objectSid``, and ``ms_Mcs_AdmPwd`` will be set to the coerced values. + +.. code-block:: yaml + + # ansible-inventory -i microsoft.ad.ldap.yml --host MYHOST --vars --yaml + + ansible_host: MYHOST.domain.com + comment: test comment + ms_Mcs_AdmPwd: Password123! + objectSid: S-1-5-21-1234-1108 + microsoft_ad_distinguished_name: CN=MYHOST,CN=Computers,DC=domain,DC=com + + +Template string +--------------- + +.. code-block:: yaml + + attributes: + comment: this + objectSid: raw | microsoft.ad.as_sid + ms-Mcs-AdmPwd: raw | first + +This format will set the host fact based on the template value specified. Each template is implicitly wrapped with ``{{ ... }}`` and processed through Jinja2 to produce a result. This means the template string can contain filters provided by Ansible and other collections to convert the raw LDAP value into something more useful. The ``this`` variable refers to the coerced LDAP attribute value and ``raw`` refers to a list of base64 encoded byte strings of the raw LDAP attribute value. See :ref:`value types and templating <ansible_collections.microsoft.ad.docsite.guide_ldap_inventory.value_types>` for more information around what can be done inside the templates. Each host fact will be named after the attribute name with ``-`` being replaced by ``_``. In the above example the host facts ``command``, ``objectSid``, and ``ms_Mcs_AdmPwd`` will be set to the template results. + +.. code-block:: yaml + + # ansible-inventory -i microsoft.ad.ldap.yml --host MYHOST --vars --yaml + + ansible_host: MYHOST.domain.com + comment: test comment + ms_Mcs_AdmPwd: UGFzc3dvcmQxMjMh + objectSid: + - S-1-5-21-1234-1108 + microsoft_ad_distinguished_name: CN=MYHOST,CN=Computers,DC=domain,DC=com + + +Dictionary +---------- + +.. code-block:: yaml + + attributes: + comment: + # Jinja2 native types will automatically convert this to a dict as + # the value is a json string. + my_comment: + other_var: this | from_json + objectSid: + sid: raw | microsoft.ad.as_sid | first + ms-Mcs-AdmPwd: + ansible_password: this + +The final value that can be set on each attribute values is a dictionary where the keys are the host facts to set and the value is the template used to dervice the final value. It can be null or an empty string to refer to the LDAP coerced value of that attribute (``this``) or a template string to template a new value based on the requirements at hand. See the above two formats for more info on null/empty string vs a string template value. In the above example there are 4 host facts set: + +* ``my_command`` - the coerced value for the ``comment`` attribute +* ``other_var`` - a dictionary created from the coerced value of ``comment`` if it was a json string +* ``sid`` - the computer SID value as a string derived from ``objectSid`` +* ``ansible_password`` - the LAPS password coerced value derived from ``ms-Mcs-AdmPwd`` + +.. code-block:: yaml + + # ansible-inventory -i microsoft.ad.ldap.yml --host MYHOST --vars --yaml + + ansible_host: MYHOST.domain.com + ansible_password: Password123! + my_comment: + foo: bar + other_var: + foo: bar + sid: S-1-5-21-1234-1108 + microsoft_ad_distinguished_name: CN=MYHOST,CN=Computers,DC=domain,DC=com + +.. note:: + The host fact names are used literally, there are no conversions from ``-`` to ``_`` when using this format. + + +.. _ansible_collections.microsoft.ad.docsite.guide_ldap_inventory.inventory_hostname: + +Inventory hostname +================== + +By default the ``inventory_hostname`` for a found host will be based on the ``name`` LDAP attribute value. If the ``dNSHostName`` attribute is set for the computer account found, it will be set as the ``ansible_host`` fact. To define a custom ``inventory_hostname`` or ``ansible_host`` either set it in the ``attributes`` or ``compose`` plugin option under that key. For example this will set the ``inventory_hostname`` to the value of ``sAMAccountName`` without the ending ``$`` rather than the computer account LDAP ``name`` attribute. + +.. code-block:: yaml + + attributes: + sAMAccountName: + inventory_hostname: sAMAccountName[:-1] + +.. code-block:: yaml + + # ansible-inventory -i microsoft.ad.ldap.yml --host MYHOST --vars --yaml + + ansible_host: MYHOST.domain.com + microsoft_ad_distinguished_name: CN=OtherName,CN=Computers,DC=domain,DC=com + sAMAccountName: MYHOST$ + + +It is also possible to set ``inventory_hostname`` under the ``compose`` key. The following will produce the same output as the above. + +.. code-block:: yaml + + attributes: + sAMAccountName: + + compose: + inventory_hostname: sAMAccountName[:-1] + + +An example of setting a custom ``ansible_host`` fact that is used as the connection host but leaving the default ``inventory_hostname`` of the computer account name is: + +.. code-block:: yaml + + attributes: + sAMAccountName: + ansible_host: sAMAccountName[:-1] + + +.. _ansible_collections.microsoft.ad.docsite.guide_ldap_inventory.value_types: + +Value types and templating +========================== + +Each LDAP attribute value is stored as a list of bytes but the schema supplied in the LDAP database can describe how those raw list of bytes are represented as a proper type, like a string, integer, boolean, etc. Currently only these four types are used when coercing LDAP attribute values + +* Booleans +* Integers +* Bytes +* Strings + +Booleans, integers, and strings are coerced into those specific Python types but bytes are coerced into a base64 string encoding of those bytes. + +.. note:: + The ``objectGuid`` and ``objectSid`` attributes are always coerced into strings representing the security identifier and guid respectively. These are the only attributes that have special coercion rules outside of the LDAP schema syntax. + +LDAP attribute values may also be marked as a a single or multi valued attribute. A single value contains just the coerced value, or ``None/null`` if it has not been set while a multi valued attribute will be set as a list of coerced values. For example the ``comment`` is a single valued string while ``servicePrincipalName`` is a multi valued string. Using this inventory configuration that requests ``comment``, and ``servicePrincipalName`` we get the following inventory host definition: + +.. code-block:: yaml + + plugin: microsoft.ad.ldap + + attributes: + comment: + servicePrincipalName: + + +.. code-block:: yaml + + # ansible-inventory -i microsoft.ad.ldap.yml --host MYHOST --vars --yaml + + ansible_host: MYHOST.domain.com + comment: test comment + servicePrincipalName: + - WSMAN/MYHOST + - WSMAN/MYHOST.domain.com + - TERMSRV/MYHOST + - TERMSRV/MYHOST.domain.com + - RestrictedKrbHost/MYHOST + - HOST/MYHOST + - RestrictedKrbHost/MYHOST.domain.com + - HOST/MYHOST.domain.com + microsoft_ad_distinguished_name: CN=MYHOST,CN=Computers,DC=domain,DC=com + +Some attributes like ``pwdLastSet`` are typically represented as a datetime value but internally are stored as integers. As there is no metadata in the LDAP schema to denote these integer values as datetime objects they will only be coerced into integer values by default. + +The following filters can be used as an easy way to further convert the coerced values into something more readable: + +* :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>` + +An example of these filters being used in the ``attributes`` option can be seen below: + +.. code-block:: yaml + + plugin: microsoft.ad.ldap + + attributes: + pwdLastSet: + password_last_set_int: this + password_last_set_datetime: this | microsoft.ad.as_datetime + +.. code-block:: yaml + + # ansible-inventory -i microsoft.ad.ldap.yml --host MYHOST --vars --yaml + + ansible_host: MYHOST.domain.com + password_last_set_datetime: 2023-02-06T07:39:09.195321+0000 + password_last_set_int: 133201427491953218 + microsoft_ad_distinguished_name: CN=MYHOST,CN=Computers,DC=domain,DC=com + +The templates can also reference other filters that exist outside the collection, like the Ansible builtin ``from_json`` and more. The value is simply what would be placed inside ``{{ ... }}`` during a normal template operation. + +.. note:: + Lookups cannot be used in the attribute value templates, only filters. + +Each template used in the ``attributes`` inventory option can reference the following variables: + +* ``this`` +* ``raw`` +* Any previously defined attributes + +The ``this`` variable refers to the coerced LDAP attribute value while ``raw`` refers to the list of base64 encoded strings representing the raw LDAP value that hasn't been coerced. As each attribute host fact is processed, it is also available in the subsequent templates under that host fact name. Here is an example of a more complex set of attributes: + +.. code-block:: yaml + + plugin: microsoft.ad.ldap + + attributes: + objectSid: + sid: this + sid_raw: raw + sid_raw_filtered: raw | microsoft.ad.as_sid | first + objectGuid: + sAMAccountName: + computer_name: + comment: + comment: this + # Can refer to previously set attributes above + description: computer_name ~ " - " ~ sid ~ " - " ~ objectGuid ~ " - " ~ this + + # Can also be used as a template and refer to the vars retrieved above + compose: + comment2: comment + +.. code-block:: yaml + + # ansible-inventory -i microsoft.ad.ldap.yml --host MYHOST --vars --yaml + + ansible_host: MYHOST.domain.com + comment: test comment + comment2: test comment + computer_name: MYHOST$ + description: MYHOST$ - S-1-5-21-1234-1108 - 51cc490f-1de0-41ae-98ad-dc065d5b33e2 - test comment + objectGuid: 51cc490f-1de0-41ae-98ad-dc065d5b33e2 + sid: S-1-5-21-1234-1108 + sid_raw: + - AQMAAAAAAAUVAAAA0gQAAFQEAAA= + sid_raw_filtered: S-1-5-21-1234-1108 + microsoft_ad_distinguished_name: CN=MYHOST,CN=Computers,DC=domain,DC=com + + +.. _ansible_collections.microsoft.ad.docsite.guide_ldap_inventory.laps: + +LAPS +==== + +Local Administrator Administrator Password Solution (LAPS) can be used to automatically change the password of the local administrator account on domain joined hosts. The LDAP connection plugin can retrieve the LAPS-managed value and assign it as the connection password for the target host. + +There are three different attributes that can be used by LAPS to store the password information: + +* ``ms-Mcs-AdmPwd`` - The legacy LAPS attribute containing the password +* ``msLAPS-Password`` - The Windows LAPS attribute containing the username and password +* ``msLAPS-EncryptedPassword`` - The Windows LAPS attribute containing the encrypted username and password + +If using the legacy LAPS setup, the following will retrieve and assign the connection username and password to the LAPS-managed value: + +.. code-block:: yaml + + plugin: microsoft.ad.ldap + + attributes: + ms-Mcs-AdmPwd: + ansible_user: '"Administrator"' + ansible_password: this + +.. code-block:: yaml + + # ansible-inventory -i microsoft.ad.ldap.yml --host MYHOST --vars --yaml + + ansible_host: MYHOST.domain.com + ansible_password: aR$lmrqK1l622H + ansible_user: Administrator + microsoft_ad_distinguished_name: CN=MYHOST,CN=Computers,DC=domain,DC=com + +.. note:: + Legacy LAPS does not store the username, the above example hardcodes the user name ``Administrator``. + +If using Windows LAPS without encryption, the following will assign the connection username and password to the LAPS-managed values: + +.. code-block:: yaml + + plugin: microsoft.ad.ldap + + attributes: + msLAPS-Password: + ansible_user: (this | from_json).n + ansible_password: (this | from_json).p + raw_example: raw + this_example: this + +.. code-block:: yaml + + # ansible-inventory -i microsoft.ad.ldap.yml --host MYHOST --vars --yaml + + ansible_host: MYHOST.domain.com + ansible_password: AWznso@ZJ+J6p9 + ansible_user: Administrator + microsoft_ad_distinguished_name: CN=MYHOST,CN=Computers,DC=domain,DC=com + raw_example: + - eyJuIjoiQWRtaW5pc3RyYXRvciIsInQiOiIxZDk4MmI0MzdiN2E1YzYiLCJwIjoiQVd6bnNvQFpKK0o2cDkifQ== + this_example: + n: Administrator + p: AWznso@ZJ+J6p9 + t: 1d982b437b7a5c6 + +Unlike Legacy LAPS, the attribute value is a json string that contains the keys: + +* ``n`` - The account name the password was encrypted for +* ``p`` - The password for the account +* ``t`` - The time the password was set encoded as a FILETIME in base16 + +.. note:: + It is recommended to use the ``from_json`` filter (as shown in the example above) on the ``this`` value to ensure consistent behavior in the presence or absence of Jinja2 native type support. + +Getting an encrypted Windows LAPS value requires the ``dpapi-ng`` Python library to be installed. See :ref:`the LDAP connection requirements <ansible_collections.microsoft.ad.docsite.guide_ldap_connection.requirements>` for more information on this optional package and how to debug whether it's installed or not. + +.. note:: + Using Windows LAPS encrypted password is currently an experimental feature. + +With the ``dpapi-ng`` package installed, an authorized LDAP user can decrypt and assign the LAPS-managed username and password to the target host connection as follows: + +.. code-block:: yaml + + plugin: microsoft.ad.ldap + + attributes: + msLAPS-EncryptedPassword: + ansible_user: (this.value | from_json).n + ansible_password: (this.value | from_json).p + raw_example: raw + this_example: this + +.. code-block:: yaml + + # ansible-inventory -i microsoft.ad.ldap.yml --host MYHOST --vars --yaml + + ansible_host: MYHOST.domain.com + ansible_password: 6jr&}yK++{0Q}& + ansible_user: Administrator + microsoft_ad_distinguished_name: CN=MYHOST,CN=Computers,DC=domain,DC=com + raw_example: + - toLZAWR7rgfk... + this_example: + encrypted_value: MIIETgYJKoZI... + flags: 0 + info: '' + update_timestamp: 133281382308674404 + value: '{"n":"Administrator","t":"1d982b607ae7b64","p":"6jr&}yK++{0Q}&"}' + +The ``raw`` value contains the raw base64 encoded value as stored in AD. The ``this`` value contains a dictionary with the following keys: + +* ``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 +* ``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 + +The ``value`` key will only be present if the decryption process was successful. If it failed, the ``debug`` key will be present and contain the reason why it failed to be decrypted. + +If the ``dpapi-ng`` library is not installed this is what the output would look like: + +.. code-block:: yaml + + # ansible-inventory -i microsoft.ad.ldap.yml --host MYHOST --vars --yaml + + ansible_host: MYHOST.domain.com + microsoft_ad_distinguished_name: CN=MYHOST,CN=Computers,DC=domain,DC=com + raw_example: + - toLZAWR7rgfk... + this_example: + debug: Cannot decrypt value as the Python library dpapi-ng is not installed + encrypted_value: MIIETgYJKoZI... + flags: 0 + update_timestamp: 133281382308674404 + +The ``value`` key is no longer present and ``debug`` contains the message that ``dpapi-ng`` is not installed. + +If ``dpapi-ng`` library was installed but the connection user is not authorized to decrypt the value this is what the output would look like: + +.. code-block:: yaml + + # ansible-inventory -i microsoft.ad.ldap.yml --host MYHOST --vars --yaml + + ansible_host: MYHOST.domain.com + microsoft_ad_distinguished_name: CN=MYHOST,CN=Computers,DC=domain,DC=com + raw_example: + - toLZAWR7rgfk... + this_example: + debug: Failed to decrypt value due to error - ValueError GetKey failed 0x80070005 + encrypted_value: MIIETgYJKoZI... + flags: 0 + update_timestamp: 133281382308674404 + +A simple way to test that the connection user is able to decrypt the password is to run ``Get-LapsADPassword -Identity MYHOST`` on a Windows host as that user. diff --git a/ansible_collections/microsoft/ad/docs/docsite/rst/guide_list_values.rst b/ansible_collections/microsoft/ad/docs/docsite/rst/guide_list_values.rst new file mode 100644 index 000000000..642e748bd --- /dev/null +++ b/ansible_collections/microsoft/ad/docs/docsite/rst/guide_list_values.rst @@ -0,0 +1,118 @@ +.. _ansible_collections.microsoft.ad.docsite.guide_list_values: + +******************************** +Setting list option values guide +******************************** + +Some AD options accept multiple values which require special rules when it comes to checking for idempotency in Ansible. This collection has been designed so that each of the modules which manage AD objects follow the same style when it comes to their options. In particular, they should all follow the style documented in this guide when it comes to options that contain multiple values like ``spn``, ``delegates``, etc. + +.. contents:: + :local: + :depth: 1 + +.. _ansible_collections.microsoft.ad.docsite.guide_list_values.something: + +Add, remove, and set +==================== + +For each module option that manage a multi valued LDAP attribute there exists three actions: + +* ``add`` +* ``remove`` +* ``set`` + +The ``add`` and ``remove`` option will add or remove the specified value(s) from the existing value. The ``set`` option will replace the existing values with what was specified in the task. +Using an example of an AD object with the following ``servicePrincipalNames`` values: + +* ``HTTP/host1`` +* ``HTTP/host1.domain.com`` +* ``HTTP/host1.domain.com:443`` + +Doing ``add: ['HTTP/host1','HTTP/host2']`` will add ``HTTP/host2`` to the existing values bringing it to: + +* ``HTTP/host1`` +* ``HTTP/host1.domain.com`` +* ``HTTP/host1.domain.com:443`` +* ``HTTP/host2`` + +Doing ``remove: ['HTTP/host1','HTTP/host3']`` will remove ``HTTP/host1`` from the existing values bringing it to: + +* ``HTTP/host1.domain.com`` +* ``HTTP/host1.domain.com:443`` + +Doing ``set: ['HTTP/host1', 'HTTP/host2']`` will remove any values not in that list and add values in that list but not set bringing it to: + +* ``HTTP/host1`` +* ``HTTP/host2`` + +It is possible to use ``add`` and ``remove`` together but setting ``set`` will always take precedence over the others. +It is also possible to clear all the existing values by setting the ``set`` value to an empty list, for example ``set: []``. + +Examples +======== + +The ``add``, ``remove``, and ``set`` options are subkeys of the module option it controls. For example the :ref:`microsoft.ad.user <ansible_collections.microsoft.ad.user_module>` has an option called ``groups`` which control the list of groups the user is a member of. To add a group to the user, simply use the ``add`` key like so: + +.. code-block:: yaml + + - name: add a user to a group + microsoft.ad.user: + name: MyUser + groups: + add: + - Group 1 + - Group 2 + +This will ensure the user is added to the groups ``Group 1`` and ``Group 2`` while also preserving the existing membership. To remove a user from a user, simple use the ``remove`` key like so: + +.. code-block:: yaml + + - name: remove a user from a group + microsoft.ad.user: + name: MyUser + groups: + remove: + - Group 1 + - Group 2 + +This does the opposite to add and will remove the user from ``Group 1`` and ``Group 2`` but it will still preserve any existing group memberships of that user. It is also possible to combine ``add`` and ``remove`` together: + +.. code-block:: yaml + + - name: add and remove user groups + microsoft.ad.user: + name: MyUser + groups: + add: + - Group 1 + remove: + - Group 2 + +This will ensure the user is a member of ``Group 1`` and is not a member of ``Group 2``. Like before it will not touch the existing group membership if they are not specified. + +The set option following the same format like so: + +.. code-block:: yaml + + - name: set user groups + microsoft.ad.user: + name: MyUser + groups: + set: + - Group 1 + - Group 2 + +This will ensure the user is only members of ``Group 1`` and ``Group 2``, removing any other group not in that list. While it is possible to combine ``set`` with either ``add`` or ``remove``, the module will completely ignore the values in ``add`` or ``remove``. + +Finally to remove a user from all groups, use an empty list for the ``set`` option like so: + +.. code-block:: yaml + + - name: remove user groups + microsoft.ad.user: + name: MyUser + groups: + set: [] + +.. note:: + This is not actually possible for user groups as it will always be a member of its primary group, it is just used for demonstration purposes. diff --git a/ansible_collections/microsoft/ad/docs/docsite/rst/guide_migration.rst b/ansible_collections/microsoft/ad/docs/docsite/rst/guide_migration.rst new file mode 100644 index 000000000..c0b01ca5f --- /dev/null +++ b/ansible_collections/microsoft/ad/docs/docsite/rst/guide_migration.rst @@ -0,0 +1,189 @@ +.. _ansible_collections.microsoft.ad.docsite.guide_migration: + +*************** +Migration guide +*************** + +Some of the modules in this collection have come from the `ansible.windows collection <https://galaxy.ansible.com/ansible/windows>`_ or the `community.windows collection <https://galaxy.ansible.com/community/windows>`_. This document will go through some of the changes made to help ease the transition from the older modules to the ones in this collection. + +.. contents:: + :local: + :depth: 1 + +.. _ansible_collections.microsoft.ad.docsite.guide_migration.migrated_modules: + +Migrated Modules +================ + +The following modules have been migrated in some shape or form into this collection + +* ``ansible.windows.win_domain`` -> ``microsoft.ad.domain`` - :ref:`details <ansible_collections.microsoft.ad.docsite.guide_migration.migrated_modules.win_domain>` +* ``ansible.windows.win_domain_controller`` -> ``microsoft.ad.domain_controller`` - :ref:`details <ansible_collections.microsoft.ad.docsite.guide_migration.migrated_modules.win_domain_controller>` +* ``ansible.windows.win_domain_membership`` -> ``microsoft.ad.membership`` - :ref:`details <ansible_collections.microsoft.ad.docsite.guide_migration.migrated_modules.win_domain_membership>` +* ``community.windows.win_domain_computer`` -> ``microsoft.ad.computer`` - :ref:`details <ansible_collections.microsoft.ad.docsite.guide_migration.migrated_modules.win_domain_computer>` +* ``community.windows.win_domain_group`` -> ``microsoft.ad.group`` - :ref:`details <ansible_collections.microsoft.ad.docsite.guide_migration.migrated_modules.win_domain_group>` +* ``community.windows.win_domain_group_membership`` -> ``microsoft.ad.group`` - :ref:`details <ansible_collections.microsoft.ad.docsite.guide_migration.migrated_modules.win_domain_group_membership>` +* ``community.windows.win_domain_object_info`` -> ``microsoft.ad.object_info`` - :ref:`details <ansible_collections.microsoft.ad.docsite.guide_migration.migrated_modules.win_domain_object_info>` +* ``community.windows.win_domain_ou`` -> ``microsoft.ad.ou`` - :ref:`details <ansible_collections.microsoft.ad.docsite.guide_migration.migrated_modules.win_domain_ou>` +* ``community.windows.win_domain_user`` -> ``microsoft.ad.user`` - :ref:`details <ansible_collections.microsoft.ad.docsite.guide_migration.migrated_modules.win_domain_user>` + +While these modules are mostly drop in place compatible there are some breaking changes that need to be considered. See each module entry for more information. + +.. _ansible_collections.microsoft.ad.docsite.guide_migration.migrated_modules.win_domain: + +Module ``win_domain`` +--------------------- + +Migrated to :ref:`microsoft.ad.domain <ansible_collections.microsoft.ad.domain_module>`. + +There are no known breaking changes and should work as a drop in replacement. The ``reboot`` option has been added to have the module handle any reboots that are needed instead of a separate ``ansible.windows.win_reboot`` task. Due to the operations involved with promoting a domain controller, it is highly recommended to use this option. + +.. _ansible_collections.microsoft.ad.docsite.guide_migration.migrated_modules.win_domain_controller: + +Module ``win_domain_controller`` +-------------------------------- + +Migrated to :ref:`microsoft.ad.domain_controller <ansible_collections.microsoft.ad.domain_controller_module>`. + +The following options have been removed: + +* ``log_path`` - Creating a debug log of module actions is not supported + +The ``reboot`` option has been added to have the module handle any reboots instead of a separate ``ansible.windows.win_reboot`` task. Due to the operations involved with promoting a domain controller, it is highly recommended to use this option. + +.. _ansible_collections.microsoft.ad.docsite.guide_migration.migrated_modules.win_domain_membership: + +Module ``win_domain_membership`` +-------------------------------- + +Migrated to :ref:`microsoft.ad.membership <ansible_collections.microsoft.ad.membership_module>`. + +The following options have been removed: + +* ``log_path`` - Creating a debug log of module actions is not supported + +The ``reboot`` option has been added to have the module handle any reboots instead of a separate ``ansible.windows.win_reboot`` task. + +.. _ansible_collections.microsoft.ad.docsite.guide_migration.migrated_modules.win_domain_computer: + +Module ``win_domain_computer`` +------------------------------ + +Migrated to :ref:`microsoft.ad.computer <ansible_collections.microsoft.ad.computer_module>`. + +The option ``dns_host_name`` is not required when ``state: present``, the computer object is created without the ``dnsHostName`` LDAP attribute set if it is not defined. + +The default for ``enabled`` is nothing, the group will still be enabled when created but it will use the existing status if the option is omitted. + +The option ``ou`` is now named ``path`` to match the standard set by other modules. + +The options ``offline_domain_join`` and ``odj_blob_path`` has been removed. Use the new module ``microsoft.ad.offline_join`` to generate the offline join blob. For example: + +.. code-block:: yaml + + - name: create computer object + microsoft.ad.computer: + name: MyComputer + state: present + register: computer_obj + + - name: create offline blob + microsoft.ad.offline_join: + identity: '{{ computer_obj.object_guid }}' + when: computer_obj is changed + register: offline_blob + + - name: display offline blob + debug: + var: offline_blob.blob + when: computer_obj is changed + +.. _ansible_collections.microsoft.ad.docsite.guide_migration.migrated_modules.win_domain_group: + +Module ``win_domain_group`` +--------------------------- + +Migrated to :ref:`microsoft.ad.group <ansible_collections.microsoft.ad.group_module>`. + +The following options have changed: + +* ``attributes`` - changed format as outlined in :ref:`Attributes guid <ansible_collections.microsoft.ad.docsite.guide_attributes>` +* ``ignore_protection`` - Has been removed and ``state: absent`` will also remove objects regardless of the protection status +* ``organizational_unit`` and ``ou`` - Have been removed, use ``path`` instead +* ``protect`` - Has been renamed to ``protect_from_deletion`` and is now not needed to be unset for ``state: absent`` to remove the group + +The return values for ``win_domain_group`` have also been simplified to only return: + +* ``distinguished_name`` - The Distinguished Name (``DN``) of the managed OU +* ``object_guid`` - The Object GUID of the managed OU +* ``sid`` - The Security Identifier of the managed user + +All other return values have been removed, use ``microsoft.ad.object_info`` to get extra values if needed. + +.. _ansible_collections.microsoft.ad.docsite.guide_migration.migrated_modules.win_domain_group_membership: + +Module ``win_domain_group_membership`` +-------------------------------------- + +Migrated to :ref:`microsoft.ad.group <ansible_collections.microsoft.ad.group_module>`. + +The functionality of this module has been merged with ``microsoft.ad.group``. Use the ``members`` option to ``add``, ``remove``, or ``set`` to add, remove, or set group members respectively. + +.. _ansible_collections.microsoft.ad.docsite.guide_migration.migrated_modules.win_domain_object_info: + +Module ``win_domain_object_info`` +--------------------------------- + +Migrated to :ref:`microsoft.ad.object_info <ansible_collections.microsoft.ad.object_info_module>`. + +There are no known breaking changes and should work as a drop in replacement. + +.. _ansible_collections.microsoft.ad.docsite.guide_migration.migrated_modules.win_domain_ou: + +Module ``win_domain_ou`` +------------------------ + +Migrated to :ref:`microsoft.ad.ou <ansible_collections.microsoft.ad.ou_module>`. + +The following options have changed: + +* ``protected`` - Has been renamed to ``protect_from_deletion`` and is now not needed to be unset for ``state: absent`` to remove the OU +* ``recursive`` - Has been removed and ``state: absent`` will also remove objects recursively +* ``filter`` - Has been removed, the ``name`` object refers to the OU name and ``identity`` can be used to select the OU by ``DistinguishedName`` or ``ObjectGUID`` if a rename or move is needed +* ``properties`` - Has been removed, use the new ``attributes`` option + +The return values for ``win_domain_ou`` have also been simplified to only return: + +* ``distinguished_name`` - The Distinguished Name (``DN``) of the managed OU +* ``object_guid`` - The Object GUID of the managed OU + +All other return values have been removed, use ``microsoft.ad.object_info`` to get extra values if needed. + +.. _ansible_collections.microsoft.ad.docsite.guide_migration.migrated_modules.win_domain_user: + +Module ``win_domain_user`` +-------------------------- + +Migrated to :ref:`microsoft.ad.user <ansible_collections.microsoft.ad.user_module>`. + +The following options have changed: + +* ``attributes`` - changed format as outlined in :ref:`Attributes guid <ansible_collections.microsoft.ad.docsite.guide_attributes>` +* ``delegates`` - changed format as outlined in :ref:`Setting list values <ansible_collections.microsoft.ad.docsite.guide_list_values>` +* ``groups`` - changed format as outlined in :ref:`Setting list values <ansible_collections.microsoft.ad.docsite.guide_list_values>` +* ``groups_action`` - has been removed in favour of the new ``groups`` format +* ``groups_missing_behaviour`` - has been moved into the ``group`` dictionary value as ``missing_behaviour`` +* ``spn``- changed format as outlined in :ref:`Setting list values <ansible_collections.microsoft.ad.docsite.guide_list_values>` +* ``spn_action`` - has been removed in favour of the new ``spn`` format +* ``state`` - No query option - use ``microsoft.ad.object_info`` instead +* ``enabled`` - Does not default to ``true``. Creating a new user without a password will use ``enabled=false`` but setting a password will use ``enabled=true`` + +The ``groups_action`` and ``spn_action`` ``set`` value was renamed to align with common practice. The ``state=query`` functionality has been removed to simplify the module and favour ``microsoft.ad.object_info`` which is designed to return information about AD objects. The ``enabled`` default was removed to allow setting other attributes on an existing AD object without always having to specify ``enabled``. + +The return values for ``win_domain_user`` have also been simplified to only return: + +* ``distinguished_name`` - The Distinguished Name (``DN``) of the managed user +* ``object_guid`` - The Object GUID of the managed user +* ``sid`` - The Security Identifier of the managed user + +All other return values have been removed, use ``microsoft.ad.object_info`` to get extra values if needed. diff --git a/ansible_collections/microsoft/ad/meta/runtime.yml b/ansible_collections/microsoft/ad/meta/runtime.yml new file mode 100644 index 000000000..afd650545 --- /dev/null +++ b/ansible_collections/microsoft/ad/meta/runtime.yml @@ -0,0 +1 @@ +requires_ansible: '>=2.12'
\ No newline at end of file diff --git a/ansible_collections/microsoft/ad/plugins/action/__init__.py b/ansible_collections/microsoft/ad/plugins/action/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/ansible_collections/microsoft/ad/plugins/action/__init__.py diff --git a/ansible_collections/microsoft/ad/plugins/action/debug_ldap_client.py b/ansible_collections/microsoft/ad/plugins/action/debug_ldap_client.py new file mode 100644 index 000000000..a33f21dda --- /dev/null +++ b/ansible_collections/microsoft/ad/plugins/action/debug_ldap_client.py @@ -0,0 +1,176 @@ +# Copyright (c) 2023 Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +import importlib +import importlib.metadata +import typing as t +import traceback + + +from ansible.plugins.action import ActionBase +from ansible.utils.display import Display + +display = Display() + +try: + import dns.resolver + + HAS_DNSRESOLVER = True +except Exception: + HAS_DNSRESOLVER = False + + +try: + import krb5 + + HAS_KRB5 = True +except Exception: + HAS_KRB5 = False + + +class ActionModule(ActionBase): + def run( + self, + tmp: t.Optional[str] = None, + task_vars: t.Optional[t.Dict[str, t.Any]] = None, + ) -> t.Dict[str, t.Any]: + self._supports_check_mode = True + self._supports_async = True + + result = super().run(tmp=tmp, task_vars=task_vars) + del tmp + + kerb_info = self._get_kerberos_info() + + dns_info: t.Dict[str, t.Any] = {} + default_realm = kerb_info.get("default_realm", None) + if default_realm: + dns_info = self._get_server_lookup_info(default_realm) + + result.update( + { + "dns": dns_info, + "kerberos": kerb_info, + "packages": { + "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"), + "sansldap": self._import_lib("sansldap"), + }, + } + ) + + return result + + def _get_kerberos_info(self) -> t.Dict[str, t.Any]: + if not HAS_KRB5: + return {} + + res: t.Dict[str, t.Any] = { + "exception": None, + "default_realm": None, + "default_cc": {}, + } + + try: + ctx = krb5.init_context() + except Exception: + res["exception"] = traceback.format_exc() + + if not ctx: + return res + + try: + res["default_realm"] = krb5.get_default_realm(ctx).decode("utf-8") + except Exception: + res["exception"] = traceback.format_exc() + + res["default_cc"] = self._get_kerberos_cc_info(ctx) + + return res + + def _get_kerberos_cc_info( + self, + ctx: "krb5.Context", + ) -> t.Dict[str, t.Any]: + creds: t.List[t.Dict[str, t.Any]] = [] + res: t.Dict[str, t.Any] = { + "exception": None, + "name": None, + "principal": None, + "creds": creds, + } + + try: + default_cc = krb5.cc_default(ctx) + except Exception: + res["exception"] = traceback.format_exc() + + if not default_cc: + return res + + try: + res["name"] = str(default_cc) + res["principal"] = str(default_cc.principal) + for cred in default_cc: + # cred attrs added in krb5 0.5.0 + creds.append( + { + "client": str(getattr(cred, "client", "krb5 too old")), + "server": str(getattr(cred, "server", "krb5 too old")), + } + ) + except Exception: + res["exception"] = traceback.format_exc() + + return res + + def _get_server_lookup_info( + self, + default_realm: str, + ) -> t.Dict[str, t.Any]: + if not HAS_DNSRESOLVER: + return {} + + records: t.List[t.Dict[str, t.Any]] = [] + res: t.Dict[str, t.Any] = { + "exception": None, + "default_server": None, + "default_port": None, + "records": records, + } + + try: + srv_record = f"_ldap._tcp.dc._msdcs.{default_realm}" + + for rec in dns.resolver.resolve(srv_record, "SRV"): + records.append( + { + "target": str(rec.target), + "port": rec.port, + "weight": rec.weight, + "priority": rec.priority, + } + ) + + 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"] + + except Exception: + res["exception"] = traceback.format_exc() + + return res + + def _import_lib( + self, + name: str, + package_name: t.Optional[str] = None, + ) -> str: + try: + importlib.import_module(name) + return importlib.metadata.version(package_name or name) + except Exception: + return traceback.format_exc() diff --git a/ansible_collections/microsoft/ad/plugins/action/domain.py b/ansible_collections/microsoft/ad/plugins/action/domain.py new file mode 100644 index 000000000..36cdb26e5 --- /dev/null +++ b/ansible_collections/microsoft/ad/plugins/action/domain.py @@ -0,0 +1,34 @@ +# Copyright (c) 2022 Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +import typing as t + +from ..plugin_utils._module_with_reboot import ActionModuleWithReboot + + +class ActionModule(ActionModuleWithReboot): + def __init__(self, *args: t.Any, **kwargs: t.Any) -> None: + super().__init__(*args, **kwargs) + self._ran_once = False + + def _ad_should_rerun(self, result: t.Dict[str, t.Any]) -> bool: + ran_once = self._ran_once + self._ran_once = True + + if ran_once or not result.get("_do_action_reboot", False): + return False + + if self._task.check_mode: + # Assume that on a rerun it will not have failed and that it + # ran successfull. + result["failed"] = False + result.pop("msg", None) + return False + + else: + return True + + def _ad_process_result(self, result: t.Dict[str, t.Any]) -> t.Dict[str, t.Any]: + result.pop("_do_action_reboot", None) + + return result diff --git a/ansible_collections/microsoft/ad/plugins/action/domain_controller.py b/ansible_collections/microsoft/ad/plugins/action/domain_controller.py new file mode 100644 index 000000000..36cdb26e5 --- /dev/null +++ b/ansible_collections/microsoft/ad/plugins/action/domain_controller.py @@ -0,0 +1,34 @@ +# Copyright (c) 2022 Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +import typing as t + +from ..plugin_utils._module_with_reboot import ActionModuleWithReboot + + +class ActionModule(ActionModuleWithReboot): + def __init__(self, *args: t.Any, **kwargs: t.Any) -> None: + super().__init__(*args, **kwargs) + self._ran_once = False + + def _ad_should_rerun(self, result: t.Dict[str, t.Any]) -> bool: + ran_once = self._ran_once + self._ran_once = True + + if ran_once or not result.get("_do_action_reboot", False): + return False + + if self._task.check_mode: + # Assume that on a rerun it will not have failed and that it + # ran successfull. + result["failed"] = False + result.pop("msg", None) + return False + + else: + return True + + def _ad_process_result(self, result: t.Dict[str, t.Any]) -> t.Dict[str, t.Any]: + result.pop("_do_action_reboot", None) + + return result diff --git a/ansible_collections/microsoft/ad/plugins/action/membership.py b/ansible_collections/microsoft/ad/plugins/action/membership.py new file mode 100644 index 000000000..5eea8d120 --- /dev/null +++ b/ansible_collections/microsoft/ad/plugins/action/membership.py @@ -0,0 +1,8 @@ +# Copyright (c) 2022 Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from ..plugin_utils._module_with_reboot import ActionModuleWithReboot + + +class ActionModule(ActionModuleWithReboot): + ... diff --git a/ansible_collections/microsoft/ad/plugins/doc_fragments/ad_object.py b/ansible_collections/microsoft/ad/plugins/doc_fragments/ad_object.py new file mode 100644 index 000000000..31ed8eacd --- /dev/null +++ b/ansible_collections/microsoft/ad/plugins/doc_fragments/ad_object.py @@ -0,0 +1,161 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2023 Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + + +class ModuleDocFragment: + + # Common options for ansible_collections.microsoft.ad.plugins.module_utils._ADObject + DOCUMENTATION = r""" +requirements: +- C(ActiveDirectory) PowerShell module +options: + attributes: + description: + - The attributes to either add, remove, or set on the AD object. + - The value of each attribute option should be a dictionary where the key + is the LDAP attribute, e.g. C(firstName), C(comment) and the value is the + value, or list of values, to set for that attribute. + - The attribute value(s) can either be the raw string, integer, or bool + value to add, remove, or set on the attribute in question. + - The value can also be a dictionary with the I(type) key set to C(bytes), + C(date_time), C(security_descriptor), or C(raw) and the value for this + entry under the I(value) key. + - The C(bytes) type has a value that is a base64 encoded string of the raw + bytes to set. + - The C(date_time) type has a value that is the ISO 8601 DateTime string of + the DateTime to set. The DateTime will be set as the Microsoft FILETIME + integer value which is the number of 100 nanoseconds since 1601-01-01 in + UTC. + - The C(security_descriptor) type has a value that is the Security + Descriptor SDDL string used for the C(nTSecurityDescriptor) attribute. + - The C(raw) type is the int, string, or boolean value to set. + - String attribute values are compared using a case sensitive match on the + AD object being managed. + - See R(LDAP attributes help,ansible_collections.microsoft.ad.docsite.guide_attributes) + for more information. + default: {} + type: dict + suboptions: + add: + description: + - A dictionary of all the attributes and their value(s) to add to the + AD object being managed if they are not already present. + - This is used for attributes that can contain multiple values, if the + attribute only allows a single value, use I(set) instead. + default: {} + type: dict + remove: + description: + - A dictionary of all the attributes and their value(s) to remove from + the AD object being managed if they are present. + - This is used for attributes that can contain multiple values, if the + attribute only allows a single value, use I(set) instead. + default: {} + type: dict + set: + description: + - A dictionary of all attributes and their value(s) to set on the AD + object being managed. + - This will replace any existing values if they do not match the ones + being requested. + - The order of attribute values are not checked only, only that the + values requested are the only values on the object attribute. + - Set this to null or an empty list to clear any values for the + attribute. + default: {} + type: dict + description: + description: + - The description of the AD object to set. + - This is the value set on the C(description) LDAP attribute. + type: str + display_name: + description: + - The display name of the AD object to set. + - This is the value of the C(displayName) LDAP attribute. + type: str + domain_password: + description: + - The password for I(domain_username). + type: str + domain_server: + description: + - Specified the Active Directory Domain Services instance to connect to. + - Can be in the form of an FQDN or NetBIOS name. + - If not specified then the value is based on the default domain of the computer running PowerShell. + type: str + domain_username: + description: + - The username to use when interacting with AD. + - If this is not set then the user that is used for authentication will be the connection user. + - Ansible will be unable to use the connection user unless auth is Kerberos with credential delegation or CredSSP, + or become is used on the task. + type: str + identity: + description: + - The identity of the AD object used to find the AD object to manage. + - Must be specified if I(name) is not set, when trying to rename the object + with a new I(name), or when trying to move the object into a different + I(path). + - The identity can be in the form of a GUID representing the C(objectGUID) + value, the C(userPrincipalName), C(sAMAccountName), C(objectSid), or + C(distinguishedName). + - If omitted, the AD object to managed is selected by the + C(distinguishedName) using the format C(CN={{ name }},{{ path }}). If + I(path) is not defined, the C(defaultNamingContext) is used instead. + type: str + name: + description: + - The C(name) of the AD object to manage. + - If I(identity) is specified, and the name of the object it found does not + match this value, the object will be renamed. + - This must be set when I(state=present) or if I(identity) is not set. + - 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). + 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. + - 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. + type: str + protect_from_deletion: + description: + - Marks the object as protected from accidental deletion. + - This applies a deny access right from deleting the object normally and + the protection needs to be removed before the object can be deleted + through the GUI or any other tool outside Ansible. + - Using I(state=absent) will still delete the AD object even if it is + marked as protected from deletion. + type: bool + state: + description: + - Set to C(present) to ensure the AD object exists. + - Set to C(absent) to remove the AD object if it exists. + - The option I(name) must be set when I(state=present). + - Using C(absent) will recursively remove the AD object and any child + objects if it's a container. It will also remove the AD object even if + the object is marked as protected from accidental deletion. + choices: + - absent + - present + default: present + type: str +notes: +- Some LDAP attributes can have only a single value set while others can have + multiple. Some attributes are also read only and cannot be changed. It is + recommended to look at the schema metadata for an attribute where + C(System-Only) are read only values and C(Is-Single-Value) are attributes + with only 1 value. +- Attempting to set multiple values to a C(Is-Single-Value) attribute results + in undefined behaviour. +- If running on a server that is not a Domain Controller, credential + delegation through CredSSP or Kerberos with delegation must be used or the + I(domain_username), I(domain_password) must be set. +""" diff --git a/ansible_collections/microsoft/ad/plugins/doc_fragments/ldap_connection.py b/ansible_collections/microsoft/ad/plugins/doc_fragments/ldap_connection.py new file mode 100644 index 000000000..9300881cb --- /dev/null +++ b/ansible_collections/microsoft/ad/plugins/doc_fragments/ldap_connection.py @@ -0,0 +1,187 @@ +# Copyright (c) 2023 Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + + +class ModuleDocFragment: + + # associated with plugin_utils._ldap.create_ldap_connection + DOCUMENTATION = r""" +options: + auth_protocol: + description: + - The authentication protocol to use when connecting to the LDAP host. + - Defaults to C(certificate) if LDAPS/StartTLS is used and I(certificate) + has been specified. Otherwise it defaults to C(negotiate). + - C(simple) is simple authentication where the user and password are sent + in plaintext. It does not support any encryption so either must be used + with LDAPS, or StartTLS. If using over a plaintext LDAP connection + without TLS, C(encrypt=False) must be specified to explicitly opt into no + encryption. + - C(certificate) is TLS client certificate authentication. It can only be + used with LDAPS or StartTLS. See I(certificate) for more + information on how to specify the client certificate used for + authentication. + - C(negotiate) will attempt to negotiate Kerberos authentication with a + fallback to NTLM. If Kerberos is available the Kerberos credential cache + can be used if no username or password is specified. + - C(kerberos) will use Kerberos authentication with no NTLM fallback. + - C(ntlm) will use NTLM authentication with no Kerberos attempt. + - C(negotiate), C(kerberos), and C(ntlm) support encryption over LDAP. + - Kerberos support requires the C(pyspnego[kerberos]) extras to be + installed. + - See R(LDAP authentication,ansible_collections.microsoft.ad.docsite.guide_ldap_connection.authentication) + for more information. + choices: + - simple + - certificate + - negotiate + - kerberos + - ntlm + type: str + env: + - name: MICROSOFT_AD_LDAP_AUTH_PROTOCOL + ca_cert: + description: + - Can be the path to a CA certificate PEM or DER file, directory of PEM + certificates, or the CA certificate PEM string that is used for + certificate validation. + - If omitted, the default CA store used for validation is dependent on + the current Python settings. + type: str + env: + - name: MICROSOFT_AD_LDAP_CA_CERT + cert_validation: + description: + - The certificate validation behaviour when using a TLS connection. + - This can be set to C(always), C(ignore), C(ignore_hostname). + - C(always) will perform certificate hostname and CA validation. + - C(ignore) will ignore any certificate errors. + - C(ignore_hostname) will validate the CA trust chain but will ignore any + hostname checks performed by TLS. + - See R(Certificate validation,ansible_collections.microsoft.ad.docsite.guide_ldap_connection.cert_validation) + for more information. + choices: + - always + - ignore + - ignore_hostname + default: always + type: str + env: + - name: MICROSOFT_AD_LDAP_CERT_VALIDATION + certificate: + description: + - The certificate or certificate with key bundle that is used for + certificate authentication. + - The value can either be a path to a file containing the certificate or + string of the PEM encoded certificate. + - If using a path to a certificate file, the file can be a PEM encoded + certificate, a PEM encoded certificate and key bundle, a DER encoded + certificate, or a PFX/PKCS12 encoded certificate and key bundle. + - 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. + type: str + env: + - name: MICROSOFT_AD_LDAP_CERTIFICATE + certificate_key: + description: + - The certificate key that is used for certificate authentication. + - 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. + type: str + env: + - name: MICROSOFT_AD_LDAP_CERTIFICATE_KEY + certificate_password: + description: + - The password used to decrypt the certificate key specified by + I(certificate) or I(certificate_key). + type: str + env: + - name: MICROSOFT_AD_LDAP_CERTIFICATE_PASSWORD + connection_timeout: + description: + - The timeout in seconds to wait until the connection is established before + failing. + default: 5 + type: int + env: + - name: MICROSOFT_AD_LDAP_CONNECTION_TIMEOUT + encrypt: + description: + - Whether encryption is required for the connection. + - Encryption can either be performed using the authentication protocol or + through TLS. + - The I(auth_protocol) C(negotiate), C(kerberos), and C(ntlm) all support + encryption over LDAP whereas C(simple) does not. + - 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. + default: true + type: bool + env: + - name: MICROSOFT_AD_LDAP_ENCRYPT + password: + description: + - The password to authenticate with. + - If I(auth_protocol) is C(simple) and no password is specified, the + bind will be performed as an unauthenticated bind. + - 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. + type: str + env: + - name: MICROSOFT_AD_LDAP_PASSWORD + port: + description: + - 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). + type: int + env: + - name: MICROSOFT_AD_LDAP_PORT + server: + description: + - The domain controller/server to connect to. + - If not specified the server will be derived from the current krb5.conf + 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. + type: str + env: + - name: MICROSOFT_AD_LDAP_SERVER + tls_mode: + description: + - The TLS operation to use. + - If an explicit I(port) is set to C(636) then this defaults to C(ldaps). + - C(ldaps) will connect over LDAPS (port 636). + - C(start_tls) will connect over LDAP (port 389) and perform the StartTLS + operation before the authentication bind. + - It is recommended to use C(ldaps) over C(start_tls) if TLS is going to be + used. + choices: + - ldaps + - start_tls + type: str + env: + - name: MICROSOFT_AD_LDAP_TLS_MODE + username: + description: + - The username to authenticate with. + - If I(auth_protocol) is C(simple) and no username is specified, anonymous + authentication is used. + - 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). + type: str + env: + - name: MICROSOFT_AD_LDAP_USERNAME +notes: +- See R(LDAP connection help,ansible_collections.microsoft.ad.docsite.guide_ldap_connection) + for more information about LDAP connections. +requirements: +- dnspython - For option server lookup support +- pyspnego >= 0.8.0 +- pyspnego[kerberos] - For Kerberos and server lookup support +- sansldap +""" diff --git a/ansible_collections/microsoft/ad/plugins/filter/as_datetime.yml b/ansible_collections/microsoft/ad/plugins/filter/as_datetime.yml new file mode 100644 index 000000000..f8e7911f9 --- /dev/null +++ b/ansible_collections/microsoft/ad/plugins/filter/as_datetime.yml @@ -0,0 +1,54 @@ +# 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: as_datetime + author: + - 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 + 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. + 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. + 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' + type: str + +EXAMPLES: | + # This is an example used in the microsoft.ad.ldap plugin + + # Converting from the coerced value + attributes: + pwdLastSet: this | microsoft.ad.as_datetime + + # Converting from the raw bytes value + attributes: + maxPwdAge: raw | microsoft.ad.as_datetime + +RETURN: + _value: + description: + - The datetime string value(s) formatted as per the I(format) option. + type: string
\ No newline at end of file diff --git a/ansible_collections/microsoft/ad/plugins/filter/as_guid.yml b/ansible_collections/microsoft/ad/plugins/filter/as_guid.yml new file mode 100644 index 000000000..3110a5057 --- /dev/null +++ b/ansible_collections/microsoft/ad/plugins/filter/as_guid.yml @@ -0,0 +1,42 @@ +# 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: as_guid + author: + - 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 + 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. + 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. + type: raw + required: true + +EXAMPLES: | + # This is an example used in the microsoft.ad.ldap plugin + + attributes: + objectGuid: raw | microsoft.ad.as_guid + +RETURN: + _value: + description: + - The guid string value(s). + type: string
\ No newline at end of file diff --git a/ansible_collections/microsoft/ad/plugins/filter/as_sid.yml b/ansible_collections/microsoft/ad/plugins/filter/as_sid.yml new file mode 100644 index 000000000..5e33e3189 --- /dev/null +++ b/ansible_collections/microsoft/ad/plugins/filter/as_sid.yml @@ -0,0 +1,42 @@ +# 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: as_sid + author: + - 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 + 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. + 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. + 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 diff --git a/ansible_collections/microsoft/ad/plugins/filter/ldap_converters.py b/ansible_collections/microsoft/ad/plugins/filter/ldap_converters.py new file mode 100644 index 000000000..aa7ee669b --- /dev/null +++ b/ansible_collections/microsoft/ad/plugins/filter/ldap_converters.py @@ -0,0 +1,86 @@ +# Copyright: (c) 2023, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +import base64 +import datetime +import struct +import typing as t +import uuid + +from ansible.errors import AnsibleFilterError +from ansible.module_utils.common.collections import is_sequence + + +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): + return [func(v, *args, **kwargs) for v in value] + else: + return func(value, *args, **kwargs) + + return wrapper + + +@per_sequence +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") + + if isinstance(value, str): + value = int(value) + + # FILETIME is 100s of nanoseconds since 1601-01-01. As Python does not + # support nanoseconds the delta is number of microseconds. + delta = datetime.timedelta(microseconds=value // 10) + dt = datetime.datetime(year=1601, month=1, day=1, tzinfo=datetime.timezone.utc) + delta + + return dt.strftime(format) + + +@per_sequence +def as_guid(value: t.Any) -> str: + if isinstance(value, bytes): + guid = uuid.UUID(bytes_le=value) + + else: + b_value = base64.b64decode(str(value)) + guid = uuid.UUID(bytes_le=b_value) + + return str(guid) + + +@per_sequence +def as_sid(value: t.Any) -> str: + if isinstance(value, bytes): + view = memoryview(value) + else: + b_value = base64.b64decode(value) + view = memoryview(b_value) + + if len(view) < 8: + raise AnsibleFilterError("Raw SID bytes must be at least 8 bytes long") + + revision = view[0] + sub_authority_count = view[1] + authority = struct.unpack(">Q", view[:8])[0] & ~0xFFFF000000000000 + + view = view[8:] + if len(view) < sub_authority_count * 4: + raise AnsibleFilterError("Not enough data to unpack SID") + + sub_authorities: t.List[str] = [] + for dummy in range(sub_authority_count): + auth = struct.unpack("<I", view[:4])[0] + sub_authorities.append(str(auth)) + view = view[4:] + + return f"S-{revision}-{authority}-{'-'.join(sub_authorities)}" + + +class FilterModule: + def filters(self) -> t.Dict[str, t.Callable]: + return { + "as_datetime": as_datetime, + "as_guid": as_guid, + "as_sid": as_sid, + } diff --git a/ansible_collections/microsoft/ad/plugins/inventory/__init__.py b/ansible_collections/microsoft/ad/plugins/inventory/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/ansible_collections/microsoft/ad/plugins/inventory/__init__.py diff --git a/ansible_collections/microsoft/ad/plugins/inventory/ldap.py b/ansible_collections/microsoft/ad/plugins/inventory/ldap.py new file mode 100644 index 000000000..23ee31d67 --- /dev/null +++ b/ansible_collections/microsoft/ad/plugins/inventory/ldap.py @@ -0,0 +1,375 @@ +# Copyright: (c) 2023, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r""" +name: ldap +author: Jordan Borean (@jborean93) +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}). +- 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 + set on the computer object then C(ansible_host) is not set. See + R(LDAP inventory hostname,ansible_collections.microsoft.ad.docsite.guide_ldap_inventory.inventory_hostname) + for more information on how these values are set and how to adjust them. +- The host fact C(microsoft_ad_distinguished_name) will also be set to the + distinguished name of the host that was used to derive the host entry. +- Any other fact that is needed, needs to be defined in the I(attributes) + option. +options: + attributes: + description: + - The LDAP attributes to retrieve. + - The keys specified are the LDAP attributes requested and the values for + each attribute is a dictionary that reflects what host var to set it to + and how. + - Each key of the inner dictionary value is the host variable name to set + and the value is the template to use to derive the value. If no value is + explicitly set then it will use the coerced value as returned from the + LDAP attribute. + - Attributes that are denoted as single value in the LDAP schema are + returned as that single value, multi valued attributes are returned as a + list of values. + - See R(LDAP inventory attributes,ansible_collections.microsoft.ad.docsite.guide_ldap_inventory.attributes) + for more information. + default: {} + type: dict + filter: + description: + - The LDAP filter string used to query the computer objects. + - This will be combined with the filter "(objectClass=computer)". + type: str + search_base: + description: + - The LDAP search base to find the computer objects in. + - Defaults to the C(defaultNamingContext) of the Active Directory server + if not specified. + - If searching a larger Active Directory database, it is recommended to + narrow the search base to speed up the queries. + type: str + search_scope: + description: + - The scope of the LDAP search to perform. + - C(base) will search only the current path or object specified by + I(search_base). This is typically not useful for inventory plugins. + - C(one_level) will search only the immediate child objects in + I(search_base). + - C(subtree) will search the immediate child objects and any nested + objects in I(search_base). + choices: + - base + - one_level + - subtree + default: subtree + type: str +notes: +- See R(LDAP inventory,ansible_collections.microsoft.ad.docsite.guide_ldap_inventory) + for more details on how to use this inventory plugin. +- See R(LAPS,ansible_collections.microsoft.ad.docsite.guide_ldap_inventory.laps) + for more details on how this plugin can retrieve the LAPS password + information. +- This plugin is a tech preview and the module options are subject to change + based on feedback received. +extends_documentation_fragment: +- constructed +- microsoft.ad.ldap_connection +""" + +EXAMPLES = r""" +# Set in the file ending with microsoft.ad.ldap.yml or microsoft.ad.ldap.yaml +plugin: microsoft.ad.ldap + + +#################################################################### +# Connection Options # +# # +# These options control how the plugin connects to the LDAP server # +#################################################################### + +# Connects to ldap://dc01.domain.com:389 +server: dc01.domain.com +port: 389 + +# Connects to ldaps://dc01.domain.com:636 +server: dc01.domain.com +tls_mode: ldaps + +# Connects to the global catalog +# ldap://dc01.domain.com:3268 +server: dc01.domain.com +port: 3268 + +# Provides explicit user, will use the current Kerberos ticket if no credential +# is provided. +username: domain-user@DOMAIN.COM +password: Password123! + +# Only allow Kerberos authentication. +auth_protocol: kerberos + +# Verify LDAPS CA chain with custom CA chain. +tls_mode: ldaps +ca_cert: /home/user/certs/ldap.pem + + +############################################## +# Search Options # +# # +# These options control the searching rules # +############################################## + +# Search for computer accounts in the Workshop OU. +search_base: OU=Workshop A,DC=domain,DC=com + +# Filter the computer accounts returned for only ones with the dNSDomainName +# attribute set. +filter: (dNSDomainName=*) + +# Filter computer accounts returned for ones starting with PROD and with the +# LAPS password set. +filter: (&(sAMAccountName=PROD*)(ms-Mcs-AdmPwd=*)) + +# See documentation for more details +attributes: + sAMAccountName: + sam_account_name: + objectSid: + computer_sid: + pwdLastSet: + password_last_set: this | microsoft.ad.as_datetime + comment: + host_comment + memberOf: + computer_membership: this | map("regex_search", '^CN=(?P<name>.+?)((?<!\\),)', '\g<name>') | flatten + location: + + +############################################################################ +# LAPS Integration # +# # +# Examples on how to use the new Windows LAPS values as connection options # +############################################################################ + +attributes: + # msLAPS-Password is used if no encryption has been configured. + # Currently an encrypted LAPS password is not supported. + msLAPS-Password: + ansible_user: (this | from_json).n + ansible_password: (this | from_json).p + + # msLAPS-EncryptedPassword is used if encryption has been configured. + # If the Python dpapi-ng library is installed the `this`` value will + # contain the entry `value` which is the decrypted value. The ``info`` + # entry will contain the reason why the value could not be decrypted. + msLAPS-EncryptedPassword: + ansible_user: (this.value | from_json).n + ansible_password: (this.value | from_json).p + + # ms-Mcs-AdmPwd is used for Legacy LAPS and stores just the password. + # The username needs to be hardcoded as a string value for this template. + ms-Mcs-AdmPwd: + ansible_user: '"Administrator"' + ansible_password: this + + +##################################################################### +# Constructed Options # +# # +# These options control the constructed values like vars and groups # +##################################################################### + +# Build composed host variables. Requires attributes to be set in the +# attributes option to be referenced here. +compose: + host_var: computer_sid + +# Conditionals that adds found hosts to the groups specified. +groups: + # Adds all hosts to the windows group + windows: true + + # Uses the memberOf fact documented above to place the host in the production + # group if it's a member of that group + production: '"Production Group" in computer_membership' + +# Adds the host to a group site_{{ location }} with the default group of +# site_unknown if the location isn't defined +keyed_groups: +- key: location | default(omit) + prefix: site + default_value: unknown +""" + +import base64 +import typing as t + +from ansible.errors import AnsibleError +from ansible.inventory.data import InventoryData +from ansible.module_utils.basic import missing_required_lib +from ansible.parsing.dataloader import DataLoader +from ansible.plugins.inventory import BaseInventoryPlugin, Constructable +from ansible.utils.unsafe_proxy import wrap_var + +try: + import sansldap + + from ..plugin_utils._ldap import create_ldap_connection + from ..plugin_utils._ldap.schema import LDAPSchema + from ..plugin_utils._ldap.laps import LAPSDecryptor + + HAS_LDAP = True + LDAP_IMP_ERR = None +except Exception as e: + HAS_LDAP = False + LDAP_IMP_ERR = e + + +class InventoryModule(BaseInventoryPlugin, Constructable): + NAME = "microsoft.ad.ldap" + + def verify_file(self, path: str) -> bool: + if super().verify_file(path): + return path.endswith(("microsoft.ad.ldap.yml", "microsoft.ad.ldap.yaml")) + + return False + + def parse( + self, + inventory: InventoryData, + loader: DataLoader, + path: str, + cache: bool, + ) -> None: + super().parse(inventory, loader, path, cache) + self.set_options() + self._read_config_data(path) + + if not HAS_LDAP: + msg = missing_required_lib( + "sansldap and pyspnego", + url="https://pypi.org/project/sansldap/ and https://pypi.org/project/pyspnego/", + reason="for ldap lookups", + ) + raise AnsibleError(f"{msg}: {LDAP_IMP_ERR}") from LDAP_IMP_ERR + + compose = self.get_option("compose") + groups = self.get_option("groups") + keyed_groups = self.get_option("keyed_groups") + ldap_filter = self.get_option("filter") + search_base = self.get_option("search_base") + search_scope = self.get_option("search_scope") + strict = self.get_option("strict") + + ldap_search_scope = { + "base": sansldap.SearchScope.BASE, + "one_level": sansldap.SearchScope.ONE_LEVEL, + "subtree": sansldap.SearchScope.SUBTREE, + }[search_scope] + + 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), + ] + ) + else: + final_filter = computer_filter + + custom_attributes = self._get_custom_attributes() + 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} + + connection_options = self.get_options() + laps_decryptor = LAPSDecryptor(**connection_options) + with create_ldap_connection(**connection_options) as client: + schema = LDAPSchema.load_schema(client) + + for dn, info in client.search( + filter=final_filter, + attributes=list(attributes), + search_base=search_base, + search_scope=ldap_search_scope, + ).items(): + insensitive_info = {k.lower(): v for k, v in info.items()} + + host_name = insensitive_info["name"][0].decode("utf-8") + host_vars: t.Dict[str, t.Any] = { + "microsoft_ad_distinguished_name": dn, + } + + dns_host_name = insensitive_info.get("dnshostname", None) + if dns_host_name: + host_vars["ansible_host"] = dns_host_name[0].decode("utf-8") + + for name, var_info in custom_attributes.items(): + 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]) + + if name.lower() == 'mslaps-encryptedpassword' and raw_values: + host_vars["this"] = laps_decryptor.decrypt(raw_values[0]) + else: + host_vars["this"] = wrap_var(values) + + for n, v in var_info.items(): + try: + 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 + continue + + host_vars[n] = composite + + host_vars.pop("raw") + host_vars.pop("this") + + actual_host_name = host_vars.get("inventory_hostname", host_name) + inventory.add_host(actual_host_name) + for n, v in host_vars.items(): + if n == "inventory_hostname": + 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) + + def _get_custom_attributes(self) -> t.Dict[str, t.Dict[str, str]]: + custom_attributes = self.get_option("attributes") + + processed_attributes: t.Dict[str, t.Dict[str, str]] = {} + for name, info in custom_attributes.items(): + if not info: + info = {name.replace("-", "_"): "this"} + 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") + + for var_name in list(info.keys()): + var_template = info[var_name] + if not var_template: + info[var_name] = "this" + + elif not isinstance(var_template, str): + raise AnsibleError( + f"Attribute {name}.{var_name} template value was {type(var_template).__name__} but was expecting a string" + ) + + processed_attributes[name] = info + + return processed_attributes diff --git a/ansible_collections/microsoft/ad/plugins/module_utils/_ADObject.psm1 b/ansible_collections/microsoft/ad/plugins/module_utils/_ADObject.psm1 new file mode 100644 index 000000000..4a7ccf87c --- /dev/null +++ b/ansible_collections/microsoft/ad/plugins/module_utils/_ADObject.psm1 @@ -0,0 +1,1130 @@ +# Copyright (c) 2023 Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +# FOR INTERNAL COLLECTION USE ONLY +# The interfaces in this file are meant for use within this collection +# and may not remain stable to outside uses. Changes may be made in ANY release, even a bugfix release. +# See also: https://github.com/ansible/community/issues/539#issuecomment-780839686 +# Please open an issue if you have questions about this. + +#AnsibleRequires -CSharpUtil Ansible.Basic + +Function Compare-AnsibleADAttribute { + <# + .SYNOPSIS + Compares AD attribute values. + + .PARAMETER Name + The attribute name to compare. + + .PARAMETER ADObject + The AD object to compare with. + + .PARAMETER Attribute + The attribute value(s) to add/remove/set. + + .PARAMETER Action + Set to Add to add the value(s), Remove to remove the value(s), and Set to replace the value(s). + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [string] + $Name, + + [Parameter()] + [AllowNull()] + [Microsoft.ActiveDirectory.Management.ADObject] + $ADObject, + + [Parameter()] + [AllowEmptyCollection()] + [object] + $Attribute, + + [ValidateSet("Add", "Remove", "Set")] + [string] + $Action + ) + + <# Gets all the known types the AD module can return + + DateTime, Guid, SecurityIdentifier are all from readonly properties + that the AD module alaises of the real LDAP attributes. + + Get-ADObject -LDAPFilter '(objectClass=*)' -Properties * | + ForEach-Object { + foreach ($name in $_.PSObject.Properties.Name) { + if ($name -in @('AddedProperties', 'ModifiedProperties', 'RemovedProperties', 'PropertyNames')) { continue } + + $v = $_.$name + if ($null -eq $v) { continue } + if ($v -isnot [System.Collections.IList] -or $v -is [System.Byte[]]) { + $v = @(, $v) + } + + foreach ($value in $v) { + $value.GetType() + } + } + } | + Sort-Object -Unique + #> + $getDiffValue = { + if ($_ -is [System.Byte[]]) { + [System.Convert]::ToBase64String($_) + } + elseif ($_ -is [System.DateTime]) { + $_.ToUniversalTime().ToString('o') + } + elseif ($_ -is [System.DirectoryServices.ActiveDirectorySecurity]) { + $_.GetSecurityDescriptorSddlForm([System.Security.AccessControl.AccessControlSections]::All) + } + else { + # Bool, Int32, Int64, String + $_ + } + } + + $existingAttributes = [System.Collections.Generic.List[object]]@() + if ($ADObject -and $null -ne $ADObject.$Name) { + $existingValues = $ADObject.$Name + if ($null -ne $existingValues) { + if ( + $existingValues -is [System.Collections.IList] -and + $existingValues -isnot [System.Byte[]] + ) { + # Wrap with @() to help pwsh unroll the property value collection + $existingAttributes.AddRange(@($existingValues)) + + } + else { + $existingAttributes.Add($existingValues) + } + } + } + + $desiredAttributes = [System.Collections.Generic.List[object]]@() + if ($null -ne $Attribute -and $Attribute -isnot [System.Collections.IList]) { + $Attribute = @($Attribute) + } + foreach ($attr in $Attribute) { + if ($attr -is [System.Collections.IDictionary]) { + if ($attr.Keys.Count -gt 2) { + $keyList = $attr.Keys -join "', '" + throw "Attribute '$Name' entry should only contain the 'type' and 'value' keys, found: '$keyList'" + } + + $type = $attr.type + $value = $attr.value + } + else { + $type = 'raw' + $value = $attr + } + + switch ($type) { + bool { + $desiredAttributes.Add([System.Boolean]$value) + } + bytes { + $desiredAttributes.Add([System.Convert]::FromBase64String($value)) + } + date_time { + $dtVal = [DateTimeOffset]::ParseExact( + $value, + [string[]]@("yyyy-MM-dd'T'HH:mm:ss.FFFFFFFK"), + [System.Globalization.CultureInfo]::InvariantCulture, + [System.Globalization.DateTimeStyles]::AssumeUniversal) + $desiredAttributes.Add($dtVal.UtcDateTime) + } + int { + $desiredAttributes.Add([Int64]$value) + } + security_descriptor { + $sd = New-Object -TypeName System.DirectoryServices.ActiveDirectorySecurity + $sd.SetSecurityDescriptorSddlForm($value) + $desiredAttributes.Add($sd) + } + string { + $desiredAttributes.Add($value.ToString()) + } + raw { + # If the value is an Int32 we need it to be Int64 to ensure + # the values are all the same type. + if ($value -is [int]) { + $value = [Int64]$value + } + $desiredAttributes.Add($value) + } + default { throw "Attribute type '$type' must be bytes, date_time, int, security_descriptor, or raw" } + } + } + + $diffBefore = @($existingAttributes | ForEach-Object -Process $getDiffValue) + $diffAfter = [System.Collections.Generic.List[object]]@() + $value = [System.Collections.Generic.List[object]]@() + $changed = $false + + # It's a lot easier to compare the string values + $existing = [string[]]$diffBefore + $desired = [string[]]@($desiredAttributes | ForEach-Object -Process $getDiffValue) + + if ($Action -eq 'Add') { + $diffAfter.AddRange($existingAttributes) + + for ($i = 0; $i -lt $desired.Length; $i++) { + if ($desired[$i] -cnotin $existing) { + $value.Add($desiredAttributes[$i]) + $diffAfter.Add($desiredAttributes[$i]) + $changed = $true + } + } + } + elseif ($Action -eq 'Remove') { + $diffAfter.AddRange($existingAttributes) + + for ($i = $desired.Length - 1; $i -ge 0; $i--) { + if ($desired[$i] -cin $existing) { + $value.Add($desiredAttributes[$i]) + $diffAfter.RemoveAt($i) + $changed = $true + } + } + } + else { + $diffAfter.AddRange($desiredAttributes) + + $toAdd = [string[]][System.Linq.Enumerable]::Except($desired, $existing) + $toRemove = [string[]][System.Linq.Enumerable]::Except($existing, $desired) + if ($toAdd.Length -or $toRemove.Length) { + $changed = $true + } + + if ($changed) { + $value.AddRange($desiredAttributes) + } + } + + [PSCustomObject]@{ + Name = $Name + Value = $value.ToArray() # AD cmdlets expect an array here + Changed = $changed + DiffBefore = @($diffBefore | Sort-Object) + DiffAfter = @($diffAfter | ForEach-Object -Process $getDiffValue | Sort-Object) + } +} + +Function Update-AnsibleADSetADObjectParam { + <# + .SYNOPSIS + Updates the Set-AD* parameter splat with the parameters needed to set the + attributes requested. + It will output a boolean that indicates whether a change is needed to + update the attributes. + + .PARAMETER Splat + The parameter splat to update. + + .PARAMETER Add + The attributes to add. + + .PARAMETER Remove + The attributes to remove. + + .PARAMETER Set + The attributes to set. + + .PARAMETER Diff + An optional dictionary that can be used to store the diff output value on + what was changed. + + .PARAMETER ADObject + The AD object to compare the requested attribute values with. + + .PARAMETER ForNew + This Splat is used for New-AD* and will update the OtherAttributes + parameter. + #> + [OutputType([bool])] + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [System.Collections.IDictionary] + $Splat, + + [Parameter()] + [AllowNull()] + [System.Collections.IDictionary] + $Add, + + [Parameter()] + [AllowNull()] + [System.Collections.IDictionary] + $Remove, + + [Parameter()] + [AllowNull()] + [System.Collections.IDictionary] + $Set, + + [Parameter()] + [System.Collections.IDictionary] + $Diff, + + [Parameter()] + [AllowNull()] + [Microsoft.ActiveDirectory.Management.ADObject] + $ADObject, + + [Parameter()] + [switch] + $ForNew + ) + + $diffBefore = @{} + $diffAfter = @{} + + $addAttributes = @{} + $removeAttributes = @{} + $replaceAttributes = @{} + $clearAttributes = [System.Collections.Generic.List[String]]@() + + if ($Add.Count) { + foreach ($kvp in $Add.GetEnumerator()) { + $val = Compare-AnsibleADAttribute -Name $kvp.Key -ADObject $ADObject -Attribute $kvp.Value -Action Add + if ($val.Changed -and $val.Value.Count) { + $addAttributes[$kvp.Key] = $val.Value + } + $diffBefore[$kvp.Key] = $val.DiffBefore + $diffAfter[$kvp.Key] = $val.DiffAfter + } + } + # remove doesn't make sense when creating a new object + if (-not $ForNew -and $Remove.Count) { + foreach ($kvp in $Remove.GetEnumerator()) { + $val = Compare-AnsibleADAttribute -Name $kvp.Key -ADObject $ADObject -Attribute $kvp.Value -Action Remove + if ($val.Changed -and $val.Value.Count) { + $removeAttributes[$kvp.Key] = $val.Value + } + $diffBefore[$kvp.Key] = $val.DiffBefore + $diffAfter[$kvp.Key] = $val.DiffAfter + } + } + if ($Set.Count) { + foreach ($kvp in $Set.GetEnumerator()) { + $val = Compare-AnsibleADAttribute -Name $kvp.Key -ADObject $ADObject -Attribute $kvp.Value -Action Set + if ($val.Changed) { + if ($val.Value.Count) { + $replaceAttributes[$kvp.Key] = $val.Value + } + else { + $clearAttributes.Add($kvp.Key) + } + } + $diffBefore[$kvp.Key] = $val.DiffBefore + $diffAfter[$kvp.Key] = $val.DiffAfter + } + } + + $changed = $false + if ($ForNew) { + $diffBefore = $null + $otherAttributes = @{} + + foreach ($kvp in $addAttributes.GetEnumerator()) { + $otherAttributes[$kvp.Key] = $kvp.Value + } + foreach ($kvp in $replaceAttributes.GetEnumerator()) { + $otherAttributes[$kvp.Key] = $kvp.Value + } + + if ($otherAttributes.Count) { + $changed = $true + $Splat.OtherAttributes = $otherAttributes + } + } + else { + if ($addAttributes.Count) { + $changed = $true + $Splat.Add = $addAttributes + } + if ($removeAttributes.Count) { + $changed = $true + $Splat.Remove = $removeAttributes + } + if ($replaceAttributes.Count) { + $changed = $true + $Splat.Replace = $replaceAttributes + } + if ($clearAttributes.Count) { + $changed = $true + $Splat.Clear = $clearAttributes + } + } + + if ($null -ne $Diff.Count) { + $Diff.after = $diffAfter + $Diff.before = $diffBefore + } + + $changed +} + + +Function Compare-AnsibleADIdempotentList { + <# + .SYNOPSIS + Common code to compare AD property values with an add/remove/set collection. + + .PARAMETER Existing + The existing values for the property. + + .PARAMETER Add + A list of values to add + + .PARAMETER Remove + A list of values to remove + + .PARAMETER Set + A list of files to set, will remove existing values if they are not in the + list and add ones that are not in the existing values. + + .PARAMETER CaseInsensitive + Whether to perform a case insensitive comparison check. + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [AllowNull()] + [object[]] + $Existing, + + [Parameter()] + [AllowNull()] + [AllowEmptyCollection()] + [object[]] + $Add, + + [Parameter()] + [AllowNull()] + [AllowEmptyCollection()] + [object[]] + $Remove, + + [Parameter()] + [AllowNull()] + [AllowEmptyCollection()] + [object[]] + $Set, + + [Parameter()] + [switch] + $CaseInsensitive + ) + + # It's easier to compare with strings. + $existingString = [string[]]@(if ($null -ne $Existing) { $Existing | ForEach-Object ToString }) + $comparer = if ($CaseInsensitive) { + [System.StringComparer]::OrdinalIgnoreCase + } + else { + [System.StringComparer]::CurrentCulture + } + + $value = [System.Collections.Generic.List[Object]]@() + $toAdd = [System.Collections.Generic.List[Object]]@() + $toRemove = [System.Collections.Generic.List[Object]]@() + + if ($null -ne $Set) { + $setString = [string[]]@($Set | ForEach-Object ToString) + $value.AddRange($Set) + + for ($i = 0; $i -lt $setString.Length; $i++) { + $setElement = $setString[$i] + if (-not [System.Linq.Enumerable]::Contains($existingString, $setElement, $comparer)) { + $toAdd.Add($Set[$i]) + } + } + for ($i = 0; $i -lt $existingString.Length; $i++) { + $existingElement = $existingString[$i] + if (-not [System.Linq.Enumerable]::Contains($setString, $existingElement, $comparer)) { + $toRemove.Add($Existing[$i]) + } + } + } + else { + if ($Remove) { + $removeString = [string[]]@($Remove | ForEach-Object ToString) + + for ($i = 0; $i -lt $existingString.Length; $i++) { + $existingElement = $existingString[$i] + if ([System.Linq.Enumerable]::Contains($removeString, $existingElement, $comparer)) { + $toRemove.Add($Existing[$i]) + } + else { + $value.Add($Existing[$i]) + } + } + } + else { + $value.AddRange($Existing) + } + + if ($Add) { + $addString = [string[]]@($Add | ForEach-Object ToString) + + for ($i = 0; $i -lt $addString.Length; $i++) { + $addElement = $addString[$i] + if (-not [System.Linq.Enumerable]::Contains($existingString, $addElement, $comparer)) { + $toAdd.Add($Add[$i]) + $value.Add($Add[$i]) + } + } + } + } + + [PSCustomObject]@{ + Value = if ($value.Count) { $value.ToArray() } else { $null } + # Also returned if the API doesn't support explicitly setting 1 value + ToAdd = $toAdd.ToArray() + ToRemove = $toRemove.ToArray() + Changed = [bool]($toAdd.Count -or $toRemove.Count) + } +} + +Function Get-AnsibleADObject { + <# + .SYNOPSIS + The -Identity params is limited to just objectGuid and distinguishedName + on Get-ADObject. Try to preparse the value to support more common props + like sAMAccountName, objectSid, userPrincipalName. + + .PARAMETER Identity + The Identity to get. + + .PARAMETER Properties + Extra properties to request on the object + + .PARAMETER Server + The explicit domain controller to query. + + .PARAMETER Credential + Custom queries to authenticate with. + + .PARAMETER GetCommand + The Get-AD* cmdlet to use to get the AD object. Defaults to Get-ADObject. + #> + [OutputType([Microsoft.ActiveDirectory.Management.ADObject])] + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [string] + $Identity, + + [Parameter()] + [AllowEmptyCollection()] + [string[]] + $Properties, + + [string] + $Server, + + [PSCredential] + $Credential, + + [Parameter()] + [System.Management.Automation.CommandInfo] + $GetCommand = $null + ) + + $getParams = @{} + if ($Properties.Count) { + $getParams.Properties = $Properties + } + if ($Server) { + $getParams.Server = $Server + } + if ($Credential) { + $getParams.Credential = $Credential + } + + # The -Identity parameter is used where possible as LDAPFilter is limited + # to just the defaultNamingContext as defined by -SearchBase. + $objectGuid = [Guid]::Empty + if ([System.Guid]::TryParse($Identity, [ref]$objectGuid)) { + $getParams.Identity = $objectGuid + } + 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 + $sidBytes = New-Object -TypeName System.Byte[] -ArgumentList $sid.BinaryLength + $sid.GetBinaryForm($sidBytes, 0) + + $value = @($sidBytes | ForEach-Object { + '\' + [System.BitConverter]::ToString($_).ToLowerInvariant() + }) -join '' + $getParams.LDAPFilter = "(objectSid=$value)" + } + catch [System.ArgumentException] { + # Finally fallback to DistinguishedName. + $getParams.Identity = $Identity + } + } + + if ($GetCommand) { + $null = $getParams.Remove('GetCommand') + } + else { + $GetCommand = Get-Command -Name Get-ADObject -Module ActiveDirectory + } + try { + $obj = & $GetCommand @getParams | Select-Object -First 1 + } + catch [Microsoft.ActiveDirectory.Management.ADIdentityNotFoundException] { + $obj = $null + } + + $obj +} + +Function Invoke-AnsibleADObject { + <# + .SYNOPSIS + Runs the module code for managing an AD object. + + .PARAMETER PropertyInfo + The properties to compare on the AD object and what the module supports. + Each object in this array must have the following keys set + Name - The module option name + Option - Module options to define in the arg spec + + The following keys are optional: + Attribute - The ldap attribute name to compare against + CaseInsensitive - The values are case insensitive (defaults to $false) + StateRequired - Set to 'present' or 'absent' if this needs to be defined for either state + New - Called when the option is to be set on the New-AD* cmdlet splat + Set - Called when the option is to be set on the Set-AD* cmdlet splat + + If Attribute is set then requested value will be compared with the + attribute specified. The current attribute value is added to the before + diff state for the option it is on. If New is not specified then the + value requested is added to the New-AD* splat based on the attribute name. + If Set is not specified then the value requested is added to the Set-AD* + splat based on the attribute name. + + If New is specified it is called with the current module, common AD + parameters and a splat that is called with New-AD*. It is up to the + scriptblock to set the required splat parameters or called whatever + function is needed. + + If Set is specified it is called with the current module, common AD + parameters, a splat that is called with Set-AD*, and the current AD object. + It is up to the scriptblock to set the required splat parameters or call + whatever function is needed. + + Both New and Set must set the $Module.Diff.after results accordingly and/or + mark $Module.Result.changed if it is making a change outside of adjusting + the splat hashtable passed in. + + .PARAMETER DefaultPath + A scriptblock that retrieves the default path the object is created in. + Defaults to the defaultNamingContext. This is invoked with a hashtable + containing parameters used to connect to AD, such as the Server and/or + Credential. + + .PARAMETER ModuleNoun + The module cmdlet noun that is being managed. This is used to run the + correct Get-AD*, Set-AD*, and New-AD* cmdlets when needed. + + .PARAMETER ExtraProperties + Extra properties to request when getting the AD object. + + .PARAMETER PreAction + A scriptblock that is called at the beginning to perform any tasks needed + before the module util is run. This is called with the module object, + common ad parameters, and the ad object if it was found based on the input + options. + + .PARAMETER PostAction + A scriptblock that is called at the end to perform any tasks once the + object has been configured. This is called with the module object, common + ad parameters, and the ad object (state=present) else $null (state=absent) + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [object[]] + $PropertyInfo, + + [Parameter()] + [ScriptBlock] + $DefaultPath = { param ($Module, $Params) (Get-ADRootDSE @Params -Properties defaultNamingContext).defaultNamingContext }, + + [Parameter()] + [string] + $ModuleNoun = 'ADObject', + + [Parameter()] + [string[]] + $ExtraProperties, + + [Parameter()] + [ScriptBlock] + $PreAction, + + [Parameter()] + [ScriptBlock] + $PostAction + ) + + $spec = @{ + options = @{ + attributes = @{ + default = @{} + type = 'dict' + options = @{ + add = @{ + default = @{} + type = 'dict' + } + remove = @{ + default = @{} + type = 'dict' + } + set = @{ + default = @{} + type = 'dict' + } + } + } + domain_password = @{ + no_log = $true + type = 'str' + } + domain_server = @{ + type = 'str' + } + domain_username = @{ + type = 'str' + } + identity = @{ + type = 'str' + } + name = @{ + type = 'str' + } + path = @{ + type = 'str' + } + state = @{ + choices = 'absent', 'present' + default = 'present' + type = 'str' + } + } + required_one_of = @( + , @("identity", "name") + ) + required_together = @(, @('domain_username', 'domain_password')) + supports_check_mode = $true + } + + $stateRequiredIf = @{ + present = @('name') + absent = @() + } + + $PropertyInfo = @( + $PropertyInfo + + # These 3 options are common to all AD objects. + [PSCustomObject]@{ + Name = 'description' + Option = @{ type = 'str' } + Attribute = 'description' + } + [PSCustomObject]@{ + Name = 'display_name' + Option = @{ type = 'str' } + Attribute = 'displayName' + } + [PSCustomObject]@{ + Name = 'protect_from_deletion' + Option = @{ type = 'bool' } + Attribute = 'ProtectedFromAccidentalDeletion' + } + ) + + [string[]]$requestedAttributes = @( + foreach ($propInfo in $PropertyInfo) { + $ansibleOption = $propInfo.Name + + if ($propInfo.StateRequired) { + $stateRequiredIf[$propInfo.StateRequired] += $ansibleOption + } + + $spec.options[$ansibleOption] = $propInfo.Option + + if ($propInfo.Attribute) { + $propInfo.Attribute + } + } + + $ExtraProperties + ) + + $spec.required_if = @( + foreach ($kvp in $stateRequiredIf.GetEnumerator()) { + if ($kvp.Value) { + , @("state", $kvp.Key, $kvp.Value) + } + } + ) + + $module = [Ansible.Basic.AnsibleModule]::Create(@(), $spec) + $module.Result.distinguished_name = $null + $module.Result.object_guid = $null + + $adParams = @{} + if ($module.Params.domain_server) { + $adParams.Server = $module.Params.domain_server + } + + if ($module.Params.domain_username) { + $adParams.Credential = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList @( + $module.Params.domain_username, + (ConvertTo-SecureString -AsPlainText -Force -String $module.Params.domain_password) + ) + } + + $defaultObjectPath = & $DefaultPath $module $adParams + $getCommand = Get-Command -Name "Get-$ModuleNoun" -Module ActiveDirectory + $newCommand = Get-Command -Name "New-$ModuleNoun" -Module ActiveDirectory + $setCommand = Get-Command -Name "Set-$ModuleNoun" -Module ActiveDirectory + + $requestedAttributes = [System.Collections.Generic.HashSet[string]]@( + $requestedAttributes + 'name' + $module.Params.attributes.add.Keys + $module.Params.attributes.remove.Keys + $module.Params.attributes.set.Keys + ) | Where-Object { $_ } + + $namePrefix = 'CN' + if ($ModuleNoun -eq 'ADOrganizationalUnit' -or $Module.Params.type -eq 'organizationalUnit') { + $namePrefix = 'OU' + } + + $identity = if ($module.Params.identity) { + $module.Params.identity + } + else { + $ouPath = $defaultObjectPath + if ($module.Params.path) { + $ouPath = $module.Params.path + } + "$namePrefix=$($Module.Params.name -replace ',', '\,'),$ouPath" + } + + $getParams = @{ + GetCommand = $getCommand + Identity = $identity + Properties = $requestedAttributes + } + $adObject = Get-AnsibleADObject @getParams @adParams + if ($adObject) { + $module.Result.object_guid = $adObject.ObjectGUID + $module.Result.distinguished_name = $adObject.DistinguishedName + + $module.Diff.before = @{ + attributes = $null + name = $adObject.Name + path = @($adObject.DistinguishedName -split '[^\\],', 2)[-1] + } + + foreach ($propInfo in $PropertyInfo) { + $propValue = $module.Params[$propInfo.Name] + if ($null -eq $propValue -or -not $propInfo.Attribute) { + continue + } + + $actualValue = $adObject[$propInfo.Attribute].Value + if ($module.Option.no_log) { + $actualValue = 'VALUE_SPECIFIED_IN_NO_LOG_PARAMETER' + } + if ($actualValue -is [System.Collections.IList]) { + $actualValue = @($actualValue | Sort-Object) + } + $module.Diff.before[$propInfo.Name] = $actualValue + } + } + else { + $module.Diff.before = $null + } + + if ($PreAction) { + $null = & $PreAction $module $adParams $adObject + } + + if ($module.Params.state -eq 'absent') { + if ($adObject) { + $removeParams = @{ + Confirm = $false + WhatIf = $module.CheckMode + } + + # Remove-ADObject -Recursive fails with access is denied, use this + # instead to remove the child objects manually + Get-ADObject -Filter * -Properties ProtectedFromAccidentalDeletion -Searchbase $adObject.DistinguishedName | + Sort-Object -Property { $_.DistinguishedName.Length } -Descending | + ForEach-Object -Process { + if ($_.ProtectedFromAccidentalDeletion) { + $_ | Set-ADObject -ProtectedFromAccidentalDeletion $false @removeParams @adParams + } + $_ | Remove-ADObject @removeParams @adParams + } + + $module.Result.changed = $true + } + + $module.Diff.after = $null + } + else { + $attributes = $module.Params.attributes + $objectDN = $null + $objectGuid = $null + + if (-not $adObject) { + $newParams = @{ + Confirm = $false + Name = $module.Params.name + WhatIf = $module.CheckMode + PassThru = $true + } + + $objectPath = $null + if ($module.Params.path) { + $objectPath = $path + $newParams.Path = $module.Params.path + } + else { + $objectPath = $defaultObjectPath + } + + $diffAttributes = @{} + $null = Update-AnsibleADSetADObjectParam @attributes -Splat $newParams -Diff $diffAttributes -ForNew + + $module.Diff.after = @{ + attributes = $diffAttributes.after + name = $module.Params.name + path = $objectPath + } + + foreach ($propInfo in $PropertyInfo) { + $propValue = $module.Params[$propInfo.Name] + if ($propValue -is [System.Collections.IDictionary]) { + if ($propValue.Count -eq 0) { + continue + } + } + elseif ([string]::IsNullOrWhiteSpace($propValue)) { + continue + } + + if ($propInfo.New) { + $null = & $propInfo.New $module $adParams $newParams + } + elseif ($propInfo.Attribute) { + if ($propValue -is [System.Collections.IDictionary]) { + $propValue = @($propValue['add']; $propValue['set']) | Select-Object -Unique + } + + $newParams[$propInfo.Attribute] = $propValue + + if ($propInfo.Option.no_log) { + $propValue = 'VALUE_SPECIFIED_IN_NO_LOG_PARAMETER' + } + if ($propValue -is [System.Collections.IList]) { + $propValue = @($propValue | Sort-Object) + } + $module.Diff.after[$propInfo.Name] = $propValue + } + } + + try { + $adObject = & $newCommand @newParams @adParams + } + catch { + # Using FailJson means other useful debugging information + # like the diff output is returned + $module.FailJson("New-$ModuleNoun failed: $_", $_) + } + $module.Result.changed = $true + + if ($module.CheckMode) { + $objectDN = "$namePrefix=$($module.Params.name -replace ',', '\,'),$objectPath" + $objectGuid = [Guid]::Empty # Dummy value for check mode + } + else { + $objectDN = $adObject.DistinguishedName + $objectGuid = $adObject.ObjectGUID + } + } + else { + $objectDN = $adObject.DistinguishedName + $objectGuid = $adObject.ObjectGUID + $objectName = $adObject.Name + $objectPath = @($objectDN -split '[^\\],', 2)[-1] + + $commonParams = @{ + Confirm = $false + Identity = $adObject.ObjectGUID + PassThru = $true + WhatIf = $module.CheckMode + } + $setParams = @{} + + $diffAttributes = @{} + $null = Update-AnsibleADSetADObjectParam @attributes -Splat $setParams -Diff $diffAttributes -ADObject $adObject + + $module.Diff.before.attributes = $diffAttributes.before + $module.Diff.after = @{ + attributes = $diffAttributes.after + name = $objectName + path = $objectPath + } + + foreach ($propInfo in $PropertyInfo) { + $propValue = $module.Params[$propInfo.Name] + if ($null -eq $propValue) { + continue + } + + if ($propInfo.Set) { + $null = & $propInfo.Set $module $adParams $setParams $adObject + } + elseif ($propInfo.Attribute) { + $actualValue = $adObject[$propInfo.Attribute] + + $compareParams = @{ + Existing = $actualValue + CaseInsensitive = $propInfo.CaseInsensitive + } + + if ($propValue -is [System.Collections.IDictionary]) { + $compareParams.Add = $propValue['add'] + $compareParams.Remove = $propValue['remove'] + $compareParams.Set = $propValue['set'] + } + elseif ([string]::IsNullOrWhiteSpace($propValue)) { + $compareParams.Set = @() + } + else { + $compareParams.Set = @($propValue) + } + + $res = Compare-AnsibleADIdempotentList @compareParams + $newValue = $res.Value + if ($res.Changed) { + $setParams[$propInfo.Attribute] = $newValue + } + + $noLog = $propInfo.Option.no_log + if ($newValue) { + if ($res.Changed -and $noLog) { + $newValue = 'VALUE_SPECIFIED_IN_NO_LOG_PARAMETER - changed' + } + elseif ($noLog) { + $newValue = 'VALUE_SPECIFIED_IN_NO_LOG_PARAMETER' + } + + if ($newValue -is [System.Collections.IList]) { + $newValue = @($newValue | Sort-Object) + } + } + + $module.Diff.after[$propInfo.Name] = $newValue + } + } + + $finalADObject = $null + if ($module.Params.name -cne $objectName) { + $objectName = $module.Params.name + $module.Diff.after.name = $objectName + + $finalADObject = Rename-ADObject @commonParams -NewName $objectName + $module.Result.changed = $true + } + + if ($module.Params.path -and $module.Params.path -ne $objectPath) { + $objectPath = $module.Params.path + $module.Diff.after.path = $objectPath + + $addProtection = $false + if ($adObject.ProtectedFromAccidentalDeletion) { + $addProtection = $true + $null = Set-ADObject -ProtectedFromAccidentalDeletion $false @commonParams @adParams + } + + try { + $finalADObject = Move-ADObject @commonParams -TargetPath $objectPath + } + finally { + if ($addProtection) { + $null = Set-ADObject -ProtectedFromAccidentalDeletion $true @commonParams @adParams + } + } + + $module.Result.changed = $true + } + + if ($setParams.Count) { + try { + $finalADObject = & $setCommand @commonParams @setParams @adParams + } + catch { + # Using FailJson means other useful debugging information + # like the diff output is returned + $module.FailJson("Set-$ModuleNoun failed: $_", $_) + } + $module.Result.changed = $true + } + + # Won't be set in check mode + if ($finalADObject) { + $objectDN = $finalADObject.DistinguishedName + } + else { + $objectDN = "$namePrefix=$($objectName -replace ',', '\,'),$objectPath" + } + } + + # Explicit vars are set when running in check mode as the adObject may not + # have the desired values set at runtime + $module.Result.distinguished_name = $objectDN + $module.Result.object_guid = $objectGuid.Guid + } + + if ($PostAction) { + $null = & $PostAction $Module $adParams $adObject + } + + $module.ExitJson() +} + +$exportMembers = @{ + Function = @( + "Compare-AnsibleADIdempotentList" + "Get-AnsibleADObject" + "Invoke-AnsibleADObject" + ) +} +Export-ModuleMember @exportMembers diff --git a/ansible_collections/microsoft/ad/plugins/modules/computer.ps1 b/ansible_collections/microsoft/ad/plugins/modules/computer.ps1 new file mode 100644 index 000000000..b97bb1062 --- /dev/null +++ b/ansible_collections/microsoft/ad/plugins/modules/computer.ps1 @@ -0,0 +1,204 @@ +#!powershell + +# Copyright: (c) 2023, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic +#AnsibleRequires -PowerShell ..module_utils._ADObject + +$setParams = @{ + PropertyInfo = @( + [PSCustomObject]@{ + Name = 'delegates' + Option = @{ + aliases = 'principals_allowed_to_delegate' + type = 'dict' + options = @{ + add = @{ type = 'list'; elements = 'str' } + remove = @{ type = 'list'; elements = 'str' } + set = @{ type = 'list'; elements = 'str' } + } + } + Attribute = 'PrincipalsAllowedToDelegateToAccount' + CaseInsensitive = $true + } + [PSCustomObject]@{ + Name = 'dns_hostname' + Option = @{ type = 'str' } + Attribute = 'DNSHostName' + } + [PSCustomObject]@{ + Name = 'enabled' + Option = @{ type = 'bool' } + Attribute = 'Enabled' + } + [PSCustomObject]@{ + Name = 'kerberos_encryption_types' + Option = @{ + type = 'dict' + options = @{ + add = @{ + choices = 'aes128', 'aes256', 'des', 'rc4' + type = 'list' + elements = 'str' + } + remove = @{ + choices = 'aes128', 'aes256', 'des', 'rc4' + type = 'list' + elements = 'str' + } + set = @{ + choices = 'aes128', 'aes256', 'des', 'rc4' + type = 'list' + elements = 'str' + } + } + } + Attribute = 'KerberosEncryptionType' + CaseInsensitive = $true + + New = { + param($Module, $ADParams, $NewParams) + + $encTypes = @( + $Module.Params.kerberos_encryption_types.add + $Module.Params.kerberos_encryption_types.set + ) | Select-Object -Unique + + $NewParams.KerberosEncryptionType = $encTypes + $Module.Diff.after.kerberos_encryption_types = $MencTypes + } + Set = { + param($Module, $ADParams, $SetParams, $ADObject) + + # This is an enum value and needs custom handling for things like + # unsetting the values with none. + $rawValue = $ADObject.KerberosEncryptionType.Value + + $existing = foreach ($v in [System.Enum]::GetValues($rawValue.GetType())) { + if ($rawValue -band $v) { $v.ToString() } + } + if ($existing -eq 'None') { + $existing = @() + } + $module.Diff.before.kerberos_encryption_types = $existing + + $desired = $Module.Params.kerberos_encryption_types + $compareParams = @{ + Existing = $existing + CaseInsensitive = $true + } + $res = Compare-AnsibleADIdempotentList @compareParams @desired + if ($res.Changed) { + if ($res.Value) { + $SetParams.KerberosEncryptionType = $res.Value -join ', ' + } + else { + $SetParams.KerberosEncryptionType = 'None' + } + } + $module.Diff.after.kerberos_encryption_types = $res.Value + } + } + [PSCustomObject]@{ + Name = 'location' + Option = @{ type = 'str' } + Attribute = 'Location' + } + [PSCustomObject]@{ + Name = 'managed_by' + Option = @{ type = 'str' } + Attribute = 'ManagedBy' + } + [PSCustomObject]@{ + Name = 'sam_account_name' + Option = @{ type = 'str' } + Attribute = 'sAMAccountName' + } + [PSCustomObject]@{ + Name = 'spn' + Option = @{ + aliases = 'spns' + type = 'dict' + options = @{ + add = @{ type = 'list'; elements = 'str' } + remove = @{ type = 'list'; elements = 'str' } + set = @{ type = 'list'; elements = 'str' } + } + } + Attribute = 'ServicePrincipalNames' + New = { + param($Module, $ADParams, $NewParams) + + $spns = @( + $Module.Params.spn.add + $Module.Params.spn.set + ) | Select-Object -Unique + + $NewParams.ServicePrincipalNames = $spns + $Module.Diff.after.spn = $spns + } + Set = { + param($Module, $ADParams, $SetParams, $ADObject) + + $desired = $Module.Params.spn + $compareParams = @{ + Existing = $ADObject.ServicePrincipalNames + CaseInsensitive = $true + } + $res = Compare-AnsibleADIdempotentList @compareParams @desired + if ($res.Changed) { + $SetParams.ServicePrincipalNames = @{} + if ($res.ToAdd) { + $SetParams.ServicePrincipalNames.Add = $res.ToAdd + } + if ($res.ToRemove) { + $SetParams.ServicePrincipalNames.Remove = $res.ToRemove + } + } + $module.Diff.after.kerberos_encryption_types = @($res.Value | Sort-Object) + } + } + [PSCustomObject]@{ + Name = 'trusted_for_delegation' + Option = @{ type = 'bool' } + Attribute = 'TrustedForDelegation' + } + [PSCustomObject]@{ + Name = 'upn' + Option = @{ type = 'str' } + Attribute = 'userPrincipalName' + } + ) + ModuleNoun = 'ADComputer' + DefaultPath = { + param($Module, $ADParams) + + $GUID_COMPUTERS_CONTAINER_W = 'AA312825768811D1ADED00C04FD8D5CD' + $defaultNamingContext = (Get-ADRootDSE @ADParams -Properties defaultNamingContext).defaultNamingContext + + Get-ADObject @ADParams -Identity $defaultNamingContext -Properties wellKnownObjects | + Select-Object -ExpandProperty wellKnownObjects | + Where-Object { $_.StartsWith("B:32:$($GUID_COMPUTERS_CONTAINER_W):") } | + ForEach-Object Substring 38 + } + PreAction = { + param ($Module, $ADParams, $ADObject) + + if ($Module.Params.sam_account_name -and -not $Module.Params.sam_account_name.EndsWith('$')) { + $Module.Params.sam_account_name = "$($Module.Params.sam_account_name)$" + } + } + PostAction = { + param($Module, $ADParams, $ADObject) + + if ($ADObject) { + $Module.Result.sid = $ADObject.SID.Value + } + elseif ($Module.Params.state -eq 'present') { + # Use dummy value for check mode when creating a new user + $Module.Result.sid = 'S-1-5-0000' + } + } +} +Invoke-AnsibleADObject @setParams diff --git a/ansible_collections/microsoft/ad/plugins/modules/computer.py b/ansible_collections/microsoft/ad/plugins/modules/computer.py new file mode 100644 index 000000000..498b882ba --- /dev/null +++ b/ansible_collections/microsoft/ad/plugins/modules/computer.py @@ -0,0 +1,277 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2023, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r""" +--- +module: computer +short_description: Manage Active Directory computer objects +description: +- Manages Active Directory computer objects and their attributes. +options: + delegates: + description: + - The principal objects that the current AD object can trust for + delegation to either add, remove or set. + - The values for each sub option must be specified as a distinguished name + C(CN=shenetworks,CN=Users,DC=ansible,DC=test) + - This is the value set on the C(msDS-AllowedToActOnBehalfOfOtherIdentity) + LDAP attribute. + - This is a highly sensitive attribute as it allows the principals + specified to impersonate any account when authenticating with the AD + computer object being managed. + - To clear all principals, use I(set) with an empty list. + - See R(Setting list option values,ansible_collections.microsoft.ad.docsite.guide_list_values) + for more information on how to add/remove/set list options. + aliases: + - principals_allowed_to_delegate + type: dict + suboptions: + add: + description: + - The AD objects by their C(DistinguishedName) to add as a principal + allowed to delegate. + - Any existing principals not specified by I(add) will be untouched + unless specified by I(remove) or not in I(set). + type: list + elements: str + remove: + description: + - The AD objects by their C(DistinguishedName) to remove as a principal + allowed to delegate. + - Any existing pricipals not specified by I(remove) will be untouched + unless I(set) is defined. + type: list + elements: str + set: + description: + - The AD objects by their C(DistinguishedName) to set as the only + principals allowed to delegate. + - This will remove any existing principals if not specified in this + list. + - Specify an empty list to remove all principals allowed to delegate. + type: list + elements: str + dns_hostname: + description: + - Specifies the fully qualified domain name (FQDN) of the computer. + - This is the value set on the C(dNSHostName) LDAP attribute. + type: str + enabled: + description: + - C(yes) will enable the group. + - C(no) will disable the group. + type: bool + kerberos_encryption_types: + description: + - Specifies the Kerberos encryption types supported the AD computer + account. + - This is the value set on the C(msDS-SupportedEncryptionTypes) LDAP + attribute. + - Avoid using C(rc4) or C(des) as they are older an insecure encryption + protocols. + - To clear all encryption types, use I(set) with an empty list. + - 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 + suboptions: + add: + description: + - The encryption types to add to the existing set. + - Any existing encryption types not specified by I(add) will be + untouched unless specified by I(remove) or not in I(set). + choices: + - aes128 + - aes256 + - des + - rc4 + type: list + elements: str + remove: + description: + - The encryption types to remove from the existing set. + - Any existing encryption types not specified by I(remove) will be + untouched unless I(set) is defined. + choices: + - aes128 + - aes256 + - des + - rc4 + type: list + elements: str + set: + description: + - The encryption types to set as the only encryption types allowed + by the AD computer. + - This will remove any existing encryption types if not specified in + this list. + - Specify an empty list to remove all encryption types. + choices: + - aes128 + - aes256 + - des + - rc4 + type: list + elements: str + location: + description: + - Sets the location of the computer account. + - This is the value set on the C(location) LDAP attribute. + type: str + managed_by: + description: + - The user or group that manages the object. + - The value can be in the form of a C(distinguishedName), C(objectGUID), + C(objectSid), or sAMAccountName). + - This is the value set on the C(managedBy) LDAP attribute. + type: str + sam_account_name: + description: + - The C(sAMAccountName) value to set for the group. + - If omitted, the I(name) value is used when creating a new group. + - It has a maximum of 256 characters, 15 is advised for older + operating systems compatibility. + - If ommitted the value is the same as C(name$) when the computer is + created. + - Note that all computer C(sAMAccountName) values need to end with a C($). + - If C($) is omitted, it will be added to the end. + type: str + spn: + description: + - Specifies the service principal name(s) for the account to add, remove or + set. + - This is the value set on the C(servicePrincipalName) LDAP attribute. + - To clear all service principal names, use I(set) with an empty list. + - See R(Setting list option values,ansible_collections.microsoft.ad.docsite.guide_list_values) + for more information on how to add/remove/set list options. + aliases: + - spns + type: dict + suboptions: + add: + description: + - The SPNs to add to C(servicePrincipalName). + type: list + elements: str + remove: + description: + - The SPNs to remove from C(servicePrincipalName). + type: list + elements: str + set: + description: + - The SPNs to set as the only values in C(servicePrincipalName). + - This will clear out any existing SPNs if not in the specified list. + - Set to an empty list to clear all SPNs on the AD object. + type: list + elements: str + trusted_for_delegation: + description: + - Specifies whether an account is trusted for Kerberos delegation. + - This is also known as unconstrained Kerberos delegation. + - This sets the C(ADS_UF_TRUSTED_FOR_DELEGATION) flag in the + C(userAccountControl) LDAP attribute. + type: bool + upn: + description: + - Configures the User Principal Name (UPN) for the account. + - The format is C(<username>@<domain>). + - This is the value set on the C(userPrincipalName) LDAP attribute. + type: str +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. +extends_documentation_fragment: +- microsoft.ad.ad_object +- ansible.builtin.action_common_attributes +attributes: + check_mode: + support: full + diff_mode: + support: full + platform: + platforms: + - windows +seealso: +- module: microsoft.ad.domain +- module: microsoft.ad.domain_controller +- module: microsoft.ad.membership +- module: microsoft.ad.object_info +- module: microsoft.ad.object +- module: microsoft.ad.offline_join +- module: microsoft.ad.group +- ref: Migration guide <ansible_collections.microsoft.ad.docsite.guide_migration.migrated_modules.win_domain_computer> + description: This module replaces C(community.windows.win_domain_computer). See the migration guide for details. +- module: community.windows.win_domain_computer +author: +- Jordan Borean (@jborean93) +""" + +EXAMPLES = r""" +- name: Add linux computer to Active Directory OU using a windows machine + microsoft.ad.computer: + name: one_linux_server + sam_account_name: linux_server$ + dns_hostname: one_linux_server.my_org.local + path: OU=servers,DC=my_org,DC=local + description: Example of linux server + enabled: yes + state: present + +- name: Remove linux computer from Active Directory using a windows machine + microsoft.ad.computer: + name: one_linux_server + state: absent + +- name: Add SPNs to computer + microsoft.ad.computer: + name: TheComputer + spn: + add: + - HOST/TheComputer + - HOST/TheComputer.domain.test + - HOST/TheComputer.domain.test:1234 + +- name: Remove SPNs on the computer + microsoft.ad.computer: + name: TheComputer + spn: + remove: + - HOST/TheComputer + - HOST/TheComputer.domain.test + - HOST/TheComputer.domain.test:1234 + +- name: Set the principals the computer trusts for delegation from + microsoft.ad.computer: + name: TheComputer + delegates: + set: + - CN=FileShare,OU=Computers,DC=domain,DC=test + - CN=DC,OU=Domain Controllers,DC=domain,DC=test +""" + +RETURN = r""" +object_guid: + description: + - The C(objectGUID) of the AD object that was created, removed, or edited. + - If a new object was created in check mode, a GUID of 0s will be returned. + returned: always + type: str + sample: d84a141f-2b99-4f08-9da0-ed2d26864ba1 +distinguished_name: + description: + - The C(distinguishedName) of the AD object that was created, removed, or edited. + returned: always + type: str + sample: CN=MyComputer,CN=Computers,DC=domain,DC=test +sid: + description: + - The Security Identifier (SID) of the account managed. + - If a new computer was created in check mode, the SID will be C(S-1-5-0000). + returned: always + type: str + sample: S-1-5-21-4151808797-3430561092-2843464588-1104 +""" diff --git a/ansible_collections/microsoft/ad/plugins/modules/debug_ldap_client.py b/ansible_collections/microsoft/ad/plugins/modules/debug_ldap_client.py new file mode 100644 index 000000000..4da7537bc --- /dev/null +++ b/ansible_collections/microsoft/ad/plugins/modules/debug_ldap_client.py @@ -0,0 +1,213 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2023, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r""" +--- +module: debug_ldap_client +short_description: Get host information for debugging LDAP connections +description: +- Get information about the current Ansible host to debug LDAP connections and + their capabilities. +version_added: 1.1.0 +options: {} +notes: +- See R(LDAP connection help,ansible_collections.microsoft.ad.docsite.guide_ldap_connection) + for more information about LDAP connections. +- The return values are not part of any contract and can change in the future. + It is meant to give a snapshot of the Ansible host that can help debug LDAP + connection issues and not be used as part of a normal playbook. +extends_documentation_fragment: +- ansible.builtin.action_common_attributes +- ansible.builtin.action_common_attributes.flow +attributes: + check_mode: + support: full + diff_mode: + support: none + platform: + platforms: + - posix + action: + support: full + async: + support: full + bypass_host_loop: + support: none +author: +- Jordan Borean (@jborean93) +""" + +EXAMPLES = r""" +- name: Get information about the Ansible host's LDAP capabilities + microsoft.ad.debug_ldap_client: +""" + +RETURN = r""" +dns: + description: + - Details about the SRV LDAP server lookup. + - The values will only be populated if dnspython is installed. + returned: always + type: complex + contains: + default_port: + description: + - The default port of the SRV record chosen. + returned: dnspython is installed + type: int + sample: 389 + default_server: + description: + - The default hostname of the SRV record chosen. + returned: dnspython is installed + type: str + sample: dc01.domain.com + exception: + description: + - Any exceptions that occurred when getting the SRV records. + returned: dnspython is installed + type: str + sample: "" + records: + description: + - The SRV records that were found during the LDAP server lookup. + returned: dnspython is installed + type: list + contains: + port: + description: + - The port of this SRV record. + returned: dnspython is installed and default_realm is found + type: int + sample: 389 + priority: + description: + - The record priority value. + returned: dnspython is installed and default_realm is found + type: int + sample: 0 + target: + description: + - The target name of the SRV record. + returned: dnspython is installed and default_realm is found + type: str + sample: dc01.domain.com. + weight: + description: + - The record weight value. + returned: dnspython is installed and default_realm is found + type: int + sample: 100 +kerberos: + description: + - Details about the host Kerberos setup. + - The values will only be populated if krb5 is installed. + returned: always + type: complex + contains: + default_cc: + description: + - Details about the default Kerberos credential cache. + returned: krb5 is installed + type: complex + contains: + creds: + description: + - A list of credentials that is stored in the ccache. + - This requires C(krb5 >= 0.5.0) to be populated. + returned: krb5 is installed + type: complex + contains: + client: + description: + - The client principal name the credential is for. + returned: krb5 >= 0.5.0 is installed + type: str + sample: username@DOMAIN.COM + server: + description: + - The server principal name the credential is for. + returned: krb5 >= 0.5.0 is installed + type: str + sample: krbtgt/DOMAIN.COM@DOMAIN.COM + exception: + description: + - Any exceptions that occurred when getting the ccache information. + returned: krb5 is installed + type: str + sample: "" + name: + description: + - The default ccache type and name. + returned: krb5 is installed + type: str + sample: FILE:/tmp/krb5cc_1000 + principal: + description: + - The default principal of the ccache + returned: krb5 is installed + type: str + sample: username@DOMAIN.COM + default_realm: + description: + - The default_realm as reported by Kerberos. + - This value is used for the automatic server lookup. + returned: krb5 is installed + type: str + sample: domain.com + exception: + description: + - Exception details if the default realm could not be retrieved. + returned: krb5 is installed + type: str + sample: "" +packages: + description: + - All the packages used by this collection for LDAP connections and their + installed versions. + - If the package is not installed, or failed to import, the value is the + traceback from the import process. + - This can be used to determine the availability of optional features like + Kerberos authentication or server lookups. + returned: always + type: complex + contains: + dnspython: + description: + - The installed version of C(dnspython) or the import error if not + installed. + returned: always + type: str + sample: '2.3.0' + dpapi_ng: + description: + - The installed version of C(dpapi-ng) or the import error if not + installed. + returned: always + type: str + sample: '0.1.0' + krb5: + description: + - The installed version of C(krb5) or the import error if not + installed. + returned: always + type: str + sample: '0.5.0' + pyspnego: + description: + - The installed version of C(pyspnego) or the import error if not + installed. + returned: always + type: str + sample: '0.8.0' + sansldap: + description: + - The installed version of C(sansldap) or the import error if not + installed. + returned: always + type: str + sample: '0.1.0' +""" diff --git a/ansible_collections/microsoft/ad/plugins/modules/domain.ps1 b/ansible_collections/microsoft/ad/plugins/modules/domain.ps1 new file mode 100644 index 000000000..1d68fab85 --- /dev/null +++ b/ansible_collections/microsoft/ad/plugins/modules/domain.ps1 @@ -0,0 +1,209 @@ +#!powershell + +# Copyright (c) 2022 Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic + +$spec = @{ + options = @{ + create_dns_delegation = @{ + type = 'bool' + } + database_path = @{ + type = 'path' + } + dns_domain_name = @{ + required = $true + type = 'str' + } + domain_mode = @{ + type = 'str' + } + domain_netbios_name = @{ + type = 'str' + } + forest_mode = @{ + type = 'str' + } + install_dns = @{ + default = $true + type = 'bool' + } + log_path = @{ + type = 'path' + } + reboot = @{ + default = $false + type = 'bool' + } + safe_mode_password = @{ + no_log = $true + required = $true + type = 'str' + } + sysvol_path = @{ + type = 'path' + } + } + supports_check_mode = $true +} +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +$module.Result.reboot_required = $false +$module.Result._do_action_reboot = $false # Used by action plugin + +$create_dns_delegation = $module.Params.create_dns_delegation +$database_path = $module.Params.database_path +$dns_domain_name = $module.Params.dns_domain_name +$domain_mode = $module.Params.domain_mode +$domain_netbios_name = $module.Params.domain_netbios_name +$forest_mode = $module.Params.forest_mode +$install_dns = $module.Params.install_dns +$log_path = $module.Params.log_path +$safe_mode_password = $module.Params.safe_mode_password +$sysvol_path = $module.Params.sysvol_path + +if ([System.Environment]::OSVersion.Version -lt [Version]"6.2") { + $module.FailJson("microsoft.ad.domain requires Windows Server 2012 or higher") +} + +if ($domain_netbios_name -and $domain_netbios_name.Length -gt 15) { + $module.FailJson("The parameter 'domain_netbios_name' should not exceed 15 characters in length") +} + +$requiredFeatures = @("AD-Domain-Services", "RSAT-ADDS") +$features = Get-WindowsFeature -Name $requiredFeatures +$unavailableFeatures = Compare-Object -ReferenceObject $requiredFeatures -DifferenceObject $features.Name -PassThru + +if ($unavailableFeatures) { + $module.FailJson("The following features required for a domain controller are unavailable: $($unavailableFeatures -join ',')") +} + +$missingFeatures = $features | Where-Object InstallState -NE Installed +if ($missingFeatures) { + $res = Install-WindowsFeature -Name $missingFeatures -WhatIf:$module.CheckMode + $module.Result.changed = $true + $module.Result.reboot_required = [bool]$res.RestartNeeded + + # When in check mode and the prereq was "installed" we need to exit early as + # the AD cmdlets weren't really installed + if ($module.CheckMode) { + $module.ExitJson() + } +} + +# Check that we got a valid domain_mode +$validDomainModes = [Enum]::GetNames((Get-Command -Name Install-ADDSForest).Parameters.DomainMode.ParameterType) +if (($null -ne $domain_mode) -and -not ($domain_mode -in $validDomainModes)) { + $validModes = $validDomainModes -join ", " + $module.FailJson("The parameter 'domain_mode' does not accept '$domain_mode', please use one of: $validModes") +} + +# Check that we got a valid forest_mode +$validForestModes = [Enum]::GetNames((Get-Command -Name Install-ADDSForest).Parameters.ForestMode.ParameterType) +if (($null -ne $forest_mode) -and -not ($forest_mode -in $validForestModes)) { + $validModes = $validForestModes -join ", " + $module.FailJson("The parameter 'forest_mode' does not accept '$forest_mode', please use one of: $validModes") +} + +try { + $forestContext = New-Object -TypeName System.DirectoryServices.ActiveDirectory.DirectoryContext -ArgumentList @( + 'Forest', $dns_domain_name + ) + $forest = [System.DirectoryServices.ActiveDirectory.Forest]::GetForest($forestContext) +} +catch [System.DirectoryServices.ActiveDirectory.ActiveDirectoryObjectNotFoundException] { + $forest = $null +} +catch [System.DirectoryServices.ActiveDirectory.ActiveDirectoryOperationException] { + $forest = $null +} + +if (-not $forest) { + $installParams = @{ + DomainName = $dns_domain_name + SafeModeAdministratorPassword = (ConvertTo-SecureString $safe_mode_password -AsPlainText -Force) + Confirm = $false + SkipPreChecks = $true + InstallDns = $install_dns + NoRebootOnCompletion = $true + WhatIf = $module.CheckMode + } + + if ($database_path) { + $installParams.DatabasePath = $database_path + } + + if ($sysvol_path) { + $installParams.SysvolPath = $sysvol_path + } + + if ($log_path) { + $installParams.LogPath = $log_path + } + + if ($domain_netbios_name) { + $installParams.DomainNetBiosName = $domain_netbios_name + } + + if ($null -ne $create_dns_delegation) { + $installParams.CreateDnsDelegation = $create_dns_delegation + } + + if ($domain_mode) { + $installParams.DomainMode = $domain_mode + } + + if ($forest_mode) { + $installParams.ForestMode = $forest_mode + } + + $res = $null + try { + $res = Install-ADDSForest @installParams + } + catch [Microsoft.DirectoryServices.Deployment.DCPromoExecutionException] { + # ExitCode 15 == 'Role change is in progress or this computer needs to be restarted.' + # DCPromo exit codes details can be found at + # https://docs.microsoft.com/en-us/windows-server/identity/ad-ds/deploy/troubleshooting-domain-controller-deployment + if ($_.Exception.ExitCode -in @(15, 19)) { + $module.Result.reboot_required = $true + $module.Result._do_action_reboot = $true + } + + $module.FailJson("Failed to install ADDSForest, DCPromo exited with $($_.Exception.ExitCode): $($_.Exception.Message)", $_) + } + finally { + # The Netlogon service is set to auto start but is not started. This is + # required for Ansible to connect back to the host and reboot in a + # later task. Even if this fails Ansible can still connect but only + # with ansible_winrm_transport=basic so we just display a warning if + # this fails. + if (-not $module.CheckMode) { + try { + Start-Service -Name Netlogon + } + catch { + $msg = -join @( + "Failed to start the Netlogon service after promoting the host, " + "Ansible may be unable to connect until the host is manually rebooting: $($_.Exception.Message)" + ) + $module.Warn($msg) + } + } + } + + $module.Result.changed = $true + + if ($module.CheckMode) { + # the return value after -WhatIf does not have RebootRequired populated + # manually set to True as the domain would have been installed + $module.Result.reboot_required = $true + } + elseif ($res.RebootRequired) { + $module.Result.reboot_required = $true + } +} + +$module.ExitJson() diff --git a/ansible_collections/microsoft/ad/plugins/modules/domain.py b/ansible_collections/microsoft/ad/plugins/modules/domain.py new file mode 100644 index 000000000..72d4fc21a --- /dev/null +++ b/ansible_collections/microsoft/ad/plugins/modules/domain.py @@ -0,0 +1,143 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2022 Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r""" +module: domain +short_description: Ensures the existence of a Windows domain +description: +- Ensure that the domain named by I(dns_domain_name) exists and is reachable. +- If the domain is not reachable, the domain is created in a new forest on the target Windows Server 2012+ host. +- This module may require subsequent use of the M(ansible.windows.win_reboot) action if changes are made. +options: + create_dns_delegation: + description: + - Whether to create a DNS delegation that references the new DNS server that you install along with the domain controller. + - Valid for Active Directory-integrated DNS only. + - The default is computed automatically based on the environment. + type: bool + database_path: + description: + - The path to a directory on a fixed disk of the Windows host where the + domain database will be created. + - If not set then the default path is C(%SYSTEMROOT%\NTDS). + type: path + dns_domain_name: + description: + - The DNS name of the domain which should exist and be reachable or reside on the target Windows host. + type: str + required: yes + domain_mode: + description: + - Specifies the domain functional level of the first domain in the creation of a new forest. + - The domain functional level cannot be lower than the forest functional level, but it can be higher. + - The default is automatically computed and set. + - Current known modes are C(Win2003), C(Win2008), C(Win2008R2), C(Win2012), C(Win2012R2), or C(WinThreshold). + type: str + domain_netbios_name: + description: + - The NetBIOS name for the root domain in the new forest. + - For NetBIOS names to be valid for use with this parameter they must be single label names of 15 characters or less, if not it will fail. + - If this parameter is not set, then the default is automatically computed from the value of the I(domain_name) parameter. + type: str + forest_mode: + description: + - Specifies the forest functional level for the new forest. + - The default forest functional level in Windows Server is typically the same as the version you are running. + - Current known modes are C(Win2003), C(Win2008), C(Win2008R2), C(Win2012), C(Win2012R2), or C(WinThreshold). + type: str + install_dns: + description: + - Whether to install the DNS service when creating the domain controller. + type: bool + default: true + log_path: + description: + - Specifies the fully qualified, non-UNC path to a directory on a fixed disk of the local computer where the log file for this operation is written. + - If not set then the default path is C(%SYSTEMROOT%\NTDS). + type: path + reboot: + description: + - If C(true), this will reboot the host if a reboot was required to configure the domain. + - If C(false), this will not reboot the host if a reboot was required and instead sets the I(reboot_required) return value to C(true). + - Multiple reboots may occur if the host required a reboot before the domain promotion. + - This cannot be used with async mode. + - To use this parameter, ensure the fully qualified module name is used in the task or the I(collections) keyword includes this collection. + type: bool + default: false + safe_mode_password: + description: + - Safe mode password for the domain controller. + type: str + required: true + sysvol_path: + description: + - The path to a directory on a fixed disk of the Windows host where the + Sysvol file will be created. + - If not set then the default path is C(%SYSTEMROOT%\SYSVOL). + type: path +extends_documentation_fragment: +- ansible.builtin.action_common_attributes +- ansible.builtin.action_common_attributes.flow +attributes: + check_mode: + support: full + diff_mode: + support: none + platform: + platforms: + - windows + action: + support: full + async: + support: partial + details: Supported for all scenarios except with I(reboot=True). + bypass_host_loop: + support: none +seealso: +- module: microsoft.ad.domain_controller +- module: microsoft.ad.group +- module: microsoft.ad.membership +- module: microsoft.ad.user +- module: microsoft.ad.computer +- ref: Migration guide <ansible_collections.microsoft.ad.docsite.guide_migration.migrated_modules.win_domain> + description: This module replaces C(ansible.windows.win_domain). See the migration guide for details. +- module: ansible.windows.win_domain +author: +- Matt Davis (@nitzmahone) +- Jordan Borean (@jborean93) +""" + +EXAMPLES = r""" +- name: Create new domain in a new forest on the target host and reboot + microsoft.ad.domain: + dns_domain_name: ansible.vagrant + safe_mode_password: password123! + reboot: true + +- name: Create new Windows domain in a new forest with specific parameters and reboot in post task + microsoft.ad.domain: + create_dns_delegation: false + database_path: C:\Windows\NTDS + dns_domain_name: ansible.vagrant + domain_mode: Win2012R2 + domain_netbios_name: ANSIBLE + forest_mode: Win2012R2 + safe_mode_password: password123! + sysvol_path: C:\Windows\SYSVOL + register: domain_install + +- name: Reboot host if install requires it + ansible.windows.win_reboot: + when: domain_install.reboot_required +""" + +RETURN = r""" +reboot_required: + description: True if changes were made that require a reboot. + returned: always + type: bool + sample: true +""" diff --git a/ansible_collections/microsoft/ad/plugins/modules/domain_controller.ps1 b/ansible_collections/microsoft/ad/plugins/modules/domain_controller.ps1 new file mode 100644 index 000000000..6d1de9513 --- /dev/null +++ b/ansible_collections/microsoft/ad/plugins/modules/domain_controller.ps1 @@ -0,0 +1,276 @@ +#!powershell + +# Copyright (c) 2022 Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic + +$spec = @{ + options = @{ + database_path = @{ + type = 'path' + } + dns_domain_name = @{ + type = 'str' + } + domain_admin_password = @{ + no_log = $true + required = $true + type = 'str' + } + domain_admin_user = @{ + required = $true + type = 'str' + } + domain_log_path = @{ + # FUTURE: Add alias for log_path once some time has passed + type = 'path' + } + install_dns = @{ + type = 'bool' + } + install_media_path = @{ + type = 'path' + } + local_admin_password = @{ + no_log = $true + type = 'str' + } + read_only = @{ + default = $false + type = 'bool' + } + reboot = @{ + default = $false + type = 'bool' + } + safe_mode_password = @{ + no_log = $true + type = 'str' + } + site_name = @{ + type = 'str' + } + state = @{ + choices = 'domain_controller', 'member_server' + required = $true + type = 'str' + } + sysvol_path = @{ + type = 'path' + } + } + required_if = @( + , @('state', 'domain_controller', @('dns_domain_name', 'safe_mode_password')) + , @('state', 'member_server', @(, 'local_admin_password')) + ) + supports_check_mode = $true +} +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +$module.Result.reboot_required = $false +$module.Result._do_action_reboot = $false # Used by action plugin + +$databasePath = $module.Params.database_path +$dnsDomainName = $module.Params.dns_domain_name +$installDns = $module.Params.install_dns +$installMediaPath = $module.Params.install_media_path +$logPath = $module.Params.domain_log_path +$readOnly = $module.Params.read_only +$siteName = $module.Params.site_name +$state = $module.Params.state +$sysvolPath = $module.Params.sysvol_path + +$domainCredential = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList @( + $module.Params.domain_admin_user, + (ConvertTo-SecureString -AsPlainText -Force -String $module.Params.domain_admin_password) +) + +if ([System.Environment]::OSVersion.Version -lt [Version]"6.2") { + $module.FailJson("microsoft.ad.domain_controller requires Windows Server 2012 or higher") +} + +# short-circuit "member server" check, since we don't need feature checks for this... +# role 4/5 - backup/primary DC +$win32CS = Get-CimInstance -ClassName Win32_ComputerSystem -Property Domain, DomainRole +$isKdc = $win32CS.DomainRole -in @(4, 5) +If ($state -eq "member_server" -and -not $isKdc) { + $module.ExitJson() +} + +# all other operations will require the AD-DS and RSAT-ADDS features... +$requiredFeatures = @("AD-Domain-Services", "RSAT-ADDS") +$features = Get-WindowsFeature -Name $requiredFeatures +$unavailableFeatures = Compare-Object -ReferenceObject $requiredFeatures -DifferenceObject $features.Name -PassThru + +if ($unavailableFeatures) { + $module.FailJson("The following features required for a domain controller are unavailable: $($unavailableFeatures -join ',')") +} + +$missingFeatures = $features | Where-Object InstallState -NE Installed +if ($missingFeatures) { + $res = Install-WindowsFeature -Name $missingFeatures -WhatIf:$module.CheckMode + $module.Result.changed = $true + $module.Result.reboot_required = [bool]$res.RestartNeeded + + # When in check mode and the prereq was "installed" we need to exit early as + # the AD cmdlets weren't really installed + if ($module.CheckMode) { + $module.ExitJson() + } +} + +$lastBootTime = (Get-CimInstance -ClassName Win32_OperatingSystem -Property LastBootUpTime).LastBootUpTime.ToFileTime() + +if ($state -eq 'domain_controller') { + # ensure that domain admin user is in UPN or down-level domain format (prevent hang from https://support.microsoft.com/en-us/kb/2737935) + If (-not $domainCredential.UserName.Contains("\") -and -not $domainCredential.UserName.Contains("@")) { + $module.FailJson("domain_admin_user must be in domain\user or user@domain.com format") + } + + If ($isKdc) { + # FUTURE: implement managed Remove/Add to change domains? + If ($dnsDomainName -ne $win32CS.Domain) { + $msg = -join @( + "The host $env:COMPUTERNAME is a domain controller for the domain $($win32CS.Domain); " + "changing DC domains is not implemented" + ) + $module.FailJson($msg) + } + } + else { + $safeModePassword = $module.Params.safe_mode_password | ConvertTo-SecureString -AsPlainText -Force + + $installParams = @{ + Confirm = $false + Credential = $domainCredential + DomainName = $dnsDomainName + Force = $true + NoRebootOnCompletion = $true + SafeModeAdministratorPassword = $safeModePassword + SkipPreChecks = $true + WhatIf = $module.CheckMode + } + if ($databasePath) { + $installParams.DatabasePath = $databasePath + } + if ($logPath) { + $installParams.LogPath = $logPath + } + if ($sysvolPath) { + $installParams.SysvolPath = $sysvolPath + } + if ($installMediaPath) { + $installParams.InstallationMediaPath = $installMediaPath + } + if ($readOnly) { + # while this is a switch value, if we set on $false site_name is required + # https://github.com/ansible/ansible/issues/35858 + $installParams.ReadOnlyReplica = $true + } + if ($siteName) { + $installParams.SiteName = $siteName + } + if ($null -ne $installDns) { + $installParams.InstallDns = $installDns + } + + try { + $null = Install-ADDSDomainController @installParams + } + catch [Microsoft.DirectoryServices.Deployment.DCPromoExecutionException] { + # ExitCode 15 == 'Role change is in progress or this computer needs to be restarted.' + # DCPromo exit codes details can be found at + # https://docs.microsoft.com/en-us/windows-server/identity/ad-ds/deploy/troubleshooting-domain-controller-deployment + if ($_.Exception.ExitCode -in @(15, 19)) { + $module.Result.reboot_required = $true + $module.Result._do_action_reboot = $true + } + + $module.FailJson("Failed to install ADDSDomainController, DCPromo exited with $($_.Exception.ExitCode): $($_.Exception.Message)", $_) + } + finally { + # The Netlogon service is set to auto start but is not started. This is + # required for Ansible to connect back to the host and reboot in a + # later task. Even if this fails Ansible can still connect but only + # with ansible_winrm_transport=basic so we just display a warning if + # this fails. + if (-not $module.CheckMode) { + try { + Start-Service -Name Netlogon + } + catch { + $msg = -join @( + "Failed to start the Netlogon service after promoting the host, " + "Ansible may be unable to connect until the host is manually rebooting: $($_.Exception.Message)" + ) + $module.Warn($msg) + } + } + } + + $module.Result.changed = $true + $module.Result.reboot_required = $true + } +} +else { + # at this point we already know we're a DC and shouldn't be (due to short circuit check)... + $assignedRoles = @((Get-ADDomainController -Server localhost).OperationMasterRoles) + $localAdminPassword = $module.Params.local_admin_password | ConvertTo-SecureString -AsPlainText -Force + + # FUTURE: figure out a sane way to hand off roles automatically (designated recipient server, randomly look one up?) + If ($assignedRoles.Count -gt 0) { + $msg = -join @( + "This domain controller has operation master role(s) ({0}) assigned; they must be moved to other " + "DCs before demotion (see Move-ADDirectoryServerOperationMasterRole)" -f ($assignedRoles -join ", ") + ) + $module.FailJson($msg) + } + + # While the cmdlet has -WhatIf, it doesn't seem to work properly. Only run + # when not in check mode. + if (-not $module.CheckMode) { + $uninstallParams = @{ + Confirm = $false + Credential = $domainCredential + Force = $true + LocalAdministratorPassword = $localAdminPassword + NoRebootOnCompletion = $true + } + $null = Uninstall-ADDSDomainController @uninstallParams + } + + $module.Result.changed = $true + $module.Result.reboot_required = $true +} + +if ($module.Result.reboot_required -and $module.Params.reboot -and -not $module.CheckMode) { + # Promoting or depromoting puts the server in a very funky state and it may + # not be possible for Ansible to connect back without a reboot is done. If + # the user requested the action plugin to perform the reboot then start it + # here and get the action plugin to continue where this left off. + + $module.Result._previous_boot_time = $lastBootTime + + $shutdownRegPath = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon\AutoLogonChecked' + Remove-Item -LiteralPath $shutdownRegPath -Force -ErrorAction SilentlyContinue + + $comment = 'Reboot initiated by Ansible' + $stdout = $null + $stderr = . { shutdown.exe /r /t 10 /c $comment | Set-Variable stdout } 2>&1 | ForEach-Object ToString + if ($LASTEXITCODE -eq 1190) { + # A reboot was already scheduled, abort it and try again + shutdown.exe /a + $stdout = $null + $stderr = . { shutdown.exe /r /t 10 /c $comment | Set-Variable stdout } 2>&1 | ForEach-Object ToString + } + + if ($LASTEXITCODE) { + $module.Result.rc = $LASTEXITCODE + $module.Result.stdout = $stdout + $module.Result.stderr = $stderr + $module.FailJson("Failed to initiate reboot, see rc, stdout, stderr for more information") + } +} + +$module.ExitJson() diff --git a/ansible_collections/microsoft/ad/plugins/modules/domain_controller.py b/ansible_collections/microsoft/ad/plugins/modules/domain_controller.py new file mode 100644 index 000000000..3ef2488bb --- /dev/null +++ b/ansible_collections/microsoft/ad/plugins/modules/domain_controller.py @@ -0,0 +1,180 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2022 Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r""" +module: domain_controller +short_description: Manage domain controller/member server state for a Windows host +description: +- Ensure that a Windows Server 2012+ host is configured as a domain controller or demoted to member server. +- This module may require subsequent use of the M(ansible.windows.win_reboot) action if changes are made. +options: + database_path: + description: + - The path to a directory on a fixed disk of the Windows host where the + domain database will be created.. + - If not set then the default path is C(%SYSTEMROOT%\NTDS). + type: path + dns_domain_name: + description: + - When I(state=domain_controller), the DNS name of the domain for which the targeted Windows host should be a DC. + type: str + domain_admin_user: + description: + - Username of a domain admin for the target domain (necessary to promote or demote a domain controller). + type: str + required: true + domain_admin_password: + description: + - Password for the specified I(domain_admin_user). + type: str + required: true + domain_log_path: + description: + - Specified the fully qualified, non-UNC path to a directory on a fixed disk of the local computer that will + contain the domain log files. + type: path + install_dns: + description: + - Whether to install the DNS service when creating the domain controller. + - If not specified then the C(-InstallDns) option is not supplied to C(Install-ADDSDomainController) command, + see L(Install-ADDSDomainController,https://learn.microsoft.com/en-us/powershell/module/addsdeployment/install-addsdomaincontroller). + type: bool + install_media_path: + description: + - The path to a directory on a fixed disk of the Windows host where the Install From Media C(IFC) data will be used. + - See the L(Install using IFM guide,https://social.technet.microsoft.com/wiki/contents/articles/8630.active-directory-step-by-step-guide-to-install-an-additional-domain-controller-using-ifm.aspx) for more information. # noqa + type: path + local_admin_password: + description: + - Password to be assigned to the local C(Administrator) user (required when I(state=member_server)). + type: str + read_only: + description: + - Whether to install the domain controller as a read only replica for an existing domain. + type: bool + default: no + reboot: + description: + - If C(true), this will reboot the host if a reboot was required to configure the server. + - If C(false), this will not reboot the host if a reboot was required and instead sets the I(reboot_required) return value to C(true). + - Multiple reboots may occur if the host required a reboot before the domain promotion. + - This cannot be used with async mode. + - To use this parameter, ensure the fully qualified module name is used in the task or the I(collections) keyword includes this collection. + type: bool + default: false + safe_mode_password: + description: + - Safe mode password for the domain controller (required when I(state=domain_controller)). + type: str + site_name: + description: + - Specifies the name of an existing site where you can place the new domain controller. + - This option is required when I(read_only=true). + type: str + state: + description: + - Whether the target host should be a domain controller or a member server. + type: str + choices: + - domain_controller + - member_server + required: yes + sysvol_path: + description: + - The path to a directory on a fixed disk of the Windows host where the + Sysvol folder will be created. + - If not set then the default path is C(%SYSTEMROOT%\SYSVOL). + type: path +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. +extends_documentation_fragment: +- ansible.builtin.action_common_attributes +- ansible.builtin.action_common_attributes.flow +attributes: + check_mode: + support: full + diff_mode: + support: none + platform: + platforms: + - windows + action: + support: full + async: + support: partial + details: Supported for all scenarios except with I(reboot=True). + bypass_host_loop: + support: none +seealso: +- module: microsoft.ad.computer +- module: microsoft.ad.domain +- module: microsoft.ad.group +- module: microsoft.ad.membership +- module: microsoft.ad.user +- ref: Migration guide <ansible_collections.microsoft.ad.docsite.guide_migration.migrated_modules.win_domain_controller> + description: This module replaces C(ansible.windows.win_domain_controller). See the migration guide for details. +- module: ansible.windows.win_domain_controller +author: +- Matt Davis (@nitzmahone) +- Jordan Borean (@jborean93) +""" + +EXAMPLES = r""" +- name: Ensure a server is a domain controller + microsoft.ad.domain_controller: + dns_domain_name: ansible.vagrant + domain_admin_user: testguy@ansible.vagrant + domain_admin_password: password123! + safe_mode_password: password123! + state: domain_controller + reboot: true + +- name: Ensure a server is not a domain controller + microsoft.ad.domain_controller: + domain_admin_user: testguy@ansible.vagrant + domain_admin_password: password123! + local_admin_password: password123! + state: member_server + reboot: true + +- name: Promote server as a read only domain controller + microsoft.ad.domain_controller: + dns_domain_name: ansible.vagrant + domain_admin_user: testguy@ansible.vagrant + domain_admin_password: password123! + safe_mode_password: password123! + state: domain_controller + read_only: yes + site_name: London + reboot: true + +# This scenario is not recommended, use reboot: true when possible +- name: Promote server with custom paths with manual reboot task + microsoft.ad.domain_controller: + dns_domain_name: ansible.vagrant + domain_admin_user: testguy@ansible.vagrant + domain_admin_password: password123! + safe_mode_password: password123! + state: domain_controller + sysvol_path: D:\SYSVOL + database_path: D:\NTDS + domain_log_path: D:\NTDS + register: dc_promotion + +- name: Reboot after promotion + microsoft.ad.win_reboot: + when: dc_promotion.reboot_required +""" + +RETURN = r""" +reboot_required: + description: True if changes were made that require a reboot. + returned: always + type: bool + sample: true +""" diff --git a/ansible_collections/microsoft/ad/plugins/modules/group.ps1 b/ansible_collections/microsoft/ad/plugins/modules/group.ps1 new file mode 100644 index 000000000..bbb3aa8d7 --- /dev/null +++ b/ansible_collections/microsoft/ad/plugins/modules/group.ps1 @@ -0,0 +1,211 @@ +#!powershell + +# Copyright: (c) 2023, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic +#AnsibleRequires -PowerShell ..module_utils._ADObject + +$setParams = @{ + PropertyInfo = @( + [PSCustomObject]@{ + Name = 'category' + Option = @{ + choices = 'distribution', 'security' + type = 'str' + } + Attribute = 'GroupCategory' + CaseInsensitive = $true + } + [PSCustomObject]@{ + Name = 'homepage' + Option = @{ type = 'str' } + Attribute = 'Homepage' + } + [PSCustomObject]@{ + Name = 'managed_by' + Option = @{ type = 'str' } + Attribute = 'ManagedBy' + } + [PSCustomObject]@{ + Name = 'members' + Option = @{ + type = 'dict' + options = @{ + add = @{ + type = 'list' + elements = 'str' + } + remove = @{ + type = 'list' + elements = 'str' + } + set = @{ + type = 'list' + elements = 'str' + } + } + } + Attribute = 'member' + New = { + param($Module, $ADParams, $NewParams) + + $newMembers = @( + foreach ($actionKvp in $Module.Params.members.GetEnumerator()) { + if ($null -eq $actionKvp.Value -or $actionKvp.Key -eq 'remove') { continue } + + $invalidMembers = [System.Collections.Generic.List[string]]@() + + foreach ($m in $actionKvp.Value) { + $obj = Get-AnsibleADObject -Identity $m @ADParams | + Select-Object -ExpandProperty DistinguishedName + if ($obj) { + $obj + } + else { + $invalidMembers.Add($m) + } + } + + if ($invalidMembers) { + $module.FailJson("Failed to find the following ad objects for group members: '$($invalidMembers -join "', '")'") + } + } + ) + + if ($newMembers) { + if (-not $NewParams.ContainsKey('OtherAttributes')) { + $NewParams.OtherAttributes = @{} + } + # The AD cmdlets don't like explicitly casted arrays, use + # ForEach-Object to get back a vanilla object[] to set. + $NewParams.OtherAttributes.member = $newMembers | ForEach-Object { "$_" } + } + $Module.Diff.after.members = @($newMembers | Sort-Object) + } + Set = { + param($Module, $ADParams, $SetParams, $ADObject) + + [string[]]$existingMembers = $ADObject.member + + $desiredState = @{} + foreach ($actionKvp in $Module.Params.members.GetEnumerator()) { + if ($null -eq $actionKvp.Value) { continue } + + $invalidMembers = [System.Collections.Generic.List[string]]@() + + $dns = foreach ($m in $actionKvp.Value) { + $obj = Get-AnsibleADObject -Identity $m @ADParams | + Select-Object -ExpandProperty DistinguishedName + if ($obj) { + $obj + } + else { + $invalidMembers.Add($m) + } + } + + if ($invalidMembers) { + $module.FailJson("Failed to find the following ad objects for group members: '$($invalidMembers -join "', '")'") + } + + $desiredState[$actionKvp.Key] = @($dns) + } + + $ignoreCase = [System.StringComparer]::OrdinalIgnoreCase + [string[]]$diffAfter = @() + if ($desiredState.ContainsKey('set')) { + [string[]]$desiredMembers = $desiredState.set + $diffAfter = $desiredMembers + + $toAdd = [string[]][System.Linq.Enumerable]::Except($desiredMembers, $existingMembers, $ignoreCase) + $toRemove = [string[]][System.Linq.Enumerable]::Except($existingMembers, $desiredMembers, $ignoreCase) + + if ($toAdd -or $toRemove) { + if (-not $SetParams.ContainsKey('Replace')) { + $SetParams.Replace = @{} + } + $SetParams.Replace.member = $desiredMembers + } + } + else { + [string[]]$toAdd = @() + [string[]]$toRemove = @() + $diffAfter = $existingMembers + + if ($desiredState.ContainsKey('add') -and $desiredState.add) { + [string[]]$desiredMembers = $desiredState.add + $toAdd = [string[]][System.Linq.Enumerable]::Except($desiredMembers, $existingMembers, $ignoreCase) + $diffAfter = [System.Linq.Enumerable]::Union($desiredMembers, $diffAfter, $ignoreCase) + } + if ($desiredState.ContainsKey('remove') -and $desiredState.remove) { + + [string[]]$desiredMembers = $desiredState.remove + $toRemove = [string[]][System.Linq.Enumerable]::Intersect($desiredMembers, $existingMembers, $ignoreCase) + $diffAfter = [System.Linq.Enumerable]::Except($diffAfter, $desiredMembers, $ignoreCase) + } + + if ($toAdd) { + if (-not $SetParams.ContainsKey('Add')) { + $SetParams.Add = @{} + } + $SetParams.Add.member = $toAdd + } + if ($toRemove) { + if (-not $SetParams.ContainsKey('Remove')) { + $SetParams.Remove = @{} + } + $SetParams.Remove.member = $toRemove + } + } + + $Module.Diff.after.members = ($diffAfter | Sort-Object) + } + } + [PSCustomObject]@{ + Name = 'sam_account_name' + Option = @{ type = 'str' } + Attribute = 'sAMAccountName' + } + [PSCustomObject]@{ + Name = 'scope' + Option = @{ + choices = 'domainlocal', 'global', 'universal' + type = 'str' + } + Attribute = 'GroupScope' + CaseInsensitive = $true + } + ) + ModuleNoun = 'ADGroup' + DefaultPath = { + param($Module, $ADParams) + + $GUID_USERS_CONTAINER_W = 'A9D1CA15768811D1ADED00C04FD8D5CD' + $defaultNamingContext = (Get-ADRootDSE @ADParams -Properties defaultNamingContext).defaultNamingContext + + Get-ADObject @ADParams -Identity $defaultNamingContext -Properties wellKnownObjects | + Select-Object -ExpandProperty wellKnownObjects | + Where-Object { $_.StartsWith("B:32:$($GUID_USERS_CONTAINER_W):") } | + ForEach-Object Substring 38 + } + PreAction = { + param ($Module, $ADParams, $ADObject) + + if ($Module.Params.state -eq 'present' -and (-not $Module.Params.scope) -and (-not $ADObject)) { + $Module.FailJson("scope must be set when state=present and the group does not exist") + } + } + PostAction = { + param($Module, $ADParams, $ADObject) + + if ($ADObject) { + $Module.Result.sid = $ADObject.SID.Value + } + elseif ($Module.Params.state -eq 'present') { + # Use dummy value for check mode when creating a new user + $Module.Result.sid = 'S-1-5-0000' + } + } +} +Invoke-AnsibleADObject @setParams diff --git a/ansible_collections/microsoft/ad/plugins/modules/group.py b/ansible_collections/microsoft/ad/plugins/modules/group.py new file mode 100644 index 000000000..d34e4584b --- /dev/null +++ b/ansible_collections/microsoft/ad/plugins/modules/group.py @@ -0,0 +1,223 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2023, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r""" +--- +module: group +short_description: Manage Active Directory group objects +description: +- Manages Active Directory group objects and their attributes. +options: + category: + description: + - The category of the group. + - If a new group is created then C(security) will be used by default. + - A C(security) group can be associated with access control lists whereas + C(distribution) groups are typically associated with mailing distribution + lists. + - This is the value set on the C(groupType) LDAP attributes. + choices: + - distribution + - security + type: str + homepage: + description: + - The homepage of the group. + - This is the value set on the C(wWWHomePage) LDAP attribute. + type: str + managed_by: + description: + - The user or group that manages the group. + - The value can be in the form of a C(distinguishedName), C(objectGUID), + C(objectSid), or C(sAMAccountName). + - This is the value set on the C(managedBy) LDAP attribute. + type: str + members: + description: + - The members of the group to set. + - The value is a dictionary that contains 3 keys, I(add), I(remove), and + I(set). + - Each subkey is set to a list of AD principal objects to add, remove or + set as the members of this AD group respectively. A principal can be in + the form of a C(distinguishedName), C(objectGUID), C(objectSid), or + C(sAMAccountName). + - The module will fail if it cannot find any of the members referenced. + type: dict + suboptions: + add: + description: + - Adds the principals specified as members of the group, keeping the + existing membership if they are not specified. + type: list + elements: str + remove: + description: + - Removes the principals specified as members of the group, keeping the + existing membership if they are not specified. + type: list + elements: str + set: + description: + - Sets only the principals specified as members of the group. + - Any other existing member will be removed from the group membership + if not specified in this list. + - Set this to an empty list to remove all members from a group. + type: list + elements: str + sam_account_name: + description: + - The C(sAMAccountName) value to set for the group. + - If omitted, the I(name) value is used when creating a new group. + type: str + scope: + description: + - The scope of the group. + - This is required when I(state=present) and the group does not already + exist. + - See + L(Group scope,https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-server-2003/cc755692%28v=ws.10%29) + for more information on the various domain group scopes. + - This is the value set on the C(groupType) LDAP attributes. + choices: + - domainlocal + - global + - universal + type: str +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. +extends_documentation_fragment: +- microsoft.ad.ad_object +- ansible.builtin.action_common_attributes +attributes: + check_mode: + support: full + diff_mode: + support: full + platform: + platforms: + - windows +seealso: +- module: microsoft.ad.domain +- module: microsoft.ad.domain_controller +- module: microsoft.ad.membership +- module: microsoft.ad.object_info +- module: microsoft.ad.object +- module: microsoft.ad.user +- ref: Migration guide <ansible_collections.microsoft.ad.docsite.guide_migration.migrated_modules.win_domain_group> + description: This module replaces C(community.windows.win_domain_group). See the migration guide for details. +- module: community.windows.win_domain_group +author: +- Jordan Borean (@jborean93) +""" + +EXAMPLES = r""" +- name: Ensure a group exists + microsoft.ad.group: + name: Cow + scope: global + +- name: Remove a group + microsoft.ad.group: + name: Cow + state: absent + +- name: Create a group in a custom path + microsoft.ad.group: + name: Cow + scope: global + path: OU=groups,DC=ansible,DC=local + state: present + +- name: Remove a group in a custom path + microsoft.ad.group: + name: Cow + path: OU=groups,DC=ansible,DC=local + state: absent + +- name: Create group with delete protection enabled and custom attributes + microsoft.ad.group: + name: Ansible Users + scope: domainlocal + category: security + homepage: www.ansible.com + attributes: + set: + mail: helpdesk@ansible.com + protect_from_deletion: true + +- name: Change the path of a group + microsoft.ad.group: + name: MyGroup + scope: global + identity: S-1-5-21-2171456218-3732823212-122182344-1189 + path: OU=groups,DC=ansible,DC=local + +- name: Add managed_by user + microsoft.ad.group: + name: Group Name Here + scope: global + managed_by: Domain Admins + +- name: Add group and specify the AD domain services to use for the create + microsoft.ad.group: + name: Test Group + domain_username: user@CORP.ANSIBLE.COM + domain_password: Password01! + domain_server: corp-DC12.corp.ansible.com + scope: domainlocal + +- name: Add members to the group, preserving existing membership + microsoft.ad.group: + name: Test Group + scope: domainlocal + members: + add: + - Domain Admins + - Domain Users + +- name: Remove members from the group, preserving existing membership + microsoft.ad.group: + name: Test Group + scope: domainlocal + members: + remove: + - Domain Admins + - Domain Users + +- name: Replace entire membership of group + microsoft.ad.group: + name: Test Group + scope: domainlocal + members: + set: + - Domain Admins + - Domain Users +""" + +RETURN = r""" +object_guid: + description: + - The C(objectGUID) of the AD object that was created, removed, or edited. + - If a new object was created in check mode, a GUID of 0s will be returned. + returned: always + type: str + sample: d84a141f-2b99-4f08-9da0-ed2d26864ba1 +distinguished_name: + description: + - The C(distinguishedName) of the AD object that was created, removed, or edited. + returned: always + type: str + sample: CN=MyGroup,CN=Users,,DC=domain,DC=test +sid: + description: + - The Security Identifier (SID) of the group managed. + - If a new group was created in check mode, the SID will be C(S-1-5-0000). + returned: always + type: str + sample: S-1-5-21-4151808797-3430561092-2843464588-1104 +""" diff --git a/ansible_collections/microsoft/ad/plugins/modules/membership.ps1 b/ansible_collections/microsoft/ad/plugins/modules/membership.ps1 new file mode 100644 index 000000000..2b37bcdfd --- /dev/null +++ b/ansible_collections/microsoft/ad/plugins/modules/membership.ps1 @@ -0,0 +1,270 @@ +#!powershell + +# Copyright (c) 2022 Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic + +$spec = @{ + options = @{ + dns_domain_name = @{ + type = 'str' + } + domain_admin_user = @{ + type = 'str' + } + domain_admin_password = @{ + no_log = $true + type = 'str' + } + domain_ou_path = @{ + type = 'str' + } + hostname = @{ + type = 'str' + } + offline_join_blob = @{ + type = "str" + no_log = $true + } + reboot = @{ + default = $false + type = 'bool' + } + state = @{ + choices = 'domain', 'workgroup' + required = $true + type = 'str' + } + workgroup_name = @{ + type = 'str' + } + } + mutually_exclusive = @( + @('offline_join_blob', 'domain_admin_user'), + @('offline_join_blob', 'dns_domain_name'), + @('offline_join_blob', 'domain_ou_path'), + @('offline_join_blob', 'hostname') + ) + required_if = @( + @('state', 'domain', @('domain_admin_user', 'offline_join_blob'), $true), + @('state', 'workgroup', @('workgroup_name', 'domain_admin_user', 'domain_admin_password')) + ) + required_together = @( + , @('domain_admin_user', 'domain_admin_password') + ) + supports_check_mode = $true +} +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +$module.Result.reboot_required = $false +$module.Diff.before = @{} +$module.Diff.after = @{} + +$dnsDomainName = $module.Params.dns_domain_name +$domainCredential = if ($module.Params.domain_admin_user) { + New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList @( + $module.Params.domain_admin_user, + (ConvertTo-SecureString -AsPlainText -Force -String $module.Params.domain_admin_password) + ) +} +$domainOUPath = $module.Params.domain_ou_path +$hostname = $module.Params.hostname +$state = $module.Params.state +$workgroupName = $module.Params.workgroup_name + +Add-CSharpType -AnsibleModule $module -References @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; + +namespace microsoft.ad.membership +{ + [Flags] + public enum ProvisionOptions + { + None = 0, + NETSETUP_PROVISION_ONLINE_CALLER = 0x40000000, + } + + public static class Native + { + [DllImport("Netapi32.dll", EntryPoint = "NetRequestOfflineDomainJoin")] + private static extern int NativeNetRequestOfflineDomainJoin( + IntPtr pProvisionBinData, + int cbProvisionBinDataSize, + int dwOptions, + [MarshalAs(UnmanagedType.LPWStr)] string lpWindowsPath); + + public static void NetRequestOfflineDomainJoin(byte[] data, ProvisionOptions options, + string windowsPath) + { + IntPtr buffer = Marshal.AllocHGlobal(data.Length); + try + { + Marshal.Copy(data, 0, buffer, data.Length); + + int res = NativeNetRequestOfflineDomainJoin(buffer, data.Length, (int)options, windowsPath); + if (res != 0) + { + throw new Win32Exception(res); + } + } + finally { + Marshal.FreeHGlobal(buffer); + } + } + } +} +'@ + +Function Get-CurrentState { + <# + .SYNOPSIS + Gets the current state of the host. + #> + [CmdletBinding()] + param () + + $cs = Get-CimInstance -ClassName Win32_ComputerSystem -Property Domain, PartOfDomain, Workgroup + $domainName = if ($cs.PartOfDomain) { + try { + [System.DirectoryServices.ActiveDirectory.Domain]::GetComputerDomain().Name + } + catch [System.Security.Authentication.AuthenticationException] { + # This might happen if running as a local user on a host already + # joined to the domain. Just try the Win32_ComputerSystem fallback + # value. + $cs.Domain + } + } + else { + $null + } + + [PSCustomObject]@{ + HostName = $env:COMPUTERNAME + PartOfDomain = $cs.PartOfDomain + DnsDomainName = $domainName + WorkgroupName = $cs.Workgroup + } +} + +$currentState = Get-CurrentState + +$module.Diff.before = @{ + dns_domain_name = $currentState.DnsDomainName + hostname = $currentState.HostName + state = if ($currentState.PartOfDomain) { 'domain' } else { 'workgroup' } + workgroup_name = $currentState.WorkgroupName +} +if (-not $hostname) { + $hostname = $currentState.HostName +} + +if ($state -eq 'domain') { + if ($module.Params.offline_join_blob) { + # FUTURE: Read blob to see what domain it is for. + if (-not $currentState.PartOfDomain) { + try { + if (-not $module.CheckMode) { + [microsoft.ad.membership.Native]::NetRequestOfflineDomainJoin( + [System.Convert]::FromBase64String($module.Params.offline_join_blob), + "NETSETUP_PROVISION_ONLINE_CALLER", + $env:SystemRoot) + } + } + catch [System.ComponentModel.Win32Exception] { + $msg = "Failed to perform offline domain join (0x{0:X8}): {1}" -f $_.Exception.NativeErrorCode, $_.Exception.Message + $module.FailJson($msg, $_) + } + + $module.Result.changed = $true + $module.Result.reboot_required = $true + } + } + else { + if ($dnsDomainName -ne $currentState.DnsDomainName) { + if ($currentState.PartOfDomain) { + $module.FailJson("Host is already joined to '$($currentState.DnsDomainName)', switching domains is not implemented") + } + + $joinParams = @{ + ComputerName = '.' + Credential = $domainCredential + DomainName = $dnsDomainName + Force = $true + WhatIf = $module.CheckMode + } + if ($hostname -ne $currentState.HostName) { + $joinParams.NewName = $hostname + + # By setting this here, the Rename-Computer call is skipped as + # joining the domain will rename the host for us. + $hostname = $currentState.HostName + } + if ($domainOUPath) { + $joinParams.OUPath = $domainOUPath + } + + Add-Computer @joinParams + + $module.Result.changed = $true + $module.Result.reboot_required = $true + } + } +} +else { + if ($workgroupName -ne $currentState.WorkgroupName) { + if ($currentState.PartOfDomain) { + $removeParams = @{ + UnjoinDomainCredential = $domainCredential + Workgroup = $workgroupName + Force = $true + WhatIf = $module.CheckMode + } + + Remove-Computer @removeParams + } + elseif (-not $module.CheckMode) { + try { + $res = Get-CimInstance Win32_ComputerSystem | Invoke-CimMethod -MethodName JoinDomainOrWorkgroup -Arguments @{ + Name = $workgroupName + } + } + catch { + $module.FailJson("Failed to set workgroup as '$workgroupName': $($_.Exception.Message)", $_) + } + + if ($res.ReturnValue -ne 0) { + $msg = [System.ComponentModel.Win32Exception]$res.ReturnValue + $module.FailJson("Failed to set workgroup as '$workgroupName', return value: $($res.ReturnValue): $msg") + } + } + + $module.Result.changed = $true + $module.Result.reboot_required = $true + } +} + +if ($hostname -ne $currentState.Hostname) { + $renameParams = @{ + DomainCredential = $domainCredential + NewName = $hostname + WhatIf = $module.CheckMode + Force = $true + } + Rename-Computer @renameParams + + $module.Result.changed = $true + $module.Result.reboot_required = $true +} + +$module.Diff.after = @{ + dns_domain_name = $dnsDomainName + hostname = $hostname + state = $state + workgroup_name = $workgroupName +} + +$module.ExitJson() diff --git a/ansible_collections/microsoft/ad/plugins/modules/membership.py b/ansible_collections/microsoft/ad/plugins/modules/membership.py new file mode 100644 index 000000000..f4d8521cf --- /dev/null +++ b/ansible_collections/microsoft/ad/plugins/modules/membership.py @@ -0,0 +1,141 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2022 Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r""" +module: membership +short_description: Manage domain/workgroup membership for a Windows host +description: +- Manages domain membership or workgroup membership for a Windows host. Also supports hostname changes. +- This module may require subsequent use of the M(ansible.windows.win_reboot) action if changes are made. +options: + dns_domain_name: + description: + - When I(state=domain), this is the DNS name of the domain to which the targeted Windows host should be joined. + - This cannot be set when I(offline_join_blob) is specified. + type: str + domain_admin_user: + description: + - Username of a domain admin for the target domain (required to join or leave the domain). + - This must be set unless I(offline_join_blob) is specified. + type: str + domain_admin_password: + description: + - Password for the specified C(domain_admin_user). + - This must be set unless I(offline_join_blob) is specified. + type: str + domain_ou_path: + description: + - The desired OU path for adding the computer object. + - This is only used when adding the target host to a domain, if it is already a member then it is ignored. + - This cannot be set when I(offline_join_blob) is specified. + type: str + hostname: + description: + - The desired hostname for the Windows host. + - This cannot be set when I(offline_join_blob) is specified. + type: str + offline_join_blob: + description: + - The base64 string of the domain offline join blob to use when joining + the host to a domain. + - This blob can been generated by the + M(microsoft.ad.offline_join) module. + - This is mutually exclusive with I(domain_admin_user), I(dns_domain_name), + and I(domain_ou_path). + - It also cannot be used with I(hostname). + type: str + reboot: + description: + - If C(true), this will reboot the host if a reboot was required to configure the domain. + - If C(false), this will not reboot the host if a reboot was required and instead sets the I(reboot_required) return value to C(true). + - If changing from a domain to workgroup, the connection account must be a local user that can connect to the host + after it has unjoined the domain. + - This cannot be used with async mode. + - To use this parameter, ensure the fully qualified module name is used in the task or the I(collections) keyword includes this collection. + type: bool + default: false + state: + description: + - Whether the target host should be a member of a domain or workgroup. + - When I(state=domain), I(dns_domain_name), I(domain_admin_user), and I(domain_admin_password) or + I(offline_join_blob) must be set. + - When I(state=workgroup), I(workgroup_name) must be set. + choices: + - domain + - workgroup + required: true + type: str + workgroup_name: + description: + - When I(state=workgroup), this is the name of the workgroup that the Windows host should be in. + type: str +extends_documentation_fragment: +- ansible.builtin.action_common_attributes +- ansible.builtin.action_common_attributes.flow +attributes: + check_mode: + support: full + diff_mode: + support: full + platform: + platforms: + - windows + action: + support: full + async: + support: partial + details: Supported for all scenarios except with I(reboot=True). + bypass_host_loop: + support: none +seealso: +- module: microsoft.ad.domain +- module: microsoft.ad.domain_controller +- module: microsoft.ad.group +- module: microsoft.ad.offline_join +- module: microsoft.ad.user +- module: microsoft.ad.computer +- module: ansible.windows.win_group +- module: ansible.windows.win_group_membership +- module: ansible.windows.win_user +- ref: Migration guide <ansible_collections.microsoft.ad.docsite.guide_migration.migrated_modules.win_domain_membership> + description: This module replaces C(ansible.windows.win_domain_membership). See the migration guide for details. +- module: ansible.windows.win_domain_membership +author: +- Matt Davis (@nitzmahone) +- Jordan Borean (@jborean93) +""" + +EXAMPLES = r""" +- name: join host to ansible.vagrant with automatic reboot + microsoft.ad.membership: + dns_domain_name: ansible.vagrant + hostname: mydomainclient + domain_admin_user: testguy@ansible.vagrant + domain_admin_password: password123! + domain_ou_path: "OU=Windows,OU=Servers,DC=ansible,DC=vagrant" + state: domain + reboot: true + +- name: join host to workgroup with manual reboot in later task + microsoft.ad.membership: + workgroup_name: mywg + domain_admin_user: '{{ win_domain_admin_user }}' + domain_admin_password: '{{ win_domain_admin_password }}' + state: workgroup + register: workgroup_res + +- name: reboot host after joining workgroup + ansible.windows.win_reboot: + when: workgroup_res.reboot_required +""" + +RETURN = r""" +reboot_required: + description: True if changes were made that require a reboot. + returned: always + type: bool + sample: true +""" diff --git a/ansible_collections/microsoft/ad/plugins/modules/object.ps1 b/ansible_collections/microsoft/ad/plugins/modules/object.ps1 new file mode 100644 index 000000000..7d9167e6f --- /dev/null +++ b/ansible_collections/microsoft/ad/plugins/modules/object.ps1 @@ -0,0 +1,39 @@ +#!powershell + +# Copyright: (c) 2023, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic +#AnsibleRequires -PowerShell ..module_utils._ADObject + +$setParams = @{ + PropertyInfo = @( + [PSCustomObject]@{ + Name = 'type' + Option = @{ type = 'str' } + + Attribute = 'objectClass' + StateRequired = 'present' + New = { + param ($Module, $ADParams, $NewParams) + + $NewParams.Type = $Module.Params.type + $Module.Diff.after.type = $Module.Params.type + } + Set = { + param ($Module, $ADParams, $SetParams, $ADObject) + + $Module.Diff.after.type = $ADObject.ObjectClass + + if ($ADObject.ObjectClass -ne $Module.Params.type) { + $msg = -join @( + "Cannot change object type $($ADObject.ObjectClass) of existing object " + "$($ADObject.DistinguishedName) to $($Module.Params.type)" + ) + $Module.FailJson($msg) + } + } + } + ) +} +Invoke-AnsibleADObject @setParams diff --git a/ansible_collections/microsoft/ad/plugins/modules/object.py b/ansible_collections/microsoft/ad/plugins/modules/object.py new file mode 100644 index 000000000..db7c7e5f6 --- /dev/null +++ b/ansible_collections/microsoft/ad/plugins/modules/object.py @@ -0,0 +1,160 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2023, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r""" +--- +module: object +short_description: Manage Active Directory objects +description: +- Manages Active Directory objects and their attributes. +options: + type: + description: + - The object type of the AD object. + - This corresponds to the C(objectClass) of the AD object. + - Some examples of a type are C(user), C(computer), C(group), C(subnet), + C(contact), C(container). + - This is required when I(state=present). + type: str +notes: +- This is a generic module used to create and manage any object type in Active + Directory. It will not validate all the correct defaults are set for each + type when it is created. If a type specific module is available to manage + that AD object type it is recommend to use that. +extends_documentation_fragment: +- microsoft.ad.ad_object +- ansible.builtin.action_common_attributes +attributes: + check_mode: + support: full + diff_mode: + support: full + platform: + platforms: + - windows +seealso: +- module: microsoft.ad.domain +- module: microsoft.ad.domain_controller +- module: microsoft.ad.object_info +- module: microsoft.ad.user +- module: microsoft.ad.computer +- module: microsoft.ad.group +author: +- Jordan Borean (@jborean93) +""" + +EXAMPLES = r""" +# Use this to get all valid types in a domain environment +# (Get-ADObject -SearchBase (Get-ADRootDSE).subschemaSubentry -Filter * -Properties objectClasses).objectClasses | +# Select-String -Pattern "Name\s+'(\w+)'" | +# ForEach-Object { $_.Matches.Groups[1].Value } | +# Sort-Object + +- name: Create a contact object + microsoft.ad.object: + name: MyContact + description: My Contact Description + type: contact + state: present + +- name: Rename a contact object + microsoft.ad.object: + identity: '{{ contact_obj.object_guid }}' + name: RenamedContact + type: contact + state: present + +- name: Move a contact object + microsoft.ad.object: + identity: '{{ contact_object.object_guid }}' + name: MyContact + path: OU=Contacts,DC=domain,DC=test + type: contact + state: present + +- name: Remove a contact object in default path + microsoft.ad.object: + name: MyContact + state: absent + +- name: Remove a contact object in custom path + microsoft.ad.object: + name: MyContact + path: OU=Contacts,DC=domain,DC=test + state: absent + +- name: Remove a contact by identity + microsoft.ad.object: + identity: '{{ contact_obj.object_guid }}' + state: absent + +- name: Create container object with custom attributes + microsoft.ad.object: + name: App + attributes: + set: + wWWHomePage: https://ansible.com + type: container + state: present + +- name: Clear attribute of any value + microsoft.ad.object: + name: App + attributes: + set: + wWWHomePage: ~ + type: container + state: present + +- name: Edit object security with Everyone Allow All access + microsoft.ad.object: + name: App + attributes: + add: + nTSecurityDescriptor: + type: security_descriptor + value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;WD) + type: container + state: present + +- name: Ensure multiple values are present in attribute + microsoft.ad.object: + name: App + attributes: + add: + extensionName: + - value 1 + - value 2 + type: container + state: present + +- name: Ensure multiple values are not present in attribute + microsoft.ad.object: + name: App + attributes: + remove: + extensionName: + - value 1 + - value 3 + type: container + state: present +""" + +RETURN = r""" +object_guid: + description: + - The C(objectGUID) of the AD object that was created, removed, or edited. + - If a new object was created in check mode, a GUID of 0s will be returned. + returned: always + type: str + sample: d84a141f-2b99-4f08-9da0-ed2d26864ba1 +distinguished_name: + description: + - The C(distinguishedName) of the AD object that was created, removed, or edited. + returned: always + type: str + sample: CN=TestUser,CN=Users,DC=domain,DC=test +""" diff --git a/ansible_collections/microsoft/ad/plugins/modules/object_info.ps1 b/ansible_collections/microsoft/ad/plugins/modules/object_info.ps1 new file mode 100644 index 000000000..d386417fd --- /dev/null +++ b/ansible_collections/microsoft/ad/plugins/modules/object_info.ps1 @@ -0,0 +1,323 @@ +#!powershell + +# Copyright: (c) 2022, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic +#AnsibleRequires -PowerShell Ansible.ModuleUtils.AddType + +$spec = @{ + options = @{ + domain_password = @{ type = 'str'; no_log = $true } + domain_server = @{ type = 'str' } + domain_username = @{ type = 'str' } + filter = @{ type = 'str' } + identity = @{ type = 'str' } + include_deleted = @{ type = 'bool'; default = $false } + ldap_filter = @{ type = 'str' } + properties = @{ type = 'list'; elements = 'str' } + search_base = @{ type = 'str' } + search_scope = @{ type = 'str'; choices = @('base', 'one_level', 'subtree') } + } + supports_check_mode = $true + mutually_exclusive = @( + @('filter', 'identity', 'ldap_filter'), + @('identity', 'search_base'), + @('identity', 'search_scope') + ) + required_one_of = @( + , @('filter', 'identity', 'ldap_filter') + ) + required_together = @(, @('domain_username', 'domain_password')) +} + +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +$module.Result.objects = @() # Always ensure this is returned even in a failure. + +$domainServer = $module.Params.domain_server +$domainPassword = $module.Params.domain_password +$domainUsername = $module.Params.domain_username +$filter = $module.Params.filter +$identity = $module.Params.identity +$includeDeleted = $module.Params.include_deleted +$ldapFilter = $module.Params.ldap_filter +$properties = $module.Params.properties +$searchBase = $module.Params.search_base +$searchScope = $module.Params.search_scope + +$credential = $null +if ($domainUsername) { + $credential = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList @( + $domainUsername, + (ConvertTo-SecureString -AsPlainText -Force -String $domainPassword) + ) +} + +Add-CSharpType -AnsibleModule $module -References @' +using System; + +namespace Ansible.WinDomainObjectInfo +{ + [Flags] + public enum GroupType : uint + { + GROUP_TYPE_BUILTIN_LOCAL_GROUP = 0x00000001, + GROUP_TYPE_ACCOUNT_GROUP = 0x00000002, + GROUP_TYPE_RESOURCE_GROUP = 0x00000004, + GROUP_TYPE_UNIVERSAL_GROUP = 0x00000008, + GROUP_TYPE_APP_BASIC_GROUP = 0x00000010, + GROUP_TYPE_APP_QUERY_GROUP = 0x00000020, + GROUP_TYPE_SECURITY_ENABLED = 0x80000000, + } + + [Flags] + public enum UserAccountControl : int + { + ADS_UF_SCRIPT = 0x00000001, + ADS_UF_ACCOUNTDISABLE = 0x00000002, + ADS_UF_HOMEDIR_REQUIRED = 0x00000008, + ADS_UF_LOCKOUT = 0x00000010, + ADS_UF_PASSWD_NOTREQD = 0x00000020, + ADS_UF_PASSWD_CANT_CHANGE = 0x00000040, + ADS_UF_ENCRYPTED_TEXT_PASSWORD_ALLOWED = 0x00000080, + ADS_UF_TEMP_DUPLICATE_ACCOUNT = 0x00000100, + ADS_UF_NORMAL_ACCOUNT = 0x00000200, + ADS_UF_INTERDOMAIN_TRUST_ACCOUNT = 0x00000800, + ADS_UF_WORKSTATION_TRUST_ACCOUNT = 0x00001000, + ADS_UF_SERVER_TRUST_ACCOUNT = 0x00002000, + ADS_UF_DONT_EXPIRE_PASSWD = 0x00010000, + ADS_UF_MNS_LOGON_ACCOUNT = 0x00020000, + ADS_UF_SMARTCARD_REQUIRED = 0x00040000, + ADS_UF_TRUSTED_FOR_DELEGATION = 0x00080000, + ADS_UF_NOT_DELEGATED = 0x00100000, + ADS_UF_USE_DES_KEY_ONLY = 0x00200000, + ADS_UF_DONT_REQUIRE_PREAUTH = 0x00400000, + ADS_UF_PASSWORD_EXPIRED = 0x00800000, + ADS_UF_TRUSTED_TO_AUTHENTICATE_FOR_DELEGATION = 0x01000000, + } + + [Flags] + public enum SupportedEncryptionTypes : int + { + None = 0x00, + DES_CBC_CRC = 0x01, + DES_CBC_MD5 = 0x02, + RC4_HMAC = 0x04, + AES128_CTS_HMAC_SHA1_96 = 0x08, + AES256_CTS_HMAC_SHA1_96 = 0x10, + } + + public enum sAMAccountType : int + { + SAM_DOMAIN_OBJECT = 0x00000000, + SAM_GROUP_OBJECT = 0x10000000, + SAM_NON_SECURITY_GROUP_OBJECT = 0x10000001, + SAM_ALIAS_OBJECT = 0x20000000, + SAM_NON_SECURITY_ALIAS_OBJECT = 0x20000001, + SAM_USER_OBJECT = 0x30000000, + SAM_NORMAL_USER_ACCOUNT = 0x30000000, + SAM_MACHINE_ACCOUNT = 0x30000001, + SAM_TRUST_ACCOUNT = 0x30000002, + SAM_APP_BASIC_GROUP = 0x40000000, + SAM_APP_QUERY_GROUP = 0x40000001, + SAM_ACCOUNT_TYPE_MAX = 0x7fffffff, + } +} +'@ + +Function ConvertTo-OutputValue { + [CmdletBinding()] + Param ( + [Parameter(Mandatory = $true)] + [AllowNull()] + [Object] + $InputObject + ) + + if ($InputObject -is [System.Security.Principal.SecurityIdentifier]) { + # Syntax: SID - Only serialize the SID as a string and not the other metadata properties. + $sidInfo = @{ + Sid = $InputObject.Value + } + + # Try and map the SID to the account name, this may fail if the SID is invalid or not mappable. + try { + $sidInfo.Name = $InputObject.Translate([System.Security.Principal.NTAccount]).Value + } + catch [System.Security.Principal.IdentityNotMappedException] { + $sidInfo.Name = $null + } + + $sidInfo + } + elseif ($InputObject -is [Byte[]]) { + # Syntax: Octet String - By default will serialize as a list of decimal values per byte, instead return a + # Base64 string as Ansible can easily parse that. + [System.Convert]::ToBase64String($InputObject) + } + elseif ($InputObject -is [DateTime]) { + # Syntax: UTC Coded Time - .NET DateTimes serialized as in the form "Date(FILETIME)" which isn't easily + # parsable by Ansible, instead return as an ISO 8601 string in the UTC timezone. + [TimeZoneInfo]::ConvertTimeToUtc($InputObject).ToString("o") + } + elseif ($InputObject -is [System.Security.AccessControl.ObjectSecurity]) { + # Complex object which isn't easily serializable. Instead we should just return the SDDL string. If a user + # needs to parse this then they really need to reprocess the SDDL string and process their results on another + # win_shell task. + $InputObject.GetSecurityDescriptorSddlForm(([System.Security.AccessControl.AccessControlSections]::All)) + } + else { + # Syntax: (All Others) - The default serialization handling of other syntaxes are fine, don't do anything. + $InputObject + } +} + +<# +Calling Get-ADObject that returns multiple objects with -Properties * will only return the properties that were set on +the first found object. To counter this problem we will first call Get-ADObject to list all the objects that match the +filter specified then get the properties on each object. +#> + +$commonParams = @{ + IncludeDeletedObjects = $includeDeleted +} + +if ($credential) { + $commonParams.Credential = $credential +} + +if ($domainServer) { + $commonParams.Server = $domainServer +} + +# First get the IDs for all the AD objects that match the filter specified. +$getParams = @{ + Properties = @('DistinguishedName', 'ObjectGUID') +} + +if ($filter) { + $getParams.Filter = $filter +} +elseif ($identity) { + $getParams.Identity = $identity +} +elseif ($ldapFilter) { + $getParams.LDAPFilter = $ldapFilter +} + +# Explicit check on $null as an empty string is different from not being set. +if ($null -ne $searchBase) { + $getParams.SearchBase = $searchbase +} + +if ($searchScope) { + $getParams.SearchScope = switch ($searchScope) { + base { 'Base' } + one_level { 'OneLevel' } + subtree { 'Subtree' } + } +} + +try { + # We run this in a custom PowerShell pipeline so that users of this module can't use any of the variables defined + # above in their filter. While the cmdlet won't execute sub expressions we don't want anyone implicitly relying on + # a defined variable in this module in case we ever change the name or remove it. + $ps = [PowerShell]::Create() + $null = $ps.AddCommand('Get-ADObject').AddParameters($commonParams).AddParameters($getParams) + $null = $ps.AddCommand('Select-Object').AddParameter('Property', @('DistinguishedName', 'ObjectGUID')) + + $foundGuids = @($ps.Invoke()) +} +catch { + # Because we ran in a pipeline we can't catch ADIdentityNotFoundException. Instead this scans each InnerException + # to see if it contains ADIdentityNotFoundException. + $exp = $_.Exception + $foundGuids = $null + while ($exp) { + $exp = $exp.InnerException + if ($exp -is [Microsoft.ActiveDirectory.Management.ADIdentityNotFoundException]) { + $foundGuids = @() + break + } + } + + if ($null -eq $foundGuids) { + # The exception is from the .Invoke() call, compare on the InnerException which was what was actually raised by + # the pipeline. + $innerException = $_.Exception.InnerException.InnerException + if ($innerException -is [Microsoft.ActiveDirectory.Management.ADServerDownException]) { + # Point users in the direction of the double hop problem as that is what is typically the cause of this. + $msg = "Failed to contact the AD server, this could be caused by the double hop problem over WinRM. " + $msg += "Try using the module with auth as Kerberos with credential delegation or CredSSP, become, or " + $msg += "defining the domain_username and domain_password module parameters." + $module.FailJson($msg, $innerException) + } + else { + throw $innerException + } + } +} + +$getParams = @{} +if ($properties) { + $getParams.Properties = $properties +} +$module.Result.objects = @(foreach ($adId in $foundGuids) { + try { + $adObject = Get-ADObject @commonParams @getParams -Identity $adId.ObjectGUID + } + catch { + $msg = "Failed to retrieve properties for AD Object '$($adId.DistinguishedName)': $($_.Exception.Message)" + $module.Warn($msg) + continue + } + + $propertyNames = $adObject.PropertyNames + $propertyNames += ($properties | Where-Object { $_ -ne '*' }) + + # Now process each property to an easy to represent string + $filteredObject = [Ordered]@{} + foreach ($name in ($propertyNames | Sort-Object)) { + # In the case of explicit properties that were asked for but weren't set, Get-ADObject won't actually return + # the property so this is a defensive check against that scenario. + if (-not $adObject.PSObject.Properties.Name.Contains($name)) { + $filteredObject.$name = $null + continue + } + + $value = $adObject.$name + if ($value -is [Microsoft.ActiveDirectory.Management.ADPropertyValueCollection]) { + $value = foreach ($v in $value) { + ConvertTo-OutputValue -InputObject $v + } + } + else { + $value = ConvertTo-OutputValue -InputObject $value + } + $filteredObject.$name = $value + + # For these 3 properties, add an _AnsibleFlags attribute which contains the enum strings that are set. + $enumValue = switch ($name) { + groupType { + [Ansible.WinDomainObjectInfo.GroupType]$value + } + 'msDS-SupportedEncryptionTypes' { + [Ansible.WinDomainObjectInfo.SupportedEncryptionTypes]$value + } + sAMAccountType { + [Ansible.WinDomainObjectInfo.sAMAccountType]$value + } + userAccountControl { + [Ansible.WinDomainObjectInfo.UserAccountControl]$value + } + } + if ($null -ne $enumValue) { + $filteredObject."$($name)_AnsibleFlags" = $enumValue.ToString() -split ', ' + } + } + + $filteredObject + }) + +$module.ExitJson() diff --git a/ansible_collections/microsoft/ad/plugins/modules/object_info.py b/ansible_collections/microsoft/ad/plugins/modules/object_info.py new file mode 100644 index 000000000..0fe2f54ed --- /dev/null +++ b/ansible_collections/microsoft/ad/plugins/modules/object_info.py @@ -0,0 +1,180 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2022, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r""" +--- +module: object_info +short_description: Gather information an Active Directory object +description: +- Gather information about multiple Active Directory object(s). +requirements: +- C(ActiveDirectory) PowerShell module +options: + domain_password: + description: + - The password for I(domain_username). + type: str + domain_server: + description: + - Specified the Active Directory Domain Services instance to connect to. + - Can be in the form of an FQDN or NetBIOS name. + - If not specified then the value is based on the default domain of the computer running PowerShell. + type: str + domain_username: + description: + - The username to use when interacting with AD. + - If this is not set then the user that is used for authentication will be the connection user. + - Ansible will be unable to use the connection user unless auth is Kerberos with credential delegation or CredSSP, + or become is used on the task. + type: str + filter: + description: + - Specifies a query string using the PowerShell Expression Language syntax. + - This follows the same rules and formatting as the C(-Filter) parameter for the PowerShell AD cmdlets except + there is no variable substitutions. + - This is mutually exclusive with I(identity) and I(ldap_filter). + type: str + identity: + description: + - Specifies a single Active Directory object by its distinguished name or its object GUID. + - This is mutually exclusive with I(filter) and I(ldap_filter). + - This cannot be used with either the I(search_base) or I(search_scope) options. + type: str + include_deleted: + description: + - Also search for deleted Active Directory objects. + default: no + type: bool + ldap_filter: + description: + - Like I(filter) but this is a traditional LDAP query string to filter the objects to return. + - This is mutually exclusive with I(filter) and I(identity). + type: str + properties: + description: + - A list of properties to return. + - If a property is C(*), all properties that have a set value on the AD object will be returned. + - If a property is valid on the object but not set, it is only returned if defined explicitly in this option list. + - The properties C(DistinguishedName), C(Name), C(ObjectClass), and C(ObjectGUID) are always returned. + - Specifying multiple properties can have a performance impact, it is best to only return what is needed. + - If an invalid property is specified then the module will display a warning for each object it is invalid on. + type: list + elements: str + search_base: + description: + - Specify the Active Directory path to search for objects in. + - This cannot be set with I(identity). + - By default the search base is the default naming context of the target AD instance which is the DN returned by + C(Get-ADRootDSE | Select-Object -ExpandProperty defaultNamingContext). + type: str + search_scope: + description: + - Specify the scope of when searching for an object in the I(search_base). + - C(base) will limit the search to the base object so the maximum number of objects returned is always one. This + will not search any objects inside a container.. + - C(one_level) will search the current path and any immediate objects in that path. + - C(subtree) will search the current path and all objects of that path recursively. + - This cannot be set with I(identity). + choices: + - base + - one_level + - subtree + type: str +notes: +- The C(groupType_AnsibleFlags), C(msDS-SupportedEncryptionTypes_AnsibleFlags), C(sAMAccountType_AnsibleFlags), + and C(userAccountControl_AnsibleFlags) return property is something set by the module itself as an easy way to view + what those flags represent. These properties cannot be used as part of the I(filter) or I(ldap_filter) and are + automatically added if those properties were requested. +extends_documentation_fragment: +- ansible.builtin.action_common_attributes +attributes: + check_mode: + support: full + diff_mode: + support: none + platform: + platforms: + - windows +seealso: +- module: microsoft.ad.domain +- module: microsoft.ad.domain_controller +- module: microsoft.ad.group +- module: microsoft.ad.object +- module: microsoft.ad.user +- module: microsoft.ad.computer +- ref: Migration guide <ansible_collections.microsoft.ad.docsite.guide_migration.migrated_modules.win_domain_object_info> + description: This module replaces C(community.windows.win_domain_object_info). See the migration guide for details. +- module: community.windows.win_domain_object_info +author: +- Jordan Borean (@jborean93) +""" + +EXAMPLES = r""" +- name: Get all properties for the specified account using its DistinguishedName + microsoft.ad.object_info: + identity: CN=Username,CN=Users,DC=domain,DC=com + properties: '*' + +- name: Get the SID for all user accounts as a filter + microsoft.ad.object_info: + filter: ObjectClass -eq 'user' -and objectCategory -eq 'Person' + properties: + - objectSid + +- name: Get the SID for all user accounts as a LDAP filter + microsoft.ad.object_info: + ldap_filter: (&(objectClass=user)(objectCategory=Person)) + properties: + - objectSid + +- name: Search all computer accounts in a specific path that were added after February 1st + microsoft.ad.object_info: + filter: objectClass -eq 'computer' -and whenCreated -gt '20200201000000.0Z' + properties: '*' + search_scope: one_level + search_base: CN=Computers,DC=domain,DC=com +""" + +RETURN = r""" +objects: + description: + - A list of dictionaries that are the Active Directory objects found and the properties requested. + - The dict's keys are the property name and the value is the value for the property. + - All date properties are return in the ISO 8601 format in the UTC timezone. + - All SID properties are returned as a dict with the keys C(Sid) as the SID string and C(Name) as the translated SID + account name. + - All byte properties are returned as a base64 string. + - All security descriptor properties are returned as the SDDL string of that descriptor. + - The properties C(DistinguishedName), C(Name), C(ObjectClass), and C(ObjectGUID) are always returned. + returned: always + type: list + elements: dict + sample: | + [{ + "accountExpires": 0, + "adminCount": 1, + "CanonicalName": "domain.com/Users/Administrator", + "CN": "Administrator", + "Created": "2020-01-13T09:03:22.0000000Z", + "Description": "Built-in account for administering computer/domain", + "DisplayName": null, + "DistinguishedName": "CN=Administrator,CN=Users,DC=domain,DC=com", + "memberOf": [ + "CN=Group Policy Creator Owners,CN=Users,DC=domain,DC=com", + "CN=Domain Admins",CN=Users,DC=domain,DC=com" + ], + "Name": "Administrator", + "nTSecurityDescriptor": "O:DAG:DAD:PAI(A;;LCRPLORC;;;AU)(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;SY)(A;;CCDCLCSWRPWPLOCRSDRCWDWO;;;BA)", + "ObjectCategory": "CN=Person,CN=Schema,CN=Configuration,DC=domain,DC=com", + "ObjectClass": "user", + "ObjectGUID": "c8c6569e-4688-4f3c-8462-afc4ff60817b", + "objectSid": { + "Sid": "S-1-5-21-2959096244-3298113601-420842770-500", + "Name": "DOMAIN\Administrator" + }, + "sAMAccountName": "Administrator", + }] +""" diff --git a/ansible_collections/microsoft/ad/plugins/modules/offline_join.ps1 b/ansible_collections/microsoft/ad/plugins/modules/offline_join.ps1 new file mode 100644 index 000000000..aef811f11 --- /dev/null +++ b/ansible_collections/microsoft/ad/plugins/modules/offline_join.ps1 @@ -0,0 +1,203 @@ +#!powershell + +# Copyright (c) 2022 Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic +#AnsibleRequires -PowerShell Ansible.ModuleUtils.AddType + +$spec = @{ + options = @{ + blob_path = @{ + type = 'str' + } + domain_server = @{ + type = 'str' + } + identity = @{ + type = 'str' + } + name = @{ + type = 'str' + } + path = @{ + type = 'str' + } + provision_root_ca_certs = @{ + type = 'bool' + default = $false + } + } + mutually_exclusive = @( + , @('identity', 'name') + ) + required_one_of = @( + , @('identity', 'name') + ) + supports_check_mode = $true +} +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +$adParams = @{} +if ($module.Params.domain_server) { + $adParams.Server = $module.Params.domain_server +} + +$module.Result.blob = $null + +Add-CSharpType -AnsibleModule $module -References @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; + +namespace microsoft.ad.domain_join +{ + [StructLayout(LayoutKind.Sequential)] + internal struct NETSETUP_PROVISIONING_PARAMS + { + internal const int NETSETUP_PROVISIONING_PARAMS_CURRENT_VERSION = 0x00000001; + + public int dwVersion; + [MarshalAs(UnmanagedType.LPWStr)] + public string lpDomain; + [MarshalAs(UnmanagedType.LPWStr)] + public string lpHostName; + [MarshalAs(UnmanagedType.LPWStr)] + public string lpMachineAccountOU; + [MarshalAs(UnmanagedType.LPWStr)] + public string lpDCName; + public int dwProvisionOptions; + public IntPtr aCertTemplateNames; + public int cCertTemplateNames; + public IntPtr aMachinePolicyNames; + public int cMachinePolicyNames; + public IntPtr aMachinePolicyPaths; + public int cMachinePolicyPaths; + public IntPtr lpNetbiosName; + public IntPtr lpSiteName; + public IntPtr lpPrimaryDNSDomain; + } + + [Flags] + public enum ProvisionOptions + { + NETSETUP_PROVISION_DOWNLEVEL_PRIV_SUPPORT = 0x00000001, + NETSETUP_PROVISION_REUSE_ACCOUNT = 0x00000002, + NETSETUP_PROVISION_USE_DEFAULT_PASSWORD = 0x00000004, + NETSETUP_PROVISION_SKIP_ACCOUNT_SEARCH = 0x00000008, + NETSETUP_PROVISION_ROOT_CA_CERTS = 0x00000010, + NETSETUP_PROVISION_PERSISTENTSITE = 0x00000020, + } + + public static class Native + { + [DllImport("Netapi32.dll", EntryPoint = "NetCreateProvisioningPackage")] + private static extern int NativeNetCreateProvisioningPackage( + ref NETSETUP_PROVISIONING_PARAMS pProvisioningParams, + ref IntPtr ppPackageBinData, + out int ppdwPackageBinDataSize, + IntPtr ppPackageTextData); + + public static byte[] NetCreateProvisioningPackage(string domain, string hostName, string machineAccountOU, + string dcName, ProvisionOptions options) + { + domain = String.IsNullOrWhiteSpace(domain) ? null : domain; + hostName = String.IsNullOrWhiteSpace(hostName) ? null : hostName; + machineAccountOU = String.IsNullOrWhiteSpace(machineAccountOU) ? null : machineAccountOU; + dcName = String.IsNullOrWhiteSpace(dcName) ? null : dcName; + + NETSETUP_PROVISIONING_PARAMS p = new NETSETUP_PROVISIONING_PARAMS() + { + dwVersion = NETSETUP_PROVISIONING_PARAMS.NETSETUP_PROVISIONING_PARAMS_CURRENT_VERSION, + lpDomain = domain, + lpHostName = hostName, + lpMachineAccountOU = machineAccountOU, + lpDCName = dcName, + dwProvisionOptions = (int)options, + }; + + IntPtr outBuffer = IntPtr.Zero; + int outBufferLength = 0; + int res = NativeNetCreateProvisioningPackage(ref p, ref outBuffer, out outBufferLength, IntPtr.Zero); + if (res != 0) + { + throw new Win32Exception(res); + } + + byte[] data = new byte[outBufferLength]; + Marshal.Copy(outBuffer, data, 0, data.Length); + + return data; + } + } +} +'@ + +$identity = if ($module.Params.identity) { + $module.Params.identity +} +else { + $path = $module.Params.path + if (-not $path) { + $GUID_COMPUTERS_CONTAINER_W = 'AA312825768811D1ADED00C04FD8D5CD' + $defaultNamingContext = (Get-ADRootDSE @adParams -Properties defaultNamingContext).defaultNamingContext + + $path = Get-ADObject @ADParams -Identity $defaultNamingContext -Properties wellKnownObjects | + Select-Object -ExpandProperty wellKnownObjects | + Where-Object { $_.StartsWith("B:32:$($GUID_COMPUTERS_CONTAINER_W):") } | + ForEach-Object Substring 38 + } + + "CN=$($Module.Params.name -replace ',', '\,'),$path" +} + +try { + $computer = Get-ADComputer -Identity $identity @adParams +} +catch [Microsoft.ActiveDirectory.Management.ADIdentityNotFoundException] { + $msg = "Failed to find domain computer account '$identity': $($_.Exception.Message)" + $module.FailJson($msg, $_) +} + + +# The name expected by NetCreateProvisioningPackage is the sAMAccountName but +# without the trailing $. +$computerName = $computer.SamAccountName.Substring(0, + $computer.SamAccountName.Length - 1) +$computerPath = @($computerName.DistinguishedName -split '[^\\],', 2)[-1] + +$flags = [microsoft.ad.domain_join.ProvisionOptions]::NETSETUP_PROVISION_REUSE_ACCOUNT +if ($module.Params.provision_root_ca_certs) { + $flags = $flags -bor [microsoft.ad.domain_join.ProvisionOptions]::NETSETUP_PROVISION_ROOT_CA_CERTS +} + +$domainInfo = Get-ADDomain @adParams + +if ($module.Params.blob_path -and (Test-Path -LiteralPath $module.Params.blob_path)) { + $module.ExitJson() +} + +if (-not $Module.CheckMode) { + $blob = [microsoft.ad.domain_join.Native]::NetCreateProvisioningPackage( + $domainInfo.DNSRoot, + $computerName, + $computerPath, + $adParams.Server, + $flags) +} +else { + $blob = New-Object -TypeName System.Byte[] -ArgumentList 0 +} + +$module.Result.changed = $true + +if ($module.Params.blob_path) { + if (-not $Module.CheckMode) { + [System.IO.File]::WriteAllBytes($module.Params.blob_path, $blob) + } +} +else { + $module.Result.blob = [System.Convert]::ToBase64String($blob) +} + +$module.ExitJson() diff --git a/ansible_collections/microsoft/ad/plugins/modules/offline_join.py b/ansible_collections/microsoft/ad/plugins/modules/offline_join.py new file mode 100644 index 000000000..0b07bc36f --- /dev/null +++ b/ansible_collections/microsoft/ad/plugins/modules/offline_join.py @@ -0,0 +1,135 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2023, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r""" +--- +module: offline_join +short_description: Get the Offline Domain Join BLOB +description: +- Used to get the Offline Domain Join BLOB. +- This BLOB is used to join computers to a domain without any network access. +requirements: +- C(ActiveDirectory) PowerShell module +options: + blob_path: + description: + - If set, will store the blob bytes into a file at this path. + - This will not create the parent directory specified if it does not exist. + - The existence of this file is also used as an idempotency check, if the + file already exists the blob will not be regenerated. + - If specified the module return value I(blob) will be null. + type: str + domain_server: + description: + - Specified the Active Directory Domain Services instance to connect to. + - Can be in the form of an FQDN or NetBIOS name. + - If not specified then the value is based on the default domain of the + computer running PowerShell. + type: str + identity: + description: + - The identity of the computer object used to generate the offline join + blob for. + - This is mutually exclusive with I(name) and (path). + - The identity can be in the form of a GUID representing the C(objectGUID) + value, C(sAMAccountName), C(objectSid), or C(distinguishedName). + - This option or I(name) must be specified. + type: str + name: + description: + - The name of the computer object used to generate the offline join blob + for. + - This is mutually exclusive with I(identity). + - The name is combined with I(path) to find the AD computer object that + matches the DistinguishedName C(CN={{ name}},{{ path }}). + - This option or I(identity) must be specified. + type: str + path: + description: + - The path where the computer object specified by C(name) is stored. + - By default the default computer path defined in AD is used, for example + C(CN=Computers,DC=domain,DC=com). + type: str + provision_root_ca_certs: + description: + - Adds all the root Certificate Authority certificates on the local machine + and adds them to the blob. + type: bool + default: false +extends_documentation_fragment: +- ansible.builtin.action_common_attributes +attributes: + check_mode: + support: full + diff_mode: + support: none + platform: + platforms: + - windows +notes: +- For more information on Offline Domain Join see + L(the step-by-step guide,https://docs.microsoft.com/en-us/previous-versions/windows/it-pro/windows-server-2008-R2-and-2008/dd392267%28v=ws.10%29). +- There is no way to specify specific credentials to communicate with the + domain controller when creating the blob. Use C(become) with net credentials + if the current user cannot authenticate itself and bypass the double hop + problem. +- The data returned by this module is very sensitive. If not using I(blob_path) + then C(no_log=True) should be used on the task to avoid the data being + leaked. +- This module will always report a change unless I(blob_path) is specified. If + the path is specified then then the existence of that path will act as the + idempotency check. +- 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. +seealso: +- module: microsoft.ad.domain +- module: microsoft.ad.membership +- module: microsoft.ad.computer +author: +- Jordan Borean (@jborean93) +""" + +EXAMPLES = r""" +- name: create computer object + microsoft.ad.computer: + name: MyComputer + state: present + register: computer_obj + +- name: create offline blob + microsoft.ad.offline_join: + identity: '{{ computer_obj.object_guid }}' + when: computer_obj is changed + register: offline_blob + no_log: true + +- name: join host by offline blob + microsoft.ad.membership: + offline_join_blob: '{{ offline_blob.blob }}' + state: domain + reboot: true + delegate_to: member-host + +- name: create blob and store it in a file on the target host + microsoft.ad.offline_join: + name: MyComputer + path: OU=Production,DC=domain,DC=com + blob_path: C:\Windows\TEMP\offline_blob +""" + +RETURN = r""" +blob: + description: + - The blob as a base64 string. + - This value is empty when running in check mode. + - This value is null when I(blob_path) is specified. + - This value is highly sensitive as it contains the credentials and other + authentication data needed for an offline join. + returned: always + type: str + sample: ARAIAMzMzMygCAAAAAAAAAAAAgABAAAA +""" diff --git a/ansible_collections/microsoft/ad/plugins/modules/ou.ps1 b/ansible_collections/microsoft/ad/plugins/modules/ou.ps1 new file mode 100644 index 000000000..6af68b5ae --- /dev/null +++ b/ansible_collections/microsoft/ad/plugins/modules/ou.ps1 @@ -0,0 +1,44 @@ +#!powershell + +# Copyright: (c) 2023, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic +#AnsibleRequires -PowerShell ..module_utils._ADObject + +$setParams = @{ + PropertyInfo = @( + [PSCustomObject]@{ + Name = 'city' + Option = @{ type = 'str' } + Attribute = 'City' + } + [PSCustomObject]@{ + Name = 'country' + Option = @{ type = 'str' } + Attribute = 'Country' + } + [PSCustomObject]@{ + Name = 'managed_by' + Option = @{ type = 'str' } + Attribute = 'ManagedBy' + } + [PSCustomObject]@{ + Name = 'postal_code' + Option = @{ type = 'str' } + Attribute = 'PostalCode' + } + [PSCustomObject]@{ + Name = 'state_province' + Option = @{ type = 'str' } + Attribute = 'State' + } + [PSCustomObject]@{ + Name = 'street' + Option = @{ type = 'str' } + Attribute = 'StreetAddress' + } + ) + ModuleNoun = 'ADOrganizationalUnit' +} +Invoke-AnsibleADObject @setParams diff --git a/ansible_collections/microsoft/ad/plugins/modules/ou.py b/ansible_collections/microsoft/ad/plugins/modules/ou.py new file mode 100644 index 000000000..d7ac85007 --- /dev/null +++ b/ansible_collections/microsoft/ad/plugins/modules/ou.py @@ -0,0 +1,133 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2023, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r""" +--- +module: ou +short_description: Manage Active Directory organizational units +description: +- Manages Active Directory organizational units and their attributes. +options: + city: + description: + - Configures the user's city. + - This is the value set on the C(l) LDAP attribute. + type: str + country: + description: + - Configures the user's country code. + - Note that this is a two-character ISO 3166 code. + - This is the value set on the C(c) LDAP attribute. + type: str + managed_by: + description: + - The user or group that manages the object. + - The value can be in the form of a C(distinguishedName), C(objectGUID), + C(objectSid), or sAMAccountName). + - This is the value set on the C(managedBy) LDAP attribute. + type: str + postal_code: + description: + - Configures the user's postal code / zip code. + - This is the value set on the C(postalcode) LDAP attribute. + type: str + state_province: + description: + - Configures the user's state. + - This is the value set on the C(state) LDAP attribute. + type: str + street: + description: + - Configures the user's street address. + - This is the value set on the C(street) LDAP attribute. + type: str +notes: +- When an OU is created, I(protect_from_deletion) defaults to C(True) if not + 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. +extends_documentation_fragment: +- microsoft.ad.ad_object +- ansible.builtin.action_common_attributes +attributes: + check_mode: + support: full + diff_mode: + support: full + platform: + platforms: + - windows +seealso: +- module: microsoft.ad.domain +- module: microsoft.ad.domain_controller +- module: microsoft.ad.group +- module: microsoft.ad.object_info +- module: microsoft.ad.user +- module: microsoft.ad.computer +- ref: Migration guide <ansible_collections.microsoft.ad.docsite.guide_migration.migrated_modules.win_domain_ou> + description: This module replaces C(community.windows.win_domain_ou). See the migration guide for details. +- module: community.windows.win_domain_ou +author: +- Jordan Borean (@jborean93) +""" + +EXAMPLES = r""" +- name: Ensure OU is present & protected + microsoft.ad.ou: + name: AnsibleFest + state: present + +- name: Ensure OU is present & protected + microsoft.ad.ou: + name: EUC Users + path: DC=euc,DC=vmware,DC=lan + state: present + protect_from_deletion: true + +- name: Ensure OU is absent + microsoft.ad.ou: + name: EUC Users + path: DC=euc,DC=vmware,DC=lan + state: absent + +- name: Ensure OU is present with specific properties + microsoft.ad.ou: + name: WS1Users + path: CN=EUC Users,DC=euc,DC=vmware,DC=lan + protect_from_deletion: true + description: EUC Business Unit + city: Sandy Springs + country: US + state_province: Georgia + street: 1155 Perimeter Center West + postal_code: 30189 + +- name: Ensure OU updated with new properties + microsoft.ad.ou: + name: WS1Users + path: DC=euc,DC=vmware,DC=lan + protected: false + managed_by: jzollo@vmware.com + attributes: + set: + comment: A comment for the OU +""" + +RETURN = r""" +object_guid: + description: + - The C(objectGUID) of the AD object that was created, removed, or edited. + - If a new object was created in check mode, a GUID of 0s will be returned. + returned: always + type: str + sample: d84a141f-2b99-4f08-9da0-ed2d26864ba1 +distinguished_name: + description: + - The C(distinguishedName) of the AD object that was created, removed, or edited. + returned: always + type: str + sample: CN=TestUser,CN=Users,DC=domain,DC=test +""" diff --git a/ansible_collections/microsoft/ad/plugins/modules/user.ps1 b/ansible_collections/microsoft/ad/plugins/modules/user.ps1 new file mode 100644 index 000000000..d975272c7 --- /dev/null +++ b/ansible_collections/microsoft/ad/plugins/modules/user.ps1 @@ -0,0 +1,458 @@ +#!powershell + +# Copyright: (c) 2023, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.AccessToken +#AnsibleRequires -CSharpUtil Ansible.Basic +#AnsibleRequires -PowerShell ..module_utils._ADObject + +Function Test-Credential { + param( + [String]$Username, + [String]$Password, + [String]$Domain = $null + ) + if (($Username.ToCharArray()) -contains [char]'@') { + # UserPrincipalName + $Domain = $null # force $Domain to be null, to prevent undefined behaviour, as a domain name is already included in the username + } + elseif (($Username.ToCharArray()) -contains [char]'\') { + # Pre Win2k Account Name + $Domain = ($Username -split '\\')[0] + $Username = ($Username -split '\\', 2)[-1] + } # If no domain provided, so maybe local user, or domain specified separately. + + try { + ([Ansible.AccessToken.TokenUtil]::LogonUser($Username, $Domain, $Password, "Network", "Default")).Dispose() + return $true + } + catch [Ansible.AccessToken.Win32Exception] { + # following errors indicate the creds are correct but the user was + # unable to log on for other reasons, which we don't care about + $success_codes = @( + 0x0000052F, # ERROR_ACCOUNT_RESTRICTION + 0x00000530, # ERROR_INVALID_LOGON_HOURS + 0x00000531, # ERROR_INVALID_WORKSTATION + 0x00000569 # ERROR_LOGON_TYPE_GRANTED + ) + $failed_codes = @( + 0x0000052E, # ERROR_LOGON_FAILURE + 0x00000532, # ERROR_PASSWORD_EXPIRED + 0x00000773, # ERROR_PASSWORD_MUST_CHANGE + 0x00000533 # ERROR_ACCOUNT_DISABLED + ) + + if ($_.Exception.NativeErrorCode -in $failed_codes) { + return $false + } + elseif ($_.Exception.NativeErrorCode -in $success_codes) { + return $true + } + else { + # an unknown failure, reraise exception + throw $_ + } + } +} + +$setParams = @{ + PropertyInfo = @( + [PSCustomObject]@{ + Name = 'account_locked' + Option = @{ + choices = @(, $false) + type = 'bool' + } + Attribute = 'LockedOut' + Set = { + param($Module, $ADParams, $SetParams, $ADObject) + + if ($ADObject.LockedOut) { + Unlock-ADAccount @ADParams -Identity $ADObject.ObjectGUID -WhatIf:$Module.CheckMode + $Module.Result.changed = $true + } + + $Module.Diff.after.account_locked = $false + } + } + + [PSCustomObject]@{ + Name = 'city' + Option = @{ type = 'str' } + Attribute = 'City' + } + + [PSCustomObject]@{ + Name = 'company' + Option = @{ type = 'str' } + Attribute = 'company' + } + + [PSCustomObject]@{ + Name = 'country' + Option = @{ type = 'str' } + Attribute = 'Country' + } + + [PSCustomObject]@{ + Name = 'delegates' + Option = @{ + aliases = 'principals_allowed_to_delegate' + type = 'dict' + options = @{ + add = @{ type = 'list'; elements = 'str' } + remove = @{ type = 'list'; elements = 'str' } + set = @{ type = 'list'; elements = 'str' } + } + } + Attribute = 'PrincipalsAllowedToDelegateToAccount' + CaseInsensitive = $true + } + + [PSCustomObject]@{ + Name = 'email' + Option = @{ type = 'str' } + Attribute = 'EmailAddress' + } + + [PSCustomObject]@{ + Name = 'enabled' + Option = @{ type = 'bool' } + Attribute = 'Enabled' + } + + [PSCustomObject]@{ + Name = 'firstname' + Option = @{ type = 'str' } + Attribute = 'givenName' + } + + [PSCustomObject]@{ + Name = 'groups' + Option = @{ + type = 'dict' + options = @{ + add = @{ type = 'list'; elements = 'str' } + remove = @{ type = 'list'; elements = 'str' } + set = @{ type = 'list'; elements = 'str' } + missing_behaviour = @{ + choices = 'fail', 'ignore', 'warn' + default = 'fail' + type = 'str' + } + } + } + } + + [PSCustomObject]@{ + Name = 'password' + Option = @{ + no_log = $true + type = 'str' + } + New = { + param($Module, $ADParams, $NewParams) + + $NewParams.AccountPassword = (ConvertTo-SecureString -AsPlainText -Force -String $module.Params.password) + $Module.Diff.after.password = 'VALUE_SPECIFIED_IN_NO_LOG_PARAMETER' + } + Set = { + param($Module, $ADParams, $SetParams, $ADObject) + + $Module.Diff.before.password = 'VALUE_SPECIFIED_IN_NO_LOG_PARAMETER' + + $changed = switch ($Module.Params.update_password) { + always { $true } + on_create { $false } + when_changed { + # Try and use the UPN but fallback to msDS-PrincipalName if none is defined + $username = $ADObject.UserPrincipalName + if (-not $username) { + $username = $ADObject['msDS-PrincipalName'] + } + + -not (Test-Credential -Username $username -Password $module.Params.password) + } + } + + if (-not $changed) { + $Module.Diff.after.password = 'VALUE_SPECIFIED_IN_NO_LOG_PARAMETER' + return + } + + # -WhatIf was broken until Server 2016 and will set the + # password. Just avoid calling this in check mode. + if (-not $Module.CheckMode) { + $setParams = @{ + Identity = $ADObject.ObjectGUID + Reset = $true + Confirm = $false + NewPassword = (ConvertTo-SecureString -AsPlainText -Force -String $module.Params.password) + } + Set-ADAccountPassword @setParams @ADParams + } + + $Module.Diff.after.password = 'VALUE_SPECIFIED_IN_NO_LOG_PARAMETER - changed' + $Module.Result.changed = $true + } + } + + [PSCustomObject]@{ + Name = 'password_expired' + Option = @{ type = 'bool' } + Attribute = 'PasswordExpired' + New = { + param($Module, $ADParams, $NewParams) + + $NewParams.ChangePasswordAtLogon = $module.Params.password_expired + $Module.Diff.after.password_expired = $module.Params.password_expired + } + Set = { + param($Module, $ADParams, $SetParams, $ADObject) + + if ($ADObject.PasswordExpired -ne $Module.Params.password_expired) { + $SetParams.ChangePasswordAtLogon = $Module.Params.password_expired + } + + $Module.Diff.after.password_expired = $Module.Params.password_expired + } + } + + [PSCustomObject]@{ + Name = 'password_never_expires' + Option = @{ type = 'bool' } + Attribute = 'PasswordNeverExpires' + } + + [PSCustomObject]@{ + Name = 'postal_code' + Option = @{ type = 'str' } + Attribute = 'PostalCode' + } + + [PSCustomObject]@{ + Name = 'sam_account_name' + Option = @{ type = 'str' } + Attribute = 'sAMAccountName' + } + + [PSCustomObject]@{ + Name = 'spn' + Option = @{ + aliases = 'spns' + type = 'dict' + options = @{ + add = @{ type = 'list'; elements = 'str' } + remove = @{ type = 'list'; elements = 'str' } + set = @{ type = 'list'; elements = 'str' } + } + } + Attribute = 'ServicePrincipalNames' + New = { + param($Module, $ADParams, $NewParams) + + $spns = @( + $Module.Params.spn.add + $Module.Params.spn.set + ) | Select-Object -Unique + + $NewParams.ServicePrincipalNames = $spns + $Module.Diff.after.spn = $spns + } + Set = { + param($Module, $ADParams, $SetParams, $ADObject) + + $desired = $Module.Params.spn + $compareParams = @{ + Existing = $ADObject.ServicePrincipalNames + CaseInsensitive = $true + } + $res = Compare-AnsibleADIdempotentList @compareParams @desired + if ($res.Changed) { + $SetParams.ServicePrincipalNames = @{} + if ($res.ToAdd) { + $SetParams.ServicePrincipalNames.Add = $res.ToAdd + } + if ($res.ToRemove) { + $SetParams.ServicePrincipalNames.Remove = $res.ToRemove + } + } + $module.Diff.after.kerberos_encryption_types = @($res.Value | Sort-Object) + } + } + + [PSCustomObject]@{ + Name = 'state_province' + Option = @{ type = 'str' } + Attribute = 'State' + } + + [PSCustomObject]@{ + Name = 'street' + Option = @{ type = 'str' } + Attribute = 'StreetAddress' + } + + [PSCustomObject]@{ + Name = 'surname' + Option = @{ + aliases = 'lastname' + type = 'str' + } + Attribute = 'Surname' + } + + [PSCustomObject]@{ + Name = 'update_password' + Option = @{ + choices = 'always', 'on_create', 'when_changed' + default = 'always' + type = 'str' + } + } + + [PSCustomObject]@{ + Name = 'upn' + Option = @{ type = 'str' } + Attribute = 'userPrincipalName' + } + + [PSCustomObject]@{ + Name = 'user_cannot_change_password' + Option = @{ type = 'bool' } + Attribute = 'CannotChangePassword' + } + ) + ModuleNoun = 'ADUser' + DefaultPath = { + param($Module, $ADParams) + + $GUID_USERS_CONTAINER_W = 'A9D1CA15768811D1ADED00C04FD8D5CD' + $defaultNamingContext = (Get-ADRootDSE @ADParams -Properties defaultNamingContext).defaultNamingContext + + Get-ADObject @ADParams -Identity $defaultNamingContext -Properties wellKnownObjects | + Select-Object -ExpandProperty wellKnownObjects | + Where-Object { $_.StartsWith("B:32:$($GUID_USERS_CONTAINER_W):") } | + ForEach-Object Substring 38 + } + ExtraProperties = @( + # Used for password when checking if the password is valid + 'msDS-PrincipalName' + ) + PreAction = { + param ($Module, $ADParams, $ADObject) + + if ( + $Module.Params.state -eq 'present' -and + $null -eq $ADObject -and + $null -eq $Module.Params.enabled + ) { + $Module.Params.enabled = -not ([String]::IsNullOrWhiteSpace($Module.Params.password)) + } + } + PostAction = { + param($Module, $ADParams, $ADObject) + + if ($ADObject) { + $Module.Result.sid = $ADObject.SID.Value + } + elseif ($Module.Params.state -eq 'present') { + # Use dummy value for check mode when creating a new user + $Module.Result.sid = 'S-1-5-0000' + } + + if ($null -eq $Module.Params.groups -or $Module.Params.groups.Count -eq 0 -or $Module.Params.state -eq 'absent') { + return + } + + $groupMissingBehaviour = $Module.Params.groups.missing_behaviour + $lookupGroup = { + try { + (Get-ADGroup -Identity $args[0] @ADParams).DistinguishedName + } + catch { + if ($groupMissingBehaviour -eq "fail") { + $module.FailJson("Failed to locate group $($args[0]): $($_.Exception.Message)", $_) + } + elseif ($groupMissingBehaviour -eq "warn") { + $module.Warn("Failed to locate group $($args[0]) but continuing on: $($_.Exception.Message)") + } + } + } + + [string[]]$existingGroups = @( + # In check mode the ADObject won't be given + if ($ADObject) { + try { + Get-ADPrincipalGroupMembership -Identity $ADObject.ObjectGUID @ADParams -ErrorAction Stop | + Select-Object -ExpandProperty DistinguishedName + } + catch { + $module.Warn("Failed to enumerate user groups but continuing on: $($_.Exception.Message)") + } + } + ) + + if ($Module.Diff.before) { + $Module.Diff.before.groups = @($existingGroups | Sort-Object) + } + + $compareParams = @{ + CaseInsensitive = $true + Existing = $existingGroups + } + 'add', 'remove', 'set' | ForEach-Object -Process { + if ($null -ne $Module.Params.groups[$_]) { + $compareParams[$_] = @( + foreach ($group in $Module.Params.groups[$_]) { + & $lookupGroup $group + } + ) + } + } + + $res = Compare-AnsibleADIdempotentList @compareParams + $Module.Diff.after.groups = $res.Value + + if ($res.Changed) { + $commonParams = @{ + Confirm = $false + WhatIf = $Module.CheckMode + } + foreach ($member in $res.ToAdd) { + if ($ADObject) { + Add-ADGroupMember -Identity $member -Members $ADObject.ObjectGUID @ADParams @commonParams + } + $Module.Result.changed = $true + } + foreach ($member in $res.ToRemove) { + if ($ADObject) { + try { + Remove-ADGroupMember -Identity $member -Members $ADObject.ObjectGUID @ADParams @commonParams + } + catch [Microsoft.ActiveDirectory.Management.ADException] { + if ($_.Exception.ErrorCode -eq 0x0000055E) { + # ERROR_MEMBERS_PRIMARY_GROUP - win_domain_user didn't + # fail in this scenario. To preserve compatibility just + # display a warning. The warning isn't added if set + # was an empty list. + if ($null -eq $Module.Params.groups -or $Module.Params.groups.set.Length -ne 0) { + $Module.Warn("Cannot remove group '$member' as it's the primary group of the user, skipping: $($_.Exception.Message)") + } + $Module.Diff.after.groups = @($Module.Diff.after.groups; $member) + } + else { + throw + } + } + } + $Module.Result.changed = $true + } + } + + # Ensure it's in alphabetical order to match before state as much as possible + $Module.Diff.after.groups = @($res.Value | Sort-Object) + } +} +Invoke-AnsibleADObject @setParams diff --git a/ansible_collections/microsoft/ad/plugins/modules/user.py b/ansible_collections/microsoft/ad/plugins/modules/user.py new file mode 100644 index 000000000..30d1c6412 --- /dev/null +++ b/ansible_collections/microsoft/ad/plugins/modules/user.py @@ -0,0 +1,407 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2023, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r""" +--- +module: user +short_description: Manage Active Directory users +description: +- Manages Active Directory users and their attributes. +options: + account_locked: + description: + - C(no) will unlock the user account if locked. + - Note that there is not a way to lock an account as an administrator. + - Accounts are locked due to user actions; as an admin, you may only unlock a locked account. + - If you wish to administratively disable an account, set I(enabled) to C(no). + choices: + - false + type: bool + city: + description: + - Configures the user's city. + - This is the value set on the C(l) LDAP attribute. + type: str + company: + description: + - Configures the user's company name. + - This is the value set on the C(company) LDAP attribute. + type: str + country: + description: + - Configures the user's country code. + - Note that this is a two-character ISO 3166 code. + - This is the value set on the C(c) LDAP attribute. + type: str + delegates: + description: + - The principal objects that the current AD object can trust for + delegation to either add, remove or set. + - The values for each sub option must be specified as a distinguished name + C(CN=shenetworks,CN=Users,DC=ansible,DC=test) + - This is the value set on the C(msDS-AllowedToActOnBehalfOfOtherIdentity) + LDAP attribute. + - This is a highly sensitive attribute as it allows the principals + specified to impersonate any account when authenticating with the AD + computer object being managed. + - To clear all principals, use I(set) with an empty list. + - See R(Setting list option values,ansible_collections.microsoft.ad.docsite.guide_list_values) + for more information on how to add/remove/set list options. + aliases: + - principals_allowed_to_delegate + type: dict + suboptions: + add: + description: + - The AD objects by their C(DistinguishedName) to add as a principal + allowed to delegate. + - Any existing principals not specified by I(add) will be untouched + unless specified by I(remove) or not in I(set). + type: list + elements: str + remove: + description: + - The AD objects by their C(DistinguishedName) to remove as a principal + allowed to delegate. + - Any existing principals not specified by I(remove) will be untouched + unless I(set) is defined. + type: list + elements: str + set: + description: + - The AD objects by their C(DistinguishedName) to set as the only + principals allowed to delegate. + - This will remove any existing principals if not specified in this + list. + - Specify an empty list to remove all principals allowed to delegate. + type: list + elements: str + email: + description: + - Configures the user's email address. + - This is a record in AD and does not do anything to configure any email + servers or systems. + - This is the value set on the C(mail) LDAP attribute. + type: str + enabled: + description: + - C(yes) will enable the user account. + - C(no) will disable the account. + - The default when creating a new is C(yes) if I(password) is specified. If + no I(password) is specified then the user will not be enabled. + type: bool + firstname: + description: + - Configures the user's first name (given name). + - This is the value set on the C(givenName) LDAP attribute. + type: str + groups: + description: + - Specifies the group membership the user is added, removed, or set to. + - 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. + - 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 + suboptions: + add: + description: + - The groups to add the user to. + type: list + elements: str + remove: + description: + - The groups to remove the user from. + type: list + elements: str + set: + description: + - The only groups the user is a member of. + - This will clear out any existing groups if not in the specified list. + - Set to an empty list to clear all group membership of the user. + type: list + elements: str + missing_behaviour: + description: + - Controls what happens when a group specified by C(groups) is an + invalid group name. + - C(fail) is the default and will return an error any groups do not + exist. + - C(ignore) will ignore any groups that does not exist. + - C(warn) will display a warning for any groups that do not exist but + will continue without failing. + choices: + - fail + - ignore + - warn + default: fail + type: str + password: + description: + - Optionally set the user's password to this (plain text) value. + - To enable an account - I(enabled) - a password must already be + configured on the account, or you must provide a password here. + - Use the I(update_password) option to control how a password is checked + for idempotency. + type: str + password_expired: + description: + - C(yes) will require the user to change their password at next login. + - C(no) will clear the expired password flag. + - This is mutually exclusive with I(password_never_expires). + type: bool + password_never_expires: + description: + - C(yes) will set the password to never expire. + - C(no) will allow the password to expire. + - This is mutually exclusive with I(password_expired). + type: bool + postal_code: + description: + - Configures the user's postal code / zip code. + - This is the value set on the C(postalcode) LDAP attribute. + type: str + sam_account_name: + description: + - The C(sAMAccountName) value to set for the user. + - If omitted, the I(name) value is used when creating a new user. + type: str + spn: + description: + - Specifies the service principal name(s) for the account to add, remove or + set. + - This is the value set on the C(servicePrincipalName) LDAP attribute. + - To clear all service principal names, use I(set) with an empty list. + - See R(Setting list option values,ansible_collections.microsoft.ad.docsite.guide_list_values) + for more information on how to add/remove/set list options. + aliases: + - spns + type: dict + suboptions: + add: + description: + - The SPNs to add to C(servicePrincipalName). + type: list + elements: str + remove: + description: + - The SPNs to remove from C(servicePrincipalName). + type: list + elements: str + set: + description: + - The SPNs to set as the only values in C(servicePrincipalName). + - This will clear out any existing SPNs if not in the specified list. + - Set to an empty list to clear all SPNs on the AD object. + type: list + elements: str + state_province: + description: + - Configures the user's state. + - This is the value set on the C(state) LDAP attribute. + type: str + street: + description: + - Configures the user's street address. + - This is the value set on the C(streetaddress) LDAP attribute. + type: str + surname: + description: + - Configures the user's last name (surname). + - This is the value set on the C(sn) LDAP attribute. + aliases: + - lastname + type: str + update_password: + description: + - 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. + choices: + - always + - on_create + - when_changed + default: always + type: str + upn: + description: + - Configures the User Principal Name (UPN) for the account. + - This is not required, but is best practice to configure for modern + versions of Active Directory. + - The format is C(<username>@<domain>). + - This is the value set on the C(userPrincipalName) LDAP attribute. + type: str + user_cannot_change_password: + description: + - C(yes) will prevent the user from changing their password. + - C(no) will allow the user to change their password. + type: bool +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. +extends_documentation_fragment: +- microsoft.ad.ad_object +- ansible.builtin.action_common_attributes +attributes: + check_mode: + support: full + diff_mode: + support: full + platform: + platforms: + - windows +seealso: +- module: microsoft.ad.domain +- module: microsoft.ad.domain_controller +- module: microsoft.ad.group +- module: microsoft.ad.object +- module: microsoft.ad.object_info +- module: microsoft.ad.computer +- ref: Migration guide <ansible_collections.microsoft.ad.docsite.guide_migration.migrated_modules.win_domain_user> + description: This module replaces C(community.windows.win_domain_user). See the migration guide for details. +- module: community.windows.win_domain_user +author: +- Jordan Borean (@jborean93) +""" + +EXAMPLES = r""" +- name: Ensure user bob is present with address information + microsoft.ad.user: + name: bob + firstname: Bob + surname: Smith + company: BobCo + password: B0bP4ssw0rd + state: present + groups: + set: + - Domain Admins + street: 123 4th St. + city: Sometown + state_province: IN + postal_code: 12345 + country: US + attributes: + set: + telephoneNumber: 555-123456 + +- name: Ensure user bob is created and use custom credentials to create the user + microsoft.ad.user: + name: bob + firstname: Bob + surname: Smith + password: B0bP4ssw0rd + state: present + domain_username: DOMAIN\admin-account + domain_password: SomePas2w0rd + domain_server: domain@DOMAIN.COM + +- name: Ensure user bob is present in OU ou=test,dc=domain,dc=local + microsoft.ad.user: + name: bob + password: B0bP4ssw0rd + state: present + path: ou=test,dc=domain,dc=local + groups: + set: + - Domain Admins + - Domain Users + +- name: Ensure user bob is absent + microsoft.ad.user: + name: bob + state: absent + +- name: Ensure user has only these spn's defined + microsoft.ad.user: + name: liz.kenyon + spn: + set: + - MSSQLSvc/us99db-svr95:1433 + - MSSQLSvc/us99db-svr95.vmware.com:1433 + +- name: Ensure user has spn added + microsoft.ad.user: + name: liz.kenyon + spn: + add: + - MSSQLSvc/us99db-svr95:2433 + +- name: Ensure user is created with delegates and spn's defined + microsoft.ad.user: + name: shmemmmy + password: The3rubberducki33! + state: present + groups: + set: + - Domain Admins + - Domain Users + - Enterprise Admins + delegates: + set: + - CN=shenetworks,CN=Users,DC=ansible,DC=test + - CN=mk.ai,CN=Users,DC=ansible,DC=test + - CN=jessiedotjs,CN=Users,DC=ansible,DC=test + spn: + set: + - MSSQLSvc/us99db-svr95:2433 + +# The name option is the name of the AD object as seen in dsa.msc and not the +# sAMAccountName. For example, this will change the sAMAccountName of the user +# CN=existing_user,CN=Users,DC=domain,DC=com to 'new_sam_name'. +# E.g. This will change +- name: Change the user's sAMAccountName + microsoft.ad.user: + name: existing_user + sam_account_name: new_sam_name + state: present + +# This will rename the AD object that is specified by identity to 'new_name'. +# The identity value can be the object's GUID, SecurityIdentifier, or +# sAMAccountName. It is important to use the identity value when renaming or +# moving a user object to ensure the object is moved/renamed rather than a new +# one being created. +- name: Rename user LDAP name + microsoft.ad.user: + name: new_name + identity: '{{ user_obj.object_guid }}' + state: present + +# Like changing the name example above, the identity option is needed to ensure +# the existing user object specified is moved rather than a new one created at +# the path specified. +- name: Move user object to different OU + microsoft.ad.user: + name: user + path: OU=Admins,DC=domain,DC=com + identity: '{{ user_obj.sid }}' + state: present +""" + +RETURN = r""" +object_guid: + description: + - The C(objectGUID) of the AD object that was created, removed, or edited. + - If a new object was created in check mode, a GUID of 0s will be returned. + returned: always + type: str + sample: d84a141f-2b99-4f08-9da0-ed2d26864ba1 +distinguished_name: + description: + - The C(distinguishedName) of the AD object that was created, removed, or edited. + returned: always + type: str + sample: CN=TestUser,CN=Users,DC=domain,DC=test +sid: + description: + - The Security Identifier (SID) of the account managed. + - If a new user was created in check mode, the SID will be C(S-1-5-0000). + returned: always + type: str + sample: S-1-5-21-4151808797-3430561092-2843464588-1104 +""" diff --git a/ansible_collections/microsoft/ad/plugins/plugin_utils/__init__.py b/ansible_collections/microsoft/ad/plugins/plugin_utils/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/ansible_collections/microsoft/ad/plugins/plugin_utils/__init__.py diff --git a/ansible_collections/microsoft/ad/plugins/plugin_utils/_ldap/__init__.py b/ansible_collections/microsoft/ad/plugins/plugin_utils/_ldap/__init__.py new file mode 100644 index 000000000..3392b567c --- /dev/null +++ b/ansible_collections/microsoft/ad/plugins/plugin_utils/_ldap/__init__.py @@ -0,0 +1,181 @@ +# Copyright: (c) 2023, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +"""LDAP Helpers. + +This contains the code needed to perform LDAP operations for plugins in this +collection. It should only be used by plugins in this collection as the +interface is not final and could be subject to change. +""" + +# FOR INTERNAL COLLECTION USE ONLY +# The interfaces in this file are meant for use within this collection +# and may not remain stable to outside uses. Changes may be made in ANY release, even a bugfix release. +# See also: https://github.com/ansible/community/issues/539#issuecomment-780839686 +# Please open an issue if you have questions about this. + +import socket +import ssl +import typing as t + +try: + import sansldap + + LDAP_IMP_ERR = None +except Exception as e: + LDAP_IMP_ERR = e + +from ._authentication import ClientCertificate, NegotiateCredential, SimpleCredential +from ._certificate import load_client_certificate, load_trust_certificate +from ._lookup import lookup_ldap_server +from .client import Credential, SyncLDAPClient + +from ansible.errors import AnsibleError + + +def create_ldap_connection( + auth_protocol: t.Optional[str] = None, + ca_cert: t.Optional[str] = None, + cert_validation: t.Optional[str] = None, + certificate: t.Optional[str] = None, + certificate_key: t.Optional[str] = None, + certificate_password: t.Optional[str] = None, + connection_timeout: int = 5, + encrypt: bool = True, + password: t.Optional[str] = None, + port: t.Optional[int] = None, + server: t.Optional[str] = None, + tls_mode: t.Optional[str] = None, + username: t.Optional[str] = None, + **kwargs: t.Any, # Catches any other module option not needed here +) -> SyncLDAPClient: + """Creates the LDAP client. + + Creates the LDAP client using the options specified. The options here + correspond to the options defined in the ldap_connection doc fragment. + + Args: + auth_protocol: The authentication protocol to use, can be simple, + certificate, negotiate, kerberos, or ntlm. + ca_cert: The CA PEM path to use for certificate verification. + cert_validation: Controls the certificate verification behavior, can + be always, ignore, or ignore_hostname. + certificate: The client certificate PEM file (optionally key) to use for + certificate authentication. + certificate_key: The client certificate PEM key to use for certificate + authentication. + certificate_password: The password used to decrypt the client + certificate key if it is encrypted. + connection_timeout: The timeout in seconds to wait for connecting to a + host. + encrypt: The connection should be encrypted, whether through TLS or + with authentication encryption. + password: The password to authenticate with. + port: The LDAP port to use. + server: The LDAP server to connect to. + tls_mode: The TLS mode, can be ldaps or start_tls. + username: The username to authenticate with. + + Returns: + LDAPClient: The LDAP client. + """ + if LDAP_IMP_ERR: + raise ImportError(str(LDAP_IMP_ERR)) from LDAP_IMP_ERR + + if not server: + server, lookup_port = lookup_ldap_server() + if not port: + port = lookup_port + + if port is None: + port = 636 if tls_mode == "ldaps" else 389 + + if tls_mode is None and port == 636: + tls_mode = "ldaps" + + ssl_context: t.Optional[ssl.SSLContext] = None + if tls_mode: + ssl_context = ssl.create_default_context() + if ca_cert: + load_trust_certificate(ssl_context, ca_cert) + + if cert_validation == "ignore": + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.VerifyMode.CERT_NONE + + elif cert_validation == "ignore_hostname": + ssl_context.check_hostname = False + + if not auth_protocol: + auth_protocol = "certificate" if certificate and ssl_context else "negotiate" + + credential: t.Optional[Credential] = None + if auth_protocol == "simple": + if encrypt and not ssl_context: + raise ValueError("Cannot use simple auth with encryption.") + + credential = SimpleCredential(username, password) + + elif auth_protocol == "certificate": + if not ssl_context: + raise ValueError("TLS must be used for certificate authentication") + + if not certificate: + raise ValueError("A certificate must be specified for certificate authentication") + + load_client_certificate( + ssl_context, + certificate, + key=certificate_key, + password=certificate_password, + ) + + # If the client establishes the SSL/TLS-protected connection by means of connecting on a protected LDAPS port, + # then the connection is considered to be immediately authenticated (bound) as the credentials represented + # by the client certificate. An EXTERNAL bind is not allowed, and the bind will be rejected with an error. + # https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-adts/8e73932f-70cf-46d6-88b1-8d9f86235e81 + if tls_mode == "start_tls": + credential = ClientCertificate() + + else: + credential = NegotiateCredential( + username, + password, + protocol=auth_protocol, + encrypt=encrypt, + ) + + protocol = sansldap.LDAPClient() + tls_sock: t.Optional[ssl.SSLSocket] = None + try: + sock = socket.create_connection((server, port), timeout=connection_timeout) + except OSError as e: + raise AnsibleError(f"Failed to connect to {server}:{port}: {e}") from e + sock.settimeout(None) # Set socket into blocking mode + + if ssl_context and tls_mode == "ldaps": + tls_sock = sock = ssl_context.wrap_socket(sock, server_hostname=server) + + try: + if ssl_context and tls_mode == "start_tls": + SyncLDAPClient.start_tls(protocol, sock) + tls_sock = sock = ssl_context.wrap_socket(sock, server_hostname=server) + + client = SyncLDAPClient(server, protocol, sock) + except Exception: + try: + sock.shutdown(socket.SHUT_RDWR) + except OSError: + # The socket has already been shutdown for some other reason + pass + sock.close() + raise + + try: + if credential: + credential.authenticate(client, tls_sock=tls_sock) + + return client + except Exception: + client.close() + raise diff --git a/ansible_collections/microsoft/ad/plugins/plugin_utils/_ldap/_authentication.py b/ansible_collections/microsoft/ad/plugins/plugin_utils/_ldap/_authentication.py new file mode 100644 index 000000000..06a3e93c6 --- /dev/null +++ b/ansible_collections/microsoft/ad/plugins/plugin_utils/_ldap/_authentication.py @@ -0,0 +1,154 @@ +# Copyright: (c) 2023, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +# FOR INTERNAL COLLECTION USE ONLY +# The interfaces in this file are meant for use within this collection +# and may not remain stable to outside uses. Changes may be made in ANY release, even a bugfix release. +# See also: https://github.com/ansible/community/issues/539#issuecomment-780839686 +# Please open an issue if you have questions about this. + +import ssl +import struct +import typing as t + +try: + import sansldap +except Exception: + pass # Check is in __init__.py + +try: + import spnego + + SPNEGO_IMPORT_ERR = None +except Exception as e: + SPNEGO_IMPORT_ERR = e + +from ._certificate import get_tls_server_end_point_data +from .client import Credential, MessageEncryptor, SyncLDAPClient + + +class SimpleCredential(Credential): + def __init__(self, username: t.Optional[str] = None, password: t.Optional[str] = None) -> None: + self.username = username + self.password = password + + def authenticate( + self, + client: SyncLDAPClient, + *, + tls_sock: t.Optional[ssl.SSLSocket] = None, + ) -> None: + client.bind(self.username or "", sansldap.SimpleCredential(self.password or "")) + + +class ClientCertificate(Credential): + def authenticate( + self, + client: SyncLDAPClient, + *, + tls_sock: t.Optional[ssl.SSLSocket] = None, + ) -> None: + # The certs are provided in the TLS handshake, the SASL EXTERNAL mech + # just tells the server to check those for the bind. + client.bind("", sansldap.SaslCredential("EXTERNAL", b"")) + + +class NegotiateCredential(Credential): + def __init__( + self, + username: t.Optional[str] = None, + password: t.Optional[str] = None, + protocol: str = "negotiate", + encrypt: bool = True, + ) -> None: + if SPNEGO_IMPORT_ERR: + raise ImportError(str(SPNEGO_IMPORT_ERR)) from SPNEGO_IMPORT_ERR + + self.username = username + self.password = password + self.protocol = protocol + self.encrypt = encrypt + + def authenticate( + self, + client: SyncLDAPClient, + *, + tls_sock: t.Optional[ssl.SSLSocket] = None, + ) -> None: + context_req = spnego.ContextReq.mutual_auth + + if tls_sock or not self.encrypt: + # MS AD rejects any authentication that provides integrity or + # confidentiality if the connection is already protected by TLS. + # As the GSS-SPNEGO SASL relies on the context attributes to + # negotiate whether signing/encryption and Kerberos by default + # always uses the integrity attributes we need to tell it + # explicitly not to. The no_integrity flag does that for us. + needs_encryptor = False + context_req |= spnego.ContextReq.no_integrity + + else: + # When not operating over TLS request integrity and confidentiality + # so that we can encrypt the traffic. + needs_encryptor = True + context_req |= ( + spnego.ContextReq.sequence_detect | spnego.ContextReq.integrity | spnego.ContextReq.confidentiality + ) + + ctx = spnego.client( + username=self.username, + password=self.password, + hostname=client.server, + service="ldap", + context_req=context_req, + ) + + cbt = None + if tls_sock: + app_data = get_tls_server_end_point_data(tls_sock.getpeercert(True)) + if app_data: + cbt = spnego.channel_bindings.GssChannelBindings(application_data=app_data) + + in_token: t.Optional[bytes] = None + while not ctx.complete: + out_token = ctx.step(in_token=in_token, channel_bindings=cbt) + if not out_token: + break + + in_token = client.bind( + "", + sansldap.SaslCredential("GSS-SPNEGO", out_token), + success_only=ctx.complete, + ) + + if needs_encryptor: + client.register_encryptor(SpnegoEncryptor(ctx)) + + +class SpnegoEncryptor(MessageEncryptor): + def __init__( + self, + context: "spnego.ContextProxy", + ) -> None: + self.context = context + + def wrap( + self, + data: bytes, + ) -> bytes: + wrapped_data = self.context.wrap(data, encrypt=True).data + return len(wrapped_data).to_bytes(4, byteorder="big") + wrapped_data + + def unwrap( + self, + data: bytes, + ) -> t.Tuple[bytes, int]: + data_view = memoryview(data) + data_len = struct.unpack(">I", data_view[:4])[0] + data_view = data_view[4:] + if len(data_view) < data_len: + return b"", 0 + + data_view = data_view[:data_len] + + return self.context.unwrap(data_view.tobytes()).data, data_len + 4 diff --git a/ansible_collections/microsoft/ad/plugins/plugin_utils/_ldap/_certificate.py b/ansible_collections/microsoft/ad/plugins/plugin_utils/_ldap/_certificate.py new file mode 100644 index 000000000..ba0d9721d --- /dev/null +++ b/ansible_collections/microsoft/ad/plugins/plugin_utils/_ldap/_certificate.py @@ -0,0 +1,257 @@ +# Copyright: (c) 2023, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +# FOR INTERNAL COLLECTION USE ONLY +# The interfaces in this file are meant for use within this collection +# and may not remain stable to outside uses. Changes may be made in ANY release, even a bugfix release. +# See also: https://github.com/ansible/community/issues/539#issuecomment-780839686 +# Please open an issue if you have questions about this. + +import os.path +import secrets +import ssl +import tempfile +import typing as t + +# Cryptography is used for the TLS channel binding data and to convert PKCS8/12 +# certs to a PEM file required by OpenSSL. +try: + from cryptography import x509 + from cryptography.exceptions import UnsupportedAlgorithm + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.serialization import pkcs12 + + HAS_CRYPTOGRAPHY = True +except Exception: + HAS_CRYPTOGRAPHY = False + + +class _PrivateKey(t.Protocol): + def private_bytes( + self, + encoding: "serialization.Encoding", + format: "serialization.PrivateFormat", + encryption_algorithm: "serialization.KeySerializationEncryption", + ) -> bytes: + ... + + +def get_tls_server_end_point_data( + certificate: t.Optional[bytes], +) -> t.Optional[bytes]: + """Get the TLS channel binding data. + + Gets the TLS channel binding data for tls-server-end-point used in + Negotiate authentication. + + Args: + tls_sock: The SSLSocket to get the binding data for. + + Returns: + Optional[bytes]: The tls-server-end-point data used in the channel + bindings application data value. Can return None if the cryptography + isn't installed or it failed to get the certificate info. + """ + if not HAS_CRYPTOGRAPHY or not certificate: + return None + + cert = x509.load_der_x509_certificate(certificate) + try: + hash_algorithm = cert.signature_hash_algorithm + except UnsupportedAlgorithm: + hash_algorithm = None + + # If the cert signature algorithm is unknown, md5, or sha1 then use sha256 + # otherwise use the signature algorithm of the cert itself. + if not hash_algorithm or hash_algorithm.name in ["md5", "sha1"]: + digest = hashes.Hash(hashes.SHA256()) + else: + digest = hashes.Hash(hash_algorithm) + + digest.update(certificate) + return b"tls-server-end-point:" + digest.finalize() + + +def load_trust_certificate( + context: ssl.SSLContext, + certificate: str, +) -> None: + """Loads a certificate as a trusted CA. + + Loads the supplied certificate info into the SSLContext to trust. The + certificate can be in the following 3 forms: + + file path: The path to a PEM or DER encoded certificate + dir path: The path to a directory containing multiple CA PEM files in + a specific OpenSSL format (see c_rehash in OpenSSL). + string: A PEM encoded certificate as a string. + + Args: + context: The SSLContext to load the cert into. + certificate: The certificate info to trust. + """ + if os.path.exists(certificate): + if os.path.isdir(certificate): + # It is important the caller does not delete the dir because the + # lookup happens during the handshake and not now. + context.load_verify_locations(capath=certificate) + + else: + # cafile only works for PEM encoded certs, whereas cadata can + # load DER encoded certs. + with open(certificate, mode="rb") as fd: + data = fd.read() + + if data.startswith(b"-----BEGIN CERTIFICATE-----"): + context.load_verify_locations(cafile=certificate) + else: + context.load_verify_locations(cadata=data) + + else: + context.load_verify_locations(cadata=certificate) + + +def load_client_certificate( + context: ssl.SSLContext, + certificate: str, + key: t.Optional[str] = None, + password: t.Optional[str] = None, +) -> None: + """Loads a certificate to use with client authentication. + + Loads the supplied certificate that can be used for client authentication. + This function is a wrapper around load_cert_chain and offers the ability to + load a cert/key from a string or load a PFX formatted certificate with an + optional password. + + The certificate argument can either be a string of the PEM encoded + certificate and/or key. It can also be the path to a file of a PEM, DEF, or + PKCS12 (pfx) certificate and/or key. The key argument can be used to + specify the certificate key if it is not bundled with the certificate + argument. + + Args: + context: The SSLContext to load the cert info. + certificate: The certificate as a string or filepath. + key: The optional key as a string or filepath. + password: The password that is used to decrypt the key or pfx file. + """ + b_password = password.encode("utf-8", errors="surrogateescape") if password else None + + if os.path.isfile(certificate): + with open(certificate, mode="rb") as fd: + cert_data = fd.read() + + der_cert = _try_load_der_cert(cert_data) + if der_cert: + certificate = der_cert + + else: + pfx_cert = _try_load_pfx_cert(cert_data, b_password) + if pfx_cert: + certificate, key, b_password = pfx_cert + + if key and os.path.isfile(key): + with open(key, mode="rb") as fd: + key_data = fd.read() + + der_key = _try_load_der_key(key_data, b_password) + if der_key: + key, b_password = der_key + + # Specifying an empty b"" stops OpenSSL from prompting for the password if + # it was required. Instead it will just fail to load. + b_password = b_password or b"" + + # load_cert_chain does not expose a way to load a certificate/key from + # memory so a temporary directory is used in the cases where a pfx or + # string supplied cert is used. + if certificate.startswith("-----") or (key and key.startswith("-----")): + with tempfile.TemporaryDirectory() as tmpdir: + if certificate.startswith("-----"): + cert_path = os.path.join(tmpdir, "cert.pem") + with open(cert_path, mode="w") as fd: + fd.write(certificate) + + certificate = cert_path + + if key and key.startswith("-----"): + key_path = os.path.join(tmpdir, "key.pem") + with open(key_path, mode="w") as fd: + fd.write(key) + + key = key_path + + context.load_cert_chain(certfile=certificate, keyfile=key, password=b_password) + + return + + context.load_cert_chain(certfile=certificate, keyfile=key, password=b_password) + + +def _try_load_der_cert( + data: bytes, +) -> t.Optional[str]: + if not HAS_CRYPTOGRAPHY: + return None + + try: + cert = x509.load_der_x509_certificate(data) + except ValueError: + return None + else: + return cert.public_bytes(encoding=serialization.Encoding.PEM).decode() + + +def _try_load_der_key( + data: bytes, + password: t.Optional[bytes], +) -> t.Optional[t.Tuple[str, bytes]]: + if not HAS_CRYPTOGRAPHY: + return None + + try: + key = serialization.load_der_private_key(data, password=password) + except ValueError: + return None + else: + return _serialize_key_with_password(key, password) + + +def _try_load_pfx_cert( + data: bytes, + password: t.Optional[bytes], +) -> t.Optional[t.Tuple[str, str, bytes]]: + if not HAS_CRYPTOGRAPHY: + return None + + try: + pfx = pkcs12.load_key_and_certificates(data, password) + except ValueError: + pfx = None + + if not pfx or not pfx[0] or not pfx[1]: + return None + + password = password or secrets.token_bytes(32) + + certificate = pfx[1].public_bytes(encoding=serialization.Encoding.PEM).decode() + key, password = _serialize_key_with_password(pfx[0], password) + + return certificate, key, password + + +def _serialize_key_with_password( + key: _PrivateKey, + password: t.Optional[bytes], +) -> t.Tuple[str, bytes]: + password = password or secrets.token_bytes(32) + + return ( + key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.BestAvailableEncryption(password), + ).decode(), + password, + ) diff --git a/ansible_collections/microsoft/ad/plugins/plugin_utils/_ldap/_lookup.py b/ansible_collections/microsoft/ad/plugins/plugin_utils/_ldap/_lookup.py new file mode 100644 index 000000000..60a9abc1c --- /dev/null +++ b/ansible_collections/microsoft/ad/plugins/plugin_utils/_ldap/_lookup.py @@ -0,0 +1,93 @@ +# Copyright: (c) 2023, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +# FOR INTERNAL COLLECTION USE ONLY +# The interfaces in this file are meant for use within this collection +# and may not remain stable to outside uses. Changes may be made in ANY release, even a bugfix release. +# See also: https://github.com/ansible/community/issues/539#issuecomment-780839686 +# Please open an issue if you have questions about this. + +import typing as t + +# dnspython is used for dynamic server lookups +try: + import dns.resolver + + HAS_DNSPYTHON = True +except Exception: + HAS_DNSPYTHON = False + + +# krb5 is used to retrieve the default realm for dynamic server lookups. +try: + import krb5 + + HAS_KRB5 = True +except Exception: + HAS_KRB5 = False + + +class SrvRecord(t.NamedTuple): + target: str + port: int + weight: int + priority: int + + @classmethod + def lookup( + cls, + service: str, + proto: str, + name: str, + ) -> t.List["SrvRecord"]: + """Performs an SRV lookup. + + Args: + service: The SRV service. + proto: The SRV protocol. + name: The SRV name. + + Returns: + List[SrvRecord]: A list of records ordered by priority and weight. + """ + record = f"_{service}._{proto}.{name}" + + answers: t.List[SrvRecord] = [] + for answer in dns.resolver.resolve(record, "SRV"): + answers.append( + SrvRecord( + target=str(answer.target), + port=answer.port, + weight=answer.weight, + priority=answer.priority, + ) + ) + + # Sorts the array by lowest priority then highest weight. + return sorted(answers, key=lambda a: (a.priority, -a.weight)) + + +def lookup_ldap_server() -> t.Tuple[str, int]: + """Attempts to lookup LDAP server. + + Attempts to lookup LDAP server based on the current Kerberos host + configuration. Will them perform an SRV lookup for + '_ldap._tcp.dc._msdcs.{realm}' to get the LDAP server hostname nad port. + + Returns: + Tuple[str, int]: The LDAP hostname and port. + + Raises: + ImportError: Missing krb5 or dnspython. + krb5.Krb5Error: Kerberos configuration problem + dns.exception.DNSException: DNS lookup error. + """ + required_libs = [(HAS_KRB5, "krb5"), (HAS_DNSPYTHON, "dnspython")] + missing_libs = [lib for present, lib in required_libs if not present] + if missing_libs: + raise ImportError(f"Cannot lookup server without the python libraries {', '.join(missing_libs)}") + + ctx = krb5.init_context() + default_realm = krb5.get_default_realm(ctx).decode("utf-8") + answer = SrvRecord.lookup("ldap", "tcp", f"dc._msdcs.{default_realm}")[0] + return answer.target, answer.port diff --git a/ansible_collections/microsoft/ad/plugins/plugin_utils/_ldap/client.py b/ansible_collections/microsoft/ad/plugins/plugin_utils/_ldap/client.py new file mode 100644 index 000000000..88079b399 --- /dev/null +++ b/ansible_collections/microsoft/ad/plugins/plugin_utils/_ldap/client.py @@ -0,0 +1,438 @@ +# Copyright: (c) 2023, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +# FOR INTERNAL COLLECTION USE ONLY +# The interfaces in this file are meant for use within this collection +# and may not remain stable to outside uses. Changes may be made in ANY release, even a bugfix release. +# See also: https://github.com/ansible/community/issues/539#issuecomment-780839686 +# Please open an issue if you have questions about this. + +import dataclasses +import socket +import ssl +import threading +import typing as t + +try: + import sansldap +except Exception: + pass # Check is in __init__.py + + +MessageType = t.TypeVar("MessageType", bound="sansldap.LDAPMessage") + + +class Credential: + """Credential for LDAP client. + + Base object used to implementation authentication for the LDAP client. + """ + + def authenticate( + self, + client: "SyncLDAPClient", + *, + tls_sock: t.Optional[ssl.SSLSocket] = None, + ) -> None: + raise NotImplementedError() + + +class MessageEncryptor: + """Message encryptor for LDAP client. + + Base object used by the LDAP client to encrypt and decrypt messages. + """ + + def wrap( + self, + data: bytes, + ) -> bytes: + raise NotImplementedError() + + def unwrap( + self, + data: bytes, + ) -> t.Tuple[bytes, int]: + raise NotImplementedError() + + +class LDAPResultError(Exception): + def __init__( + self, + msg: str, + result: "sansldap.LDAPResult", + ) -> None: + super().__init__(msg) + self.result = result + + def __str__(self) -> str: + inner_msg = super().__str__() + msg = f"Received LDAPResult error {inner_msg} - {self.result.result_code.name}" + if self.result.matched_dn: + msg += f" - Matched DN {self.result.matched_dn}" + + if self.result.diagnostics_message: + msg += f" - {self.result.diagnostics_message}" + + return msg + + +class ResponseHandler(t.Generic[MessageType]): + def __init__( + self, + message_id: int, + message_types: t.Tuple[t.Type[MessageType], ...], + ) -> None: + self._message_id = message_id + self._message_types = message_types + self._condition = threading.Condition() + self._exp: t.Optional[Exception] = None + self._results: t.List[MessageType] = [] + + def __iter__(self) -> t.Iterator[MessageType]: + return self._iter_next() + + def append( + self, + value: t.Union[Exception, MessageType], + ) -> None: + with self._condition: + if isinstance(value, Exception): + self._exp = value + elif isinstance(value, self._message_types) and value.message_id == self._message_id: + self._results.append(value) + else: + return + + self._condition.notify_all() + + def _iter_next(self) -> t.Iterator[MessageType]: + idx = 0 + while True: + with self._condition: + if self._exp: + raise Exception(f"Exception from receiving task: {self._exp}") from self._exp + + if idx < len(self._results): + value = self._results[idx] + idx += 1 + yield value + + else: + self._condition.wait() + + +@dataclasses.dataclass(frozen=True) +class RootDSE: + default_naming_context: str + subschema_subentry: str + supported_controls: t.List[str] + + +class SyncLDAPClient: + def __init__( + self, + server: str, + protocol: "sansldap.LDAPClient", + sock: t.Union[socket.socket, ssl.SSLSocket], + ) -> None: + self.server = server + + self._protocol = protocol + self._sock = sock + self._response_handler: t.List[ResponseHandler] = [] + self._encryptor: t.Optional[MessageEncryptor] = None + self._reader_task = threading.Thread( + target=self._read_loop, + name=f"LDAP({server})", + ) + self._reader_task.start() + self._root_dse: t.Optional[RootDSE] = None + + @property + def root_dse(self) -> RootDSE: + if not self._root_dse: + default_naming_context = "" + subschema_subentry = "" + supported_controls: t.List[str] = [] + + for res in self._search_request( + base_object="", + scope=sansldap.SearchScope.BASE, + filter=sansldap.FilterPresent("objectClass"), + attributes=[ + "defaultNamingContext", + "subschemaSubentry", + "supportedControl", + ], + ): + if not isinstance(res, sansldap.SearchResultEntry): + continue + + for attr in res.attributes: + if attr.name == "defaultNamingContext": + default_naming_context = attr.values[0].decode("utf-8") + + elif attr.name == "subschemaSubentry": + subschema_subentry = attr.values[0].decode("utf-8") + + elif attr.name == "supportedControl": + supported_controls = [v.decode("utf-8") for v in attr.values] + + self._root_dse = RootDSE( + default_naming_context=default_naming_context, + subschema_subentry=subschema_subentry, + supported_controls=supported_controls, + ) + + return self._root_dse + + def __enter__(self) -> "SyncLDAPClient": + return self + + def __exit__(self, *args: t.Any, **kwargs: t.Any) -> None: + self.close() + + @classmethod + def start_tls( + cls, + protocol: "sansldap.LDAPClient", + sock: socket.socket, + ) -> None: + protocol.extended_request(sansldap.ExtendedOperations.LDAP_START_TLS) + data = protocol.data_to_send() + sock.sendall(data) + + done = False + while not done: + data = sock.recv(4096) + for msg in protocol.receive(data): + msg = t.cast(sansldap.ExtendedResponse, msg) + if msg.result.result_code != sansldap.LDAPResultCode.SUCCESS: + raise LDAPResultError("StartTLS failed", msg.result) + + done = True + break + + def bind( + self, + dn: str, + credential: "sansldap.AuthenticationCredential", + success_only: bool = True, + ) -> t.Optional[bytes]: + msg_id = self._protocol.bind(dn, credential) + response = self._write_and_wait_one(msg_id, sansldap.BindResponse) + + valid_codes = [sansldap.LDAPResultCode.SUCCESS] + if not success_only: + valid_codes.append(sansldap.LDAPResultCode.SASL_BIND_IN_PROGRESS) + + if response.result.result_code not in valid_codes: + raise LDAPResultError("bind failed", response.result) + + return response.server_sasl_creds + + def close(self) -> None: + try: + self._sock.shutdown(socket.SHUT_RDWR) + except OSError: + # The socket has already been shutdown for some other reason + pass + self._sock.close() + self._reader_task.join() + + def register_encryptor( + self, + encryptor: MessageEncryptor, + ) -> None: + self._encryptor = encryptor + + def search( + self, + filter: t.Union[str, "sansldap.LDAPFilter"], + attributes: t.List[str], + search_base: t.Optional[str] = None, + search_scope: t.Optional["sansldap.SearchScope"] = None, + ) -> t.Dict[str, t.Dict[str, t.List[bytes]]]: + if search_base is None: + search_base = self.root_dse.default_naming_context + + controls: t.List[sansldap.LDAPControl] = [] + pagination_size = 1024 + + if sansldap.PagedResultControl.control_type in self.root_dse.supported_controls: + controls.append(sansldap.PagedResultControl(False, pagination_size, b"")) + + search_kwargs: t.Dict[str, t.Any] = { + "base_object": search_base, + "scope": search_scope, + "filter": filter, + "attributes": attributes, + } + res: t.Dict[str, t.Dict[str, t.List[bytes]]] = {} + + while True: + for entry in self._search_request( + controls=controls, + **search_kwargs, + ): + if isinstance(entry, sansldap.SearchResultDone): + paginated_control = next( + iter(c for c in entry.controls if isinstance(c, sansldap.PagedResultControl)), + None, + ) + if paginated_control and paginated_control.cookie: + controls = [sansldap.PagedResultControl(False, pagination_size, paginated_control.cookie)] + else: + controls = [] + + elif isinstance(entry, sansldap.SearchResultEntry): + entry_res = res.setdefault(entry.object_name, {}) + for attr in entry.attributes: + entry_attr = entry_res.setdefault(attr.name, []) + entry_attr.extend(attr.values) + + # SearchResultReference is ignored for now. + + if not controls: + break + + return res + + def _search_request( + self, + base_object: t.Optional[str] = None, + scope: t.Optional[t.Union[int, "sansldap.SearchScope"]] = None, + dereferencing_policy: t.Optional[t.Union[int, "sansldap.DereferencingPolicy"]] = None, + size_limit: int = 0, + time_limit: int = 0, + types_only: bool = False, + filter: t.Optional[t.Union[str, "sansldap.LDAPFilter"]] = None, + attributes: t.Optional[t.List[str]] = None, + controls: t.Optional[t.List["sansldap.LDAPControl"]] = None, + ) -> t.Iterator[ + t.Union["sansldap.SearchResultReference", "sansldap.SearchResultEntry", "sansldap.SearchResultDone"] + ]: + ldap_filter: t.Optional[sansldap.LDAPFilter] = None + if isinstance(filter, sansldap.LDAPFilter): + ldap_filter = filter + elif filter: + ldap_filter = sansldap.LDAPFilter.from_string(filter) + + deref = dereferencing_policy if dereferencing_policy is not None else sansldap.DereferencingPolicy.NEVER + + msg_id = self._protocol.search_request( + base_object=base_object, + scope=scope if scope is not None else sansldap.SearchScope.SUBTREE, + dereferencing_policy=deref, + size_limit=size_limit, + time_limit=time_limit, + types_only=types_only, + filter=ldap_filter, + attributes=attributes, + controls=controls, + ) + + handler = self._register_response_handler( + msg_id, + sansldap.SearchResultEntry, + sansldap.SearchResultReference, + sansldap.SearchResultDone, + ) + try: + self._write_msg() + for res in handler: + yield res # type: ignore[misc] + + if isinstance(res, sansldap.SearchResultDone): + self._valid_result(res.result, "search request failed") + break + + finally: + self._unregister_response_handler(handler) + + def _read_loop(self) -> None: + data_buffer = bytearray() + while True: + try: + resp = self._sock.recv(4096) + if not resp: + raise Exception("LDAP connection has been shutdown") + + data_buffer.extend(resp) + + while data_buffer: + if self._encryptor: + dec_data, enc_len = self._encryptor.unwrap(data_buffer) + if enc_len == 0: + break + + data_buffer = data_buffer[enc_len:] + else: + dec_data = bytes(data_buffer) + data_buffer = bytearray() + + for msg in self._protocol.receive(dec_data): + for handler in self._response_handler: + handler.append(msg) + + except sansldap.ProtocolError as e: + if e.response: + self._sock.sendall(e.response) + + for handler in self._response_handler: + handler.append(e) + break + + except Exception as e: + for handler in self._response_handler: + handler.append(e) + break + + def _register_response_handler( + self, + msg_id: int, + *message_types: t.Type[MessageType], + ) -> ResponseHandler[MessageType]: + handler = ResponseHandler( + msg_id, + message_types, + ) + self._response_handler.append(handler) + + return handler + + def _valid_result( + self, + result: "sansldap.LDAPResult", + msg: str, + ) -> None: + if result.result_code != sansldap.LDAPResultCode.SUCCESS: + raise LDAPResultError(msg, result) + + def _unregister_response_handler( + self, + handler: ResponseHandler, + ) -> None: + self._response_handler.remove(handler) + + def _write_and_wait_one( + self, + msg_id: int, + message_type: t.Type[MessageType], + ) -> MessageType: + handler = self._register_response_handler(msg_id, message_type) + try: + self._write_msg() + + return handler.__iter__().__next__() + + finally: + self._unregister_response_handler(handler) + + def _write_msg(self) -> None: + data = self._protocol.data_to_send() + if self._encryptor: + data = self._encryptor.wrap(data) + + self._sock.sendall(data) diff --git a/ansible_collections/microsoft/ad/plugins/plugin_utils/_ldap/laps.py b/ansible_collections/microsoft/ad/plugins/plugin_utils/_ldap/laps.py new file mode 100644 index 000000000..d007c3d70 --- /dev/null +++ b/ansible_collections/microsoft/ad/plugins/plugin_utils/_ldap/laps.py @@ -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) + +# FOR INTERNAL COLLECTION USE ONLY +# The interfaces in this file are meant for use within this collection +# and may not remain stable to outside uses. Changes may be made in ANY release, even a bugfix release. +# See also: https://github.com/ansible/community/issues/539#issuecomment-780839686 +# Please open an issue if you have questions about this. + +import base64 +import typing as t + +try: + import dpapi_ng + + HAS_DPAPI_NG = True + DPAPI_NG_IMP_ERR = None +except Exception as e: + HAS_DPAPI_NG = False + DPAPI_NG_IMP_ERR = e + + +class LAPSDecryptor: + + def __init__( + self, + server: t.Optional[str] = None, + username: t.Optional[str] = None, + password: t.Optional[str] = None, + auth_protocol: t.Optional[str] = None, + **kwargs: t.Any, + ) -> None: + self._server = server + self._username = username + self._password = password + + if not auth_protocol or auth_protocol not in ["kerberos", "negotiate", "ntlm"]: + auth_protocol = "negotiate" + self._auth_protocol = auth_protocol + + self._cache = None + if HAS_DPAPI_NG: + self._cache = dpapi_ng.KeyCache() + + def decrypt( + self, + blob: bytes, + ) -> t.Dict[str, t.Any]: + update_timestamp_upper = int.from_bytes(blob[:4], byteorder="little") + update_timestamp_lower = int.from_bytes(blob[4:8], byteorder="little") + update_timestamp = (update_timestamp_upper << 32) | update_timestamp_lower + enc_buffer_size = int.from_bytes(blob[8:12], byteorder="little") + flags = int.from_bytes(blob[12:16], byteorder="little") + enc_buffer = blob[16:16 + enc_buffer_size] + + value = { + 'update_timestamp': update_timestamp, + 'flags': flags, + 'encrypted_value': base64.b64encode(enc_buffer).decode(), + } + + if HAS_DPAPI_NG: + try: + raw_dec_value = dpapi_ng.ncrypt_unprotect_secret( + enc_buffer, + server=self._server, + username=self._username, + password=self._password, + auth_protocol=self._auth_protocol, + cache=self._cache, + ) + value['value'] = raw_dec_value.decode("utf-16-le").rstrip("\00") + except Exception as e: + value['debug'] = f'Failed to decrypt value due to error - {type(e).__name__} {e}' + + else: + value['debug'] = 'Cannot decrypt value as the Python library dpapi-ng is not installed' + + return value diff --git a/ansible_collections/microsoft/ad/plugins/plugin_utils/_ldap/schema.py b/ansible_collections/microsoft/ad/plugins/plugin_utils/_ldap/schema.py new file mode 100644 index 000000000..e767ba0aa --- /dev/null +++ b/ansible_collections/microsoft/ad/plugins/plugin_utils/_ldap/schema.py @@ -0,0 +1,101 @@ +# Copyright: (c) 2023, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +# FOR INTERNAL COLLECTION USE ONLY +# The interfaces in this file are meant for use within this collection +# and may not remain stable to outside uses. Changes may be made in ANY release, even a bugfix release. +# See also: https://github.com/ansible/community/issues/539#issuecomment-780839686 +# Please open an issue if you have questions about this. + +import base64 +import typing as t + +try: + import sansldap +except Exception: + pass # Check is in __init__.py + +from ...filter.ldap_converters import as_guid, as_sid +from .client import SyncLDAPClient + + +class LDAPSchema: + def __init__( + self, + attribute_types: t.Dict[str, "sansldap.schema.AttributeTypeDescription"], + ) -> None: + self.attribute_types = attribute_types + + @classmethod + def load_schema(cls, client: SyncLDAPClient) -> "LDAPSchema": + root_dse = client.root_dse + attribute_types = list( + client.search( + filter=sansldap.FilterPresent("objectClass"), + attributes=["attributeTypes"], + search_base=root_dse.subschema_subentry, + search_scope=sansldap.SearchScope.BASE, + ).values() + )[0]["attributeTypes"] + + attribute_info: t.Dict[str, sansldap.schema.AttributeTypeDescription] = {} + for info in attribute_types: + type_description = sansldap.schema.AttributeTypeDescription.from_string(info.decode("utf-8")) + if type_description.names: + attribute_info[type_description.names[0].lower()] = type_description + + return LDAPSchema(attribute_info) + + def cast_object( + self, + attribute: str, + values: t.List[bytes], + ) -> t.Any: + info = self.attribute_types.get(attribute.lower(), None) + + caster: t.Callable[[bytes], t.Any] + if attribute == "objectSid": + caster = as_sid + + elif attribute == "objectGuid": + caster = as_guid + + elif not info or not info.syntax: + caster = _as_str + + elif info.syntax == "1.3.6.1.4.1.1466.115.121.1.7": + caster = _as_bool + + elif info.syntax in ["1.3.6.1.4.1.1466.115.121.1.27", "1.2.840.113556.1.4.906"]: + caster = _as_int + + elif info.syntax in ["1.3.6.1.4.1.1466.115.121.1.40", "1.2.840.113556.1.4.907", "OctetString"]: + caster = _as_bytes + + else: + caster = _as_str + + casted_values: t.List = [] + for v in values: + casted_values.append(caster(v)) + + if info and info.single_value: + return casted_values[0] if casted_values else None + else: + return casted_values + + +def _as_bool(value: bytes) -> bool: + return value == b"TRUE" + + +def _as_int(value: bytes) -> int: + return int(value) + + +def _as_bytes(value: bytes) -> str: + return base64.b64encode(value).decode() + + +def _as_str(value: bytes) -> str: + return value.decode("utf-8", errors="surrogateescape") diff --git a/ansible_collections/microsoft/ad/plugins/plugin_utils/_module_with_reboot.py b/ansible_collections/microsoft/ad/plugins/plugin_utils/_module_with_reboot.py new file mode 100644 index 000000000..ebc46ead6 --- /dev/null +++ b/ansible_collections/microsoft/ad/plugins/plugin_utils/_module_with_reboot.py @@ -0,0 +1,171 @@ +# Copyright (c) 2022 Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +"""Run module with reboot handler + +This code contains the skeleton needed for action plugins to run a module with +an automatic reboot handler. Right now it should only be used in this +collection as the interface is not final and count be subject to change. +""" + +# FOR INTERNAL COLLECTION USE ONLY +# The interfaces in this file are meant for use within this collection +# and may not remain stable to outside uses. Changes may be made in ANY release, even a bugfix release. +# See also: https://github.com/ansible/community/issues/539#issuecomment-780839686 +# Please open an issue if you have questions about this. + +import typing as t + +from ansible.plugins.action import ActionBase +from ansible.utils.display import Display +from ansible.utils.vars import merge_hash + +from ._reboot import reboot_host + +display = Display() + + +class ActionModuleWithReboot(ActionBase): + """Action Plugin with Auto Reboot. + + An action plugin that can be used to automatically reboot the host after + running a module. By default it checks for the return value reboot_required + and reboots the host if it is present. + + There are method that can be overloaded in the sub class to control the + behaviour like retries, whether to reboot, etc. + """ + + def _ad_should_reboot(self, result: t.Dict[str, t.Any]) -> bool: + """Check whether a reboot is to be done + + Called after the module is run and is used to check if the reboot + should be performed. The default check is to see if reboot_required + was returned by the module. + + Args: + result: The module result. + + Returns: + bool: Whether to do a reboot or not. + """ + return result.get("reboot_required", False) + + def _ad_should_rerun(self, result: t.Dict[str, t.Any]) -> bool: + """Check whether to rerun the module. + + Called after the reboot is completed and used to check whether the + module should be rerun. The default is to not rerun the module. + + Args: + result: The module result. + + Returns: + bool: Whether to rerun the module again. + """ + return False + + def _ad_process_result(self, result: t.Dict[str, t.Any]) -> t.Dict[str, t.Any]: + """Called at the end of the run. + + Called at the end of the plugin run for the sub class to edit the + result as needed. The default is for the result to be returned as is. + + Args: + result: The module result. + + Returns: + Dict[str, Any]: The final result to return. + """ + return result + + def _ad_set_rebooted( + self, + result: t.Dict[str, t.Any], + reboot_result: t.Dict[str, t.Any], + ) -> None: + """Called when a reboot is done. + + Called when the reboot has been performed. The sub class can use this + to edit the result or do additional checks as needed. The default is to + set the reboot_required return value to False if it is in the module + result. + + Args: + result: The module result. + reboot_result: The result from the reboot + """ + if "reboot_required" in result: + result["reboot_required"] = False + + def run( + self, + tmp: t.Optional[str] = None, + task_vars: t.Optional[t.Dict[str, t.Any]] = None, + ) -> t.Dict[str, t.Any]: + self._supports_check_mode = True + self._supports_async = True + + result = super().run(tmp=tmp, task_vars=task_vars) + del tmp + + wrap_async = self._task.async_val and not self._connection.has_native_async + reboot = self._task.args.get("reboot", False) + + if self._task.async_val > 0 and reboot: + return { + "failed": True, + "msg": "async is not supported for this task when reboot=true", + "changed": False, + } + + invocation = None + while True: + module_res = self._execute_module( + task_vars=task_vars, + wrap_async=wrap_async, + ) + invocation = module_res.pop("invocation", None) + + if reboot and self._ad_should_reboot(module_res): + previous_boot_time = module_res.pop("_previous_boot_time", None) + + if self._task.check_mode: + reboot_res = {} + else: + reboot_res = reboot_host( + self._task.action, + self._connection, + previous_boot_time=previous_boot_time, + ) + + if reboot_res.get("failed", False): + module_res = { + "changed": module_res.get("changed", False), + "failed": True, + "msg": "Failed to reboot after module returned reboot_required, see reboot_result and module_result for more details", + "reboot_result": reboot_res, + "module_result": module_res, + } + break + + # Regardless of the module result this needs to be True as a + # reboot happened. + module_res["changed"] = True + self._ad_set_rebooted(module_res, reboot_res) + + if self._ad_should_rerun(module_res) and not self._task.check_mode: + display.vv( + "Module result has indicated it should rerun after a reboot has occured, rerunning" + ) + continue + + break + + # Make sure the invocation details from the module are still present. + if invocation is not None: + module_res["invocation"] = invocation + + result = merge_hash(result, module_res) + + return self._ad_process_result(result) diff --git a/ansible_collections/microsoft/ad/plugins/plugin_utils/_reboot.py b/ansible_collections/microsoft/ad/plugins/plugin_utils/_reboot.py new file mode 100644 index 000000000..ae4fe0085 --- /dev/null +++ b/ansible_collections/microsoft/ad/plugins/plugin_utils/_reboot.py @@ -0,0 +1,656 @@ +# Copyright: (c) 2021, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +"""Reboot action for Windows hosts + +This contains the code to reboot a Windows host for use by other action plugins +in this collection. Right now it should only be used in this collection as the +interface is not final and count be subject to change. + +This is a straight copy from ansible.windows (with an embeded quote_pwsh) +""" + +# FOR INTERNAL COLLECTION USE ONLY +# The interfaces in this file are meant for use within this collection +# and may not remain stable to outside uses. Changes may be made in ANY release, even a bugfix release. +# See also: https://github.com/ansible/community/issues/539#issuecomment-780839686 +# Please open an issue if you have questions about this. + +import datetime +import json +import random +import re +import time +import traceback +import uuid +import typing as t + +from ansible.errors import AnsibleConnectionFailure, AnsibleError +from ansible.module_utils.common.text.converters import to_text +from ansible.plugins.connection import ConnectionBase +from ansible.utils.display import Display + + +# This is not ideal but the psrp connection plugin doesn't catch all these exceptions as an AnsibleConnectionFailure. +# Until we can guarantee we are using a version of psrp that handles all this we try to handle those issues. +try: + from requests.exceptions import ( + RequestException, + ) +except ImportError: + RequestException = AnsibleConnectionFailure + + +_LOGON_UI_KEY = ( + r"HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon\AutoLogonChecked" +) + +_DEFAULT_BOOT_TIME_COMMAND = ( + "(Get-CimInstance -ClassName Win32_OperatingSystem -Property LastBootUpTime)" + ".LastBootUpTime.ToFileTime()" +) + +# PowerShell has 5 characters it uses as a single quote, we need to double up on all of them. +# https://github.com/PowerShell/PowerShell/blob/b7cb335f03fe2992d0cbd61699de9d9aafa1d7c1/src/System.Management.Automation/engine/parser/CharTraits.cs#L265-L272 +# https://github.com/PowerShell/PowerShell/blob/b7cb335f03fe2992d0cbd61699de9d9aafa1d7c1/src/System.Management.Automation/engine/parser/CharTraits.cs#L18-L21 +_UNSAFE_PWSH = re.compile("(['\u2018\u2019\u201a\u201b])") + +T = t.TypeVar("T") + +display = Display() + + +class _ReturnResultException(Exception): + """Used to sneak results back to the return dict from an exception""" + + def __init__(self, msg, **result): + super().__init__(msg) + self.result = result + + +class _TestCommandFailure(Exception): + """Differentiates between a connection failure and just a command assertion failure during the reboot loop""" + + +def reboot_host( + task_action: str, + connection: ConnectionBase, + boot_time_command: str = _DEFAULT_BOOT_TIME_COMMAND, + connect_timeout: int = 5, + msg: str = "Reboot initiated by Ansible", + post_reboot_delay: int = 0, + pre_reboot_delay: int = 2, + reboot_timeout: int = 600, + test_command: t.Optional[str] = None, + previous_boot_time: t.Optional[str] = None, +) -> t.Dict[str, t.Any]: + """Reboot a Windows Host. + + Used by action plugins in ansible.windows to reboot a Windows host. It + takes in the connection plugin so it can run the commands on the targeted + host and monitor the reboot process. The return dict will have the + following keys set: + + changed: Whether a change occurred (reboot was done) + elapsed: Seconds elapsed between the reboot and it coming back online + failed: Whether a failure occurred + unreachable: Whether it failed to connect to the host on the first cmd + rebooted: Whether the host was rebooted + + When failed=True there may be more keys to give some information around + the failure like msg, exception. There are other keys that might be + returned as well but they are dependent on the failure that occurred. + + Verbosity levels used: + 2: Message when each reboot step is completed + 4: Connection plugin operations and their results + 5: Raw commands run and the results of those commands + Debug: Everything, very verbose + + Args: + task_action: The name of the action plugin that is running for logging. + connection: The connection plugin to run the reboot commands on. + boot_time_command: The command to run when getting the boot timeout. + connect_timeout: Override the connection timeout of the connection + plugin when polling the rebooted host. + msg: The message to display to interactive users when rebooting the + host. + post_reboot_delay: Seconds to wait after sending the reboot command + before checking to see if it has returned. + pre_reboot_delay: Seconds to wait when sending the reboot command. + reboot_timeout: Seconds to wait while polling for the host to come + back online. + test_command: Command to run when the host is back online and + determines the machine is ready for management. When not defined + the default command should wait until the reboot is complete and + all pre-login configuration has completed. + previous_boot_time: The previous boot time of the host, when set the + value is used as the previous boot time check and the code will + not initiate the reboot itself as it expects the host to be in a + reboot cycle itself. Used when a module has initiated the reboot. + + Returns: + (Dict[str, Any]): The return result as a dictionary. Use the 'failed' + key to determine if there was a failure or not. + """ + result: t.Dict[str, t.Any] = { + "changed": False, + "elapsed": 0, + "failed": False, + "unreachable": False, + "rebooted": False, + } + host_context = {"do_close_on_reset": True} + + send_reboot_command = previous_boot_time is None + + if send_reboot_command: + # Get current boot time. A lot of tasks that require a reboot leave the WSMan stack in a bad place. Will try to + # get the initial boot time 3 times before giving up. + try: + previous_boot_time = _do_until_success_or_retry_limit( + task_action, + connection, + host_context, + "pre-reboot boot time check", + 3, + _get_system_boot_time, + task_action, + connection, + boot_time_command, + ) + + except Exception as e: + # Report a the failure based on the last exception received. + if isinstance(e, _ReturnResultException): + result.update(e.result) + + if isinstance(e, AnsibleConnectionFailure): + result["unreachable"] = True + else: + result["failed"] = True + + result["msg"] = str(e) + result["exception"] = traceback.format_exc() + return result + + # Get the original connection_timeout option var so it can be reset after + original_connection_timeout: t.Optional[float] = None + try: + original_connection_timeout = connection.get_option("connection_timeout") + display.vvvv( + f"{task_action}: saving original connection_timeout of {original_connection_timeout}" + ) + except KeyError: + display.vvvv( + f"{task_action}: connection_timeout connection option has not been set" + ) + + # Initiate reboot + # This command may be wrapped in other shells or command making it hard to detect what shutdown.exe actually + # returned. We use this hackery to return a json that contains the stdout/stderr/rc as a structured object for our + # code to parse and detect if something went wrong. + reboot_command = """$ErrorActionPreference = 'Continue' + +if ($%s) { + Remove-Item -LiteralPath '%s' -Force -ErrorAction SilentlyContinue +} + +$stdout = $null +$stderr = . { shutdown.exe /r /t %s /c %s | Set-Variable stdout } 2>&1 | ForEach-Object ToString + +ConvertTo-Json -Compress -InputObject @{ + stdout = (@($stdout) -join "`n") + stderr = (@($stderr) -join "`n") + rc = $LASTEXITCODE +} +""" % ( + str(not test_command), + _LOGON_UI_KEY, + int(pre_reboot_delay), + _quote_pwsh(msg), + ) + + expected_test_result = ( + None # We cannot have an expected result if the command is user defined + ) + if not test_command: + # It turns out that LogonUI will create this registry key if it does not exist when it's about to show the + # logon prompt. Normally this is a volatile key but if someone has explicitly created it that might no longer + # be the case. We ensure it is not present on a reboot so we can wait until LogonUI creates it to determine + # the host is actually online and ready, e.g. no configurations/updates still to be applied. + # We echo a known successful statement to catch issues with powershell failing to start but the rc mysteriously + # being 0 causing it to consider a successful reboot too early (seen on ssh connections). + expected_test_result = f"success-{uuid.uuid4()}" + test_command = f"Get-Item -LiteralPath '{_LOGON_UI_KEY}' -ErrorAction Stop; '{expected_test_result}'" + + start = None + try: + if send_reboot_command: + _perform_reboot(task_action, connection, reboot_command) + + start = datetime.datetime.utcnow() + result["changed"] = True + result["rebooted"] = True + + if post_reboot_delay != 0: + display.vv( + f"{task_action}: waiting an additional {post_reboot_delay} seconds" + ) + time.sleep(post_reboot_delay) + + # Keep on trying to run the last boot time check until it is successful or the timeout is raised + display.vv(f"{task_action} validating reboot") + _do_until_success_or_timeout( + task_action, + connection, + host_context, + "last boot time check", + reboot_timeout, + _check_boot_time, + task_action, + connection, + host_context, + previous_boot_time, + boot_time_command, + connect_timeout, + ) + + # Reset the connection plugin connection timeout back to the original + if original_connection_timeout is not None: + _set_connection_timeout( + task_action, + connection, + host_context, + original_connection_timeout, + ) + + # Run test command until ti is successful or a timeout occurs + display.vv(f"{task_action} running post reboot test command") + _do_until_success_or_timeout( + task_action, + connection, + host_context, + "post-reboot test command", + reboot_timeout, + _run_test_command, + task_action, + connection, + test_command, + expected=expected_test_result, + ) + + display.vv(f"{task_action}: system successfully rebooted") + + except Exception as e: + if isinstance(e, _ReturnResultException): + result.update(e.result) + + result["failed"] = True + result["msg"] = str(e) + result["exception"] = traceback.format_exc() + + if start: + elapsed = datetime.datetime.utcnow() - start + result["elapsed"] = elapsed.seconds + + return result + + +def _check_boot_time( + task_action: str, + connection: ConnectionBase, + host_context: t.Dict[str, t.Any], + previous_boot_time: int, + boot_time_command: str, + timeout: int, +): + """Checks the system boot time has been changed or not""" + display.vvvv("%s: attempting to get system boot time" % task_action) + + # override connection timeout from defaults to custom value + if timeout: + _set_connection_timeout(task_action, connection, host_context, timeout) + + # try and get boot time + current_boot_time = _get_system_boot_time( + task_action, connection, boot_time_command + ) + if current_boot_time == previous_boot_time: + raise _TestCommandFailure("boot time has not changed") + + +def _do_until_success_or_retry_limit( + task_action: str, + connection: ConnectionBase, + host_context: t.Dict[str, t.Any], + action_desc: str, + retries: int, + func: t.Callable[..., T], + *args: t.Any, + **kwargs: t.Any, +) -> t.Optional[T]: + """Runs the function multiple times ignoring errors until the retry limit is hit""" + + def wait_condition(idx): + return idx < retries + + return _do_until_success_or_condition( + task_action, + connection, + host_context, + action_desc, + wait_condition, + func, + *args, + **kwargs, + ) + + +def _do_until_success_or_timeout( + task_action: str, + connection: ConnectionBase, + host_context: t.Dict[str, t.Any], + action_desc: str, + timeout: float, + func: t.Callable[..., T], + *args: t.Any, + **kwargs: t.Any, +) -> t.Optional[T]: + """Runs the function multiple times ignoring errors until a timeout occurs""" + max_end_time = datetime.datetime.utcnow() + datetime.timedelta(seconds=timeout) + + def wait_condition(idx): + return datetime.datetime.utcnow() < max_end_time + + try: + return _do_until_success_or_condition( + task_action, + connection, + host_context, + action_desc, + wait_condition, + func, + *args, + **kwargs, + ) + except Exception: + raise Exception( + "Timed out waiting for %s (timeout=%s)" % (action_desc, timeout) + ) + + +def _do_until_success_or_condition( + task_action: str, + connection: ConnectionBase, + host_context: t.Dict[str, t.Any], + action_desc: str, + condition: t.Callable[[int], bool], + func: t.Callable[..., T], + *args: t.Any, + **kwargs: t.Any, +) -> t.Optional[T]: + """Runs the function multiple times ignoring errors until the condition is false""" + fail_count = 0 + max_fail_sleep = 12 + reset_required = False + last_error = None + + while fail_count == 0 or condition(fail_count): + try: + if reset_required: + # Keep on trying the reset until it succeeds. + _reset_connection(task_action, connection, host_context) + reset_required = False + + else: + res = func(*args, **kwargs) + display.vvvvv("%s: %s success" % (task_action, action_desc)) + + return res + + except Exception as e: + last_error = e + + if not isinstance(e, _TestCommandFailure): + # The error may be due to a connection problem, just reset the connection just in case + reset_required = True + + # Use exponential backoff with a max timeout, plus a little bit of randomness + random_int = random.randint(0, 1000) / 1000 + fail_sleep = 2**fail_count + random_int + if fail_sleep > max_fail_sleep: + fail_sleep = max_fail_sleep + random_int + + try: + error = str(e).splitlines()[-1] + except IndexError: + error = str(e) + + display.vvvvv( + "{action}: {desc} fail {e_type} '{err}', retrying in {sleep:.4} seconds...\n{tcb}".format( + action=task_action, + desc=action_desc, + e_type=type(e).__name__, + err=error, + sleep=fail_sleep, + tcb=traceback.format_exc(), + ) + ) + + fail_count += 1 + time.sleep(fail_sleep) + + if last_error: + raise last_error + + return None + + +def _execute_command( + task_action: str, + connection: ConnectionBase, + command: str, +) -> t.Tuple[int, str, str]: + """Runs a command on the Windows host and returned the result""" + display.vvvvv(f"{task_action}: running command: {command}") + + # Need to wrap the command in our PowerShell encoded wrapper. This is done to align the command input to a + # common shell and to allow the psrp connection plugin to report the correct exit code without manually setting + # $LASTEXITCODE for just that plugin. + command = connection._shell._encode_script(command) + + try: + rc, stdout, stderr = connection.exec_command( + command, in_data=None, sudoable=False + ) + except RequestException as e: + # The psrp connection plugin should be doing this but until we can guarantee it does we just convert it here + # to ensure AnsibleConnectionFailure refers to actual connection errors. + raise AnsibleConnectionFailure(f"Failed to connect to the host: {e}") + + rc = rc or 0 + stdout = to_text(stdout, errors="surrogate_or_strict").strip() + stderr = to_text(stderr, errors="surrogate_or_strict").strip() + + display.vvvvv( + f"{task_action}: command result - rc: {rc}, stdout: {stdout}, stderr: {stderr}" + ) + + return rc, stdout, stderr + + +def _get_system_boot_time( + task_action: str, + connection: ConnectionBase, + boot_time_command: str, +) -> str: + """Gets a unique identifier to represent the boot time of the Windows host""" + display.vvvv(f"{task_action}: getting boot time") + rc, stdout, stderr = _execute_command(task_action, connection, boot_time_command) + + if rc != 0: + msg = f"{task_action}: failed to get host boot time info" + raise _ReturnResultException(msg, rc=rc, stdout=stdout, stderr=stderr) + + display.vvvv(f"{task_action}: last boot time: {stdout}") + return stdout + + +def _perform_reboot( + task_action: str, + connection: ConnectionBase, + reboot_command: str, + handle_abort: bool = True, +) -> None: + """Runs the reboot command""" + display.vv(f"{task_action}: rebooting server...") + + stdout = stderr = None + try: + rc, stdout, stderr = _execute_command(task_action, connection, reboot_command) + + except AnsibleConnectionFailure as e: + # If the connection is closed too quickly due to the system being shutdown, carry on + display.vvvv(f"{task_action}: AnsibleConnectionFailure caught and handled: {e}") + rc = 0 + + if stdout: + try: + reboot_result = json.loads(stdout) + except getattr(json.decoder, "JSONDecodeError", ValueError): + # While the reboot command should output json it may have failed for some other reason. We continue + # reporting with that output instead + pass + else: + stdout = reboot_result.get("stdout", stdout) + stderr = reboot_result.get("stderr", stderr) + rc = int(reboot_result.get("rc", rc)) + + # Test for "A system shutdown has already been scheduled. (1190)" and handle it gracefully + if handle_abort and (rc == 1190 or (rc != 0 and stderr and "(1190)" in stderr)): + display.warning("A scheduled reboot was pre-empted by Ansible.") + + # Try to abort (this may fail if it was already aborted) + rc, stdout, stderr = _execute_command( + task_action, connection, "shutdown.exe /a" + ) + display.vvvv( + f"{task_action}: result from trying to abort existing shutdown - rc: {rc}, stdout: {stdout}, stderr: {stderr}" + ) + + return _perform_reboot( + task_action, connection, reboot_command, handle_abort=False + ) + + if rc != 0: + msg = f"{task_action}: Reboot command failed" + raise _ReturnResultException(msg, rc=rc, stdout=stdout, stderr=stderr) + + +def _reset_connection( + task_action: str, + connection: ConnectionBase, + host_context: t.Dict[str, t.Any], + ignore_errors: bool = False, +) -> None: + """Resets the connection handling any errors""" + + def _wrap_conn_err(func, *args, **kwargs): + try: + func(*args, **kwargs) + + except (AnsibleError, RequestException) as e: + if ignore_errors: + return False + + raise AnsibleError(e) + + return True + + # While reset() should probably better handle this some connection plugins don't clear the existing connection on + # reset() leaving resources still in use on the target (WSMan shells). Instead we try to manually close the + # connection then call reset. If it fails once we want to skip closing to avoid a perpetual loop and just hope + # reset() brings us back into a good state. If it's successful we still want to try it again. + if host_context["do_close_on_reset"]: + display.vvvv(f"{task_action}: closing connection plugin") + try: + success = _wrap_conn_err(connection.close) + + except Exception: + host_context["do_close_on_reset"] = False + raise + + host_context["do_close_on_reset"] = success + + # For some connection plugins (ssh) reset actually does something more than close so we also class that + display.vvvv(f"{task_action}: resetting connection plugin") + try: + _wrap_conn_err(connection.reset) + + except AttributeError: + # Not all connection plugins have reset so we just ignore those, close should have done our job. + pass + + +def _run_test_command( + task_action: str, + connection: ConnectionBase, + command: str, + expected: t.Optional[str] = None, +) -> None: + """Runs the user specified test command until the host is able to run it properly""" + display.vvvv(f"{task_action}: attempting post-reboot test command") + + rc, stdout, stderr = _execute_command(task_action, connection, command) + + if rc != 0: + msg = f"{task_action}: Test command failed - rc: {rc}, stdout: {stdout}, stderr: {stderr}" + raise _TestCommandFailure(msg) + + if expected and expected not in stdout: + msg = f"{task_action}: Test command failed - '{expected}' was not in stdout: {stdout}" + raise _TestCommandFailure(msg) + + +def _set_connection_timeout( + task_action: str, + connection: ConnectionBase, + host_context: t.Dict[str, t.Any], + timeout: float, +) -> None: + """Sets the connection plugin connection_timeout option and resets the connection""" + try: + current_connection_timeout = connection.get_option("connection_timeout") + except KeyError: + # Not all connection plugins implement this, just ignore the setting if it doesn't work + return + + if timeout == current_connection_timeout: + return + + display.vvvv(f"{task_action}: setting connect_timeout {timeout}") + connection.set_option("connection_timeout", timeout) + + _reset_connection(task_action, connection, host_context, ignore_errors=True) + + +def _quote_pwsh(s: str) -> str: + """Quotes a value for PowerShell. + + Quotes a value to be safely used by a PowerShell expression. The input + string because something that is safely wrapped in single quotes. + + Args: + s: The string to quote. + + Returns: + (text_type): The quoted string value. + """ + # https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_quoting_rules?view=powershell-5.1 + if not s: + return "''" + + # We should always quote values in PowerShell as it has conflicting rules where strings can and can't be quoted. + # This means we quote the entire arg with single quotes and just double up on the single quote equivalent chars. + return "'{0}'".format(_UNSAFE_PWSH.sub("\\1\\1", s)) diff --git a/ansible_collections/microsoft/ad/requirements.txt b/ansible_collections/microsoft/ad/requirements.txt new file mode 100644 index 000000000..9d4f6c3cd --- /dev/null +++ b/ansible_collections/microsoft/ad/requirements.txt @@ -0,0 +1,20 @@ +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +# For LDAP TLS channel binding data calculation. 3.1 is when the backend arg +# was made optional. +cryptography >= 3.1 + +# For LDAP SRV lookups. 2.0.0 is when the new API used was introduced. +dnspython >= 2.0.0 + +# For krb5 default_realm lookups +krb5 + +# For LDAP negotiate authentication. 0.8.0 is needed for the no_integrity +# context_req used by LDAP. +pyspnego[kerberos] >= 0.8.0 + +# For LDAP protocol logic +sansldap diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/computer/aliases b/ansible_collections/microsoft/ad/tests/integration/targets/computer/aliases new file mode 100644 index 000000000..ccd8a25e8 --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/computer/aliases @@ -0,0 +1,2 @@ +windows +shippable/windows/group1 diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/computer/meta/main.yml b/ansible_collections/microsoft/ad/tests/integration/targets/computer/meta/main.yml new file mode 100644 index 000000000..4ce45dcfb --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/computer/meta/main.yml @@ -0,0 +1,2 @@ +dependencies: +- setup_domain diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/computer/tasks/main.yml b/ansible_collections/microsoft/ad/tests/integration/targets/computer/tasks/main.yml new file mode 100644 index 000000000..1e015c027 --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/computer/tasks/main.yml @@ -0,0 +1,14 @@ +- name: remove temp computer + computer: + name: MyComputer + state: absent + +- block: + - import_tasks: tests.yml + + always: + - name: remove temp computer + computer: + name: MyComputer + identity: '{{ object_identity | default(omit) }}' + state: absent 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 new file mode 100644 index 000000000..fb4eee366 --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/computer/tasks/tests.yml @@ -0,0 +1,425 @@ +- name: create computer - check + computer: + name: MyComputer + state: present + register: create_comp_check + check_mode: true + +- name: get result of create computer - check + object_info: + identity: '{{ create_comp_check.distinguished_name }}' + register: create_comp_check_actual + +- name: assert create computer - check + assert: + that: + - create_comp_check is changed + - create_comp_check.distinguished_name == 'CN=MyComputer,CN=Computers,' ~ setup_domain_info.output[0].defaultNamingContext + - create_comp_check.object_guid == '00000000-0000-0000-0000-000000000000' + - create_comp_check.sid == 'S-1-5-0000' + - create_comp_check_actual.objects == [] + +- name: create computer + computer: + name: MyComputer + state: present + register: create_comp + +- set_fact: + object_identity: '{{ create_comp.object_guid }}' + +- name: get result of create computer + object_info: + identity: '{{ object_identity }}' + properties: + - dnsHostName + - objectSid + - sAMAccountName + - userAccountControl + register: create_comp_actual + +- name: assert create computer + assert: + that: + - create_comp is changed + - create_comp_actual.objects | length == 1 + - create_comp.distinguished_name == 'CN=MyComputer,CN=Computers,' ~ setup_domain_info.output[0].defaultNamingContext + - create_comp.object_guid == create_comp_actual.objects[0].ObjectGUID + - create_comp.sid == create_comp_actual.objects[0].objectSid.Sid + - create_comp_actual.objects[0].DistinguishedName == create_comp.distinguished_name + - create_comp_actual.objects[0].Name == 'MyComputer' + - create_comp_actual.objects[0].dnsHostName == None + - create_comp_actual.objects[0].sAMAccountName == 'MyComputer$' + - create_comp_actual.objects[0].ObjectClass == 'computer' + - '"ADS_UF_ACCOUNTDISABLE" not in create_comp_actual.objects[0].userAccountControl_AnsibleFlags' + +- name: remove computer - check + computer: + name: MyComputer + state: absent + register: remove_comp_check + check_mode: true + +- name: get result of remove computer - check + object_info: + identity: '{{ object_identity }}' + register: remove_comp_check_actual + +- name: assert remove computer - check + assert: + that: + - remove_comp_check is changed + - remove_comp_check_actual.objects | length == 1 + +- name: remove computer + computer: + name: MyComputer + state: absent + register: remove_comp + +- name: get result of remove computer + object_info: + identity: '{{ object_identity }}' + register: remove_comp_actual + +- name: assert remove computer + assert: + that: + - remove_comp is changed + - remove_comp_actual.objects == [] + +- name: remove computer - idempotent + computer: + name: MyComputer + state: absent + register: remove_comp_again + +- name: assert remove computer - idempotent + assert: + that: + - not remove_comp_again is changed + +- name: create computer with custom options + computer: + name: MyComputer + state: present + delegates: + set: + - CN=krbtgt,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + - CN=Administrator,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + kerberos_encryption_types: + set: + - aes128 + - aes256 + location: Comp Location + dns_hostname: MyComputer.domain.com + enabled: false + managed_by: Domain Admins + sam_account_name: SamMyComputer + spn: + set: + - HTTP/MyComputer + trusted_for_delegation: true + upn: MyComputer@{{ domain_realm }} + path: CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + register: custom_comp + +- set_fact: + object_identity: '{{ custom_comp.object_guid }}' + +- name: get result of create computer with custom options + object_info: + identity: '{{ object_identity }}' + properties: + - dnsHostName + - location + - managedBy + - msDS-AllowedToActOnBehalfOfOtherIdentity + - msDS-SupportedEncryptionTypes + - objectSid + - sAMAccountName + - servicePrincipalName + - userAccountControl + - userPrincipalName + register: custom_comp_actual + +- name: convert delegate SDDL to human readable string + ansible.windows.win_powershell: + parameters: + SDDL: '{{ custom_comp_actual.objects[0]["msDS-AllowedToActOnBehalfOfOtherIdentity"] }}' + script: | + param($SDDL) + + $sd = New-Object -TypeName System.DirectoryServices.ActiveDirectorySecurity + $sd.SetSecurityDescriptorSddlForm($SDDL, 'All') + $sd.GetAccessRules($true, $false, [Type][System.Security.Principal.NTAccount] + ).IdentityReference.Value | ForEach-Object { + ($_ -split '\\', 2)[-1].ToLowerInvariant() + } | Sort-Object + register: custom_comp_delegates + +- name: assert create computer with custom options + assert: + that: + - custom_comp is changed + - custom_comp.distinguished_name == 'CN=MyComputer,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext + - custom_comp.object_guid == custom_comp_actual.objects[0].ObjectGUID + - custom_comp.sid == custom_comp_actual.objects[0].objectSid.Sid + - custom_comp_actual.objects[0].DistinguishedName == custom_comp.distinguished_name + - custom_comp_actual.objects[0].Name == 'MyComputer' + - custom_comp_actual.objects[0].dnsHostName == 'MyComputer.domain.com' + - custom_comp_actual.objects[0].location == 'Comp Location' + - custom_comp_actual.objects[0].managedBy == 'CN=Domain Admins,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext + - custom_comp_actual.objects[0]['msDS-SupportedEncryptionTypes'] == 24 + - 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].servicePrincipalName == 'HTTP/MyComputer' + - custom_comp_actual.objects[0].userPrincipalName == 'MyComputer@' ~ domain_realm + - '"ADS_UF_ACCOUNTDISABLE" in custom_comp_actual.objects[0].userAccountControl_AnsibleFlags' + - '"ADS_UF_TRUSTED_FOR_DELEGATION" in custom_comp_actual.objects[0].userAccountControl_AnsibleFlags' + - custom_comp_delegates.output == ["administrator", "krbtgt"] + +- name: change computer with custom options + computer: + name: MyComputer + path: CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + delegates: + set: + - CN=KRBTGT,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + dns_hostname: other.domain.com + kerberos_encryption_types: + set: + - aes256 + - rc4 + location: comp location + enabled: true + sam_account_name: MyComputer2$ + trusted_for_delegation: false + upn: mycomputer@{{ domain_realm }} + register: change_comp + +- name: get result of change computer with custom options + object_info: + identity: '{{ object_identity }}' + properties: + - dnsHostName + - location + - msDS-AllowedToActOnBehalfOfOtherIdentity + - msDS-SupportedEncryptionTypes + - sAMAccountName + - userAccountControl + - userPrincipalName + register: change_comp_actual + +- name: convert delegate SDDL to human readable string + ansible.windows.win_powershell: + parameters: + SDDL: '{{ change_comp_actual.objects[0]["msDS-AllowedToActOnBehalfOfOtherIdentity"] }}' + script: | + param($SDDL) + + $sd = New-Object -TypeName System.DirectoryServices.ActiveDirectorySecurity + $sd.SetSecurityDescriptorSddlForm($SDDL, 'All') + $sd.GetAccessRules($true, $false, [Type][System.Security.Principal.NTAccount] + ).IdentityReference.Value | ForEach-Object { + ($_ -split '\\', 2)[-1].ToLowerInvariant() + } | Sort-Object + register: change_comp_delegates + +- name: assert change computer with custom options + assert: + that: + - change_comp is changed + - change_comp_actual.objects[0].dnsHostName == 'other.domain.com' + - change_comp_actual.objects[0].location == 'comp location' + - change_comp_actual.objects[0]['msDS-SupportedEncryptionTypes'] == 20 + - change_comp_actual.objects[0]['msDS-SupportedEncryptionTypes_AnsibleFlags'] == ["RC4_HMAC", "AES256_CTS_HMAC_SHA1_96"] + - 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' + - '"ADS_UF_TRUSTED_FOR_DELEGATION" not in change_comp_actual.objects[0].userAccountControl_AnsibleFlags' + - change_comp_delegates.output == ["krbtgt"] + +- name: add and remove list options + computer: + name: MyComputer + path: CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + delegates: + add: + - CN=Administrator,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + remove: + - CN=KRBTGT,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + - CN=Missing,{{ setup_domain_info.output[0].defaultNamingContext }} + kerberos_encryption_types: + add: + - aes128 + - aes256 + remove: + - rc4 + register: add_remove_list + +- name: get result of add and remove list options + object_info: + identity: '{{ object_identity }}' + properties: + - msDS-AllowedToActOnBehalfOfOtherIdentity + - msDS-SupportedEncryptionTypes + register: add_remove_list_actual + +- name: convert delegate SDDL to human readable string + ansible.windows.win_powershell: + parameters: + SDDL: '{{ add_remove_list_actual.objects[0]["msDS-AllowedToActOnBehalfOfOtherIdentity"] }}' + script: | + param($SDDL) + + $sd = New-Object -TypeName System.DirectoryServices.ActiveDirectorySecurity + $sd.SetSecurityDescriptorSddlForm($SDDL, 'All') + $sd.GetAccessRules($true, $false, [Type][System.Security.Principal.NTAccount] + ).IdentityReference.Value | ForEach-Object { + ($_ -split '\\', 2)[-1].ToLowerInvariant() + } | Sort-Object + register: add_remove_list_delegation + +- name: assert add and remove list options + assert: + that: + - add_remove_list is changed + - add_remove_list_actual.objects[0]['msDS-SupportedEncryptionTypes'] == 24 + - add_remove_list_actual.objects[0]['msDS-SupportedEncryptionTypes_AnsibleFlags'] == ["AES128_CTS_HMAC_SHA1_96", "AES256_CTS_HMAC_SHA1_96"] + - add_remove_list_delegation.output == ["administrator"] + +- name: add and remove list options - idempotent + computer: + name: MyComputer + path: CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + delegates: + add: + - CN=Administrator,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + remove: + - CN=KRBTGT,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + - CN=Missing,{{ setup_domain_info.output[0].defaultNamingContext }} + kerberos_encryption_types: + add: + - aes128 + - aes256 + remove: + - rc4 + register: add_remove_list_again + +- name: assert add and remove list options - idempotent + assert: + that: + - not add_remove_list_again is changed + +- name: unset list options + computer: + name: MyComputer + path: CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + delegates: + set: [] + kerberos_encryption_types: + set: [] + register: unset_list_options + +- name: get result of unset list options + object_info: + identity: '{{ object_identity }}' + properties: + - msDS-AllowedToActOnBehalfOfOtherIdentity + - msDS-SupportedEncryptionTypes + register: unset_list_options_actual + +- name: assert unset list options + assert: + that: + - unset_list_options is changed + - unset_list_options_actual.objects[0]['msDS-AllowedToActOnBehalfOfOtherIdentity'] == None + - unset_list_options_actual.objects[0]['msDS-SupportedEncryptionTypes'] == 0 + - unset_list_options_actual.objects[0]['msDS-SupportedEncryptionTypes_AnsibleFlags'] == ["None"] + +- name: unset list options - idempotent + computer: + name: MyComputer + path: CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + delegates: + set: [] + kerberos_encryption_types: + set: [] + register: unset_list_options_again + +- name: assert unset list options - idempotent + assert: + that: + - not unset_list_options_again is changed + +- name: set spns + computer: + name: MyComputer + path: CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + spn: + set: + - HTTP/host + - HTTP/host.domain + - HTTP/host.domain:8080 + register: spn_set + +- name: get result of set spns + object_info: + identity: '{{ object_identity }}' + properties: + - servicePrincipalName + register: spn_set_actual + +- name: assert set spns + assert: + that: + - spn_set is changed + - spn_set_actual.objects[0].servicePrincipalName == ['HTTP/host.domain:8080', 'HTTP/host.domain', 'HTTP/host'] + +- name: remove spns + computer: + name: MyComputer + path: CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + spn: + remove: + - HTTP/fake + - HTTP/Host.domain + register: spn_remove + +- name: get result of remove spns + object_info: + identity: '{{ object_identity }}' + properties: + - servicePrincipalName + register: spn_remove_actual + +- name: assert remove spns + assert: + that: + - spn_remove is changed + - spn_remove_actual.objects[0].servicePrincipalName == ['HTTP/host.domain:8080', 'HTTP/host'] + +- name: add spns + computer: + name: MyComputer + path: CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + spns: + add: + - HTTP/Host.domain:8080 + - HTTP/fake + register: spn_add + +- name: get result of add spns + object_info: + identity: '{{ object_identity }}' + properties: + - servicePrincipalName + register: spn_add_actual + +- name: assert add spns + assert: + that: + - spn_add is changed + - spn_add_actual.objects[0].servicePrincipalName == ['HTTP/fake', 'HTTP/host.domain:8080', 'HTTP/host'] diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/debug_ldap_client/aliases b/ansible_collections/microsoft/ad/tests/integration/targets/debug_ldap_client/aliases new file mode 100644 index 000000000..ccd8a25e8 --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/debug_ldap_client/aliases @@ -0,0 +1,2 @@ +windows +shippable/windows/group1 diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/debug_ldap_client/tasks/main.yml b/ansible_collections/microsoft/ad/tests/integration/targets/debug_ldap_client/tasks/main.yml new file mode 100644 index 000000000..59a6d6d90 --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/debug_ldap_client/tasks/main.yml @@ -0,0 +1,17 @@ +# It's hard to ensure there's a common test environment with the same module +# versions so just test that the plugin runs and has the base level return +# values. + +- name: make sure module can run + debug_ldap_client: + register: res + +- name: assert return values + assert: + that: + - res.dns is defined + - res.kerberos is defined + - res.packages.dnspython is defined + - res.packages.krb5 is defined + - res.packages.pyspnego is defined + - res.packages.sansldap is defined diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/domain/aliases b/ansible_collections/microsoft/ad/tests/integration/targets/domain/aliases new file mode 100644 index 000000000..ccd8a25e8 --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/domain/aliases @@ -0,0 +1,2 @@ +windows +shippable/windows/group1 diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/domain/meta/main.yml b/ansible_collections/microsoft/ad/tests/integration/targets/domain/meta/main.yml new file mode 100644 index 000000000..025c08e43 --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/domain/meta/main.yml @@ -0,0 +1,4 @@ +dependencies: +- name: setup_domain + vars: + run_domain_test: true diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/domain/tasks/main.yml b/ansible_collections/microsoft/ad/tests/integration/targets/domain/tasks/main.yml new file mode 100644 index 000000000..c74df4f8d --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/domain/tasks/main.yml @@ -0,0 +1,66 @@ +- name: create domain - idempotent + domain: + dns_domain_name: '{{ domain_realm }}' + safe_mode_password: '{{ domain_password }}' + register: domain_again + +- name: assert create domain - idempotent + assert: + that: + - not domain_again is changed + - domain_again.reboot_required == False + +- name: fail when reboot and async is used + domain: + dns_domain_name: '{{ domain_realm }}' + safe_mode_password: '{{ domain_password }}' + reboot: true + async: 60 + register: fail_reboot_async + failed_when: + - fail_reboot_async.msg != "async is not supported for this task when reboot=true" + +- name: fail when domain_netbios_name is greater than 15 character + domain: + dns_domain_name: '{{ domain_realm }}' + safe_mode_password: '{{ domain_password }}' + domain_netbios_name: AReallyLongName1 + register: fail_long_netbios_name + failed_when: + - fail_long_netbios_name.msg != "The parameter 'domain_netbios_name' should not exceed 15 characters in length" + +- name: get OS version + ansible.windows.win_powershell: + script: | + $Ansible.Changed = $false + + $osVersion = [System.Environment]::OSVersion.Version + '{0}.{1}' -f $osVersion.Major, $osVersion.Minor + register: os_version + +- set_fact: + known_modes: + '6.2': Win2003, Win2008, Win2008R2, Win2012, Default + '6.3': Win2008, Win2008R2, Win2012, Win2012R2, Default + default: Win2008, Win2008R2, Win2012, Win2012R2, WinThreshold, Default + +- set_fact: + expected_modes: '{{ known_modes[os_version.output[0]] | default(known_modes["default"]) }}' + +- name: fail when domain_mode is invalid + domain: + dns_domain_name: '{{ domain_realm }}' + safe_mode_password: '{{ domain_password }}' + domain_mode: fail + register: fail_domain_mode + failed_when: + - 'fail_domain_mode.msg != "The parameter ''domain_mode'' does not accept ''fail'', please use one of: " ~ expected_modes' + +- name: fail when forest_mode is invalid + domain: + dns_domain_name: '{{ domain_realm }}' + safe_mode_password: '{{ domain_password }}' + forest_mode: fail + register: fail_forest_mode + failed_when: + - 'fail_forest_mode.msg != "The parameter ''forest_mode'' does not accept ''fail'', please use one of: " ~ expected_modes' diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/domain/tasks/test.yml b/ansible_collections/microsoft/ad/tests/integration/targets/domain/tasks/test.yml new file mode 100644 index 000000000..d443d2556 --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/domain/tasks/test.yml @@ -0,0 +1,95 @@ +- name: setup domain with no prereqs - check mode + domain: + dns_domain_name: '{{ domain_realm }}' + safe_mode_password: '{{ domain_password }}' + reboot: true + check_mode: true + register: domain_no_prereqs_check + +- name: assert setup domain with no prereqs - check mode + assert: + that: + - domain_no_prereqs_check is changed + - domain_no_prereqs_check.reboot_required == False + +- name: install feature pre-requisites + ansible.windows.win_feature: + name: + - AD-Domain-Services + - RSAT-ADDS + state: present + +- name: setup domain without reboot - check mode + domain: + dns_domain_name: '{{ domain_realm }}' + safe_mode_password: '{{ domain_password }}' + check_mode: true + register: domain_check_no_reboot + +- name: assert setup domain without reboot - check mode + assert: + that: + - domain_check_no_reboot is changed + - domain_check_no_reboot.reboot_required == True + +# While not needed it puts the host in a state where it needs to reboot +# before it can create the domain. This is testing an edge case. +- name: rename host + ansible.windows.win_hostname: + name: ansible-ad-test + +- name: setup domain without reboot after reboot pending - check mode + domain: + dns_domain_name: '{{ domain_realm }}' + safe_mode_password: '{{ domain_password }}' + check_mode: true + register: domain_check_no_reboot_pending + +- name: assert setup domain without reboot after reboot pending - check mode + assert: + that: + - domain_check_no_reboot_pending is changed + - domain_check_no_reboot_pending.reboot_required == True + +- name: setup domain with reboot - check mode + domain: + dns_domain_name: '{{ domain_realm }}' + safe_mode_password: '{{ domain_password }}' + reboot: true + check_mode: true + register: domain_check_reboot + +- name: assert setup domain with reboot - check mode + assert: + that: + - domain_check_reboot is changed + - domain_check_reboot.reboot_required == False + +- name: setup domain without reboot + domain: + dns_domain_name: '{{ domain_realm }}' + safe_mode_password: '{{ domain_password }}' + register: domain_no_reboot + ignore_errors: true + +- name: assert setup domain without reboot + assert: + that: + - not domain_no_reboot is changed + - domain_no_reboot is failed + - '"Failed to install ADDSForest" in domain_no_reboot.msg' + - '"A reboot is required" in domain_no_reboot.msg' + - domain_no_reboot.reboot_required == True + +- name: setup domain with reboot + domain: + dns_domain_name: '{{ domain_realm }}' + safe_mode_password: '{{ domain_password }}' + reboot: true + register: domain_reboot + +- name: assert setup domain with reboot + assert: + that: + - domain_reboot is changed + - domain_reboot.reboot_required == False diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/README.md b/ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/README.md new file mode 100644 index 000000000..0628d5335 --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/README.md @@ -0,0 +1,34 @@ +# microsoft.ad.domain_controller tests + +As this cannot be run in CI this is a brief guide on how to run these tests locally. +Run the following: + +```bash +vagrant up + +ansible-playbook setup.yml +``` + +It is a good idea to create a snapshot of both hosts before running the tests. +This allows you to reset the host back to a blank starting state if the tests need to be rerun. +To create a snaphost do the following: + +```bash +virsh snapshot-create-as --domain "domain_controller_DC" --name "pretest" +virsh snapshot-create-as --domain "domain_controller_TEST" --name "pretest" +``` + +To restore these snapshots run the following: + +```bash +virsh snapshot-revert --domain "domain_controller_DC" --snapshotname "pretest" --running +virsh snapshot-revert --domain "domain_controller_TEST" --snapshotname "pretest" --running +``` + +Once you are ready to run the tests run the following: + +```bash +ansible-playbook test.yml +``` + +Run `vagrant destroy` to remove the test VMs. diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/Vagrantfile b/ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/Vagrantfile new file mode 100644 index 000000000..5341185c3 --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/Vagrantfile @@ -0,0 +1,27 @@ +# -*- mode: ruby -*- +# vi: set ft=ruby : + +require 'yaml' + +inventory = YAML.load_file('inventory.yml') + +Vagrant.configure("2") do |config| + inventory['all']['children'].each do |group,details| + details['hosts'].each do |server,host_details| + config.vm.define server do |srv| + srv.vm.box = host_details['vagrant_box'] + srv.vm.hostname = server + srv.vm.network :private_network, + :ip => host_details['ansible_host'], + :libvirt__network_name => 'microsoft.ad', + :libvirt__domain_name => inventory['all']['vars']['domain_realm'] + + srv.vm.provider :libvirt do |l| + l.memory = 4096 + l.cpus = 2 + end + end + end + end +end + diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/aliases b/ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/aliases new file mode 100644 index 000000000..435ff207d --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/aliases @@ -0,0 +1,2 @@ +windows +unsupported # can never run in CI, see README.md diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/ansible.cfg b/ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/ansible.cfg new file mode 100644 index 000000000..3a986973e --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/ansible.cfg @@ -0,0 +1,3 @@ +[defaults] +inventory = inventory.yml +retry_files_enabled = False diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/inventory.yml b/ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/inventory.yml new file mode 100644 index 000000000..3daa807df --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/inventory.yml @@ -0,0 +1,21 @@ +all: + children: + windows: + hosts: + DC: + ansible_host: 192.168.11.10 + vagrant_box: jborean93/WindowsServer2022 + TEST: + ansible_host: 192.168.11.11 + vagrant_box: jborean93/WindowsServer2022 + vars: + ansible_port: 5985 + ansible_connection: psrp + + vars: + ansible_user: vagrant + ansible_password: vagrant + domain_username: vagrant-domain + domain_user_upn: '{{ domain_username }}@{{ domain_realm | upper }}' + domain_password: VagrantPass1 + domain_realm: ad.test diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/setup.yml b/ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/setup.yml new file mode 100644 index 000000000..5fcc09dfd --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/setup.yml @@ -0,0 +1,82 @@ +- name: create domain controller + hosts: DC + gather_facts: no + + tasks: + - name: get network connection names + ansible.windows.win_powershell: + parameters: + IPAddress: '{{ ansible_host }}' + script: | + param ($IPAddress) + + $Ansible.Changed = $false + + Get-CimInstance -ClassName Win32_NetworkAdapter -Filter "Netenabled='True'" | + ForEach-Object -Process { + $config = Get-CimInstance -ClassName Win32_NetworkAdapterConfiguration -Filter "Index='$($_.Index)'" + if ($config.IPAddress -contains $IPAddress) { + $_.NetConnectionID + } + } + register: connection_name + + - name: set the DNS for the internal adapters to localhost + ansible.windows.win_dns_client: + adapter_names: + - '{{ connection_name.output[0] }}' + dns_servers: + - 127.0.0.1 + + - name: ensure domain exists and DC is promoted as a domain controller + microsoft.ad.domain: + dns_domain_name: '{{ domain_realm }}' + safe_mode_password: '{{ domain_password }}' + reboot: true + + - ansible.windows.win_feature: + name: RSAT-AD-PowerShell + state: present + + - name: create domain username + microsoft.ad.user: + name: '{{ domain_username }}' + upn: '{{ domain_user_upn }}' + description: '{{ domain_username }} Domain Account' + password: '{{ domain_password }}' + password_never_expires: yes + update_password: when_changed + groups: + add: + - Domain Admins + state: present + +- name: setup test host + hosts: TEST + gather_facts: no + + tasks: + - name: get network connection names + ansible.windows.win_powershell: + parameters: + IPAddress: '{{ ansible_host }}' + script: | + param ($IPAddress) + + $Ansible.Changed = $false + + Get-CimInstance -ClassName Win32_NetworkAdapter -Filter "Netenabled='True'" | + ForEach-Object -Process { + $config = Get-CimInstance -ClassName Win32_NetworkAdapterConfiguration -Filter "Index='$($_.Index)'" + if ($config.IPAddress -contains $IPAddress) { + $_.NetConnectionID + } + } + register: connection_name + + - name: set DNS for the private adapter to point to the DC + ansible.windows.win_dns_client: + adapter_names: + - '{{ connection_name.output[0] }}' + dns_servers: + - '{{ hostvars["DC"]["ansible_host"] }}' diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/tasks/main.yml b/ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/tasks/main.yml new file mode 100644 index 000000000..b1288d093 --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/tasks/main.yml @@ -0,0 +1,232 @@ +- set_fact: + get_role_script: | + $Ansible.Changed = $false + Get-CimInstance -ClassName Win32_ComputerSystem -Property Domain, DomainRole, PartOfDomain | + Select-Object -Property @{ + N = 'Domain' + E = { + if ($_.PartOfDomain) { + $_.Domain + } + else { + $null + } + } + }, @{ + N = 'DomainRole' + E = { + switch ($_.DomainRole) { + 0 { "StandaloneWorkstation" } + 1 { "MemberWorkstation" } + 2 { "StandaloneServer" } + 3 { "MemberServer" } + 4 { "BackupDC" } + 5 { "PrimaryDC" } + } + } + }, @{ + N = 'HostName' + E = { $env:COMPUTERNAME } + } + +- name: test no change when not a DC + domain_controller: + dns_domain_name: '{{ domain_realm }}' + domain_admin_user: '{{ domain_user_upn }}' + domain_admin_password: '{{ domain_password }}' + local_admin_password: '{{ domain_password }}' + state: member_server + reboot: true + register: not_dc_no_change + +- name: assert test no change when not a DC + assert: + that: + - not not_dc_no_change is changed + - not_dc_no_change.reboot_required == False + +- name: promote to DC - check mode + domain_controller: + dns_domain_name: '{{ domain_realm }}' + domain_admin_user: '{{ domain_user_upn }}' + domain_admin_password: '{{ domain_password }}' + safe_mode_password: '{{ domain_password }}' + state: domain_controller + reboot: true + register: to_dc_check + check_mode: true + +- name: get result of promote to DC - check mode + ansible.windows.win_powershell: + script: '{{ get_role_script }}' + register: to_dc_check_actual + +- name: assert promote to DC - check mode + assert: + that: + - to_dc_check is changed + - to_dc_check_actual.output[0]["Domain"] == None + - to_dc_check_actual.output[0]["DomainRole"] == "StandaloneServer" + +- name: change hostname to have a pending change before promotion + ansible.windows.win_hostname: + name: FOO + +- name: promote to DC with pending reboot + domain_controller: + dns_domain_name: '{{ domain_realm }}' + domain_admin_user: '{{ domain_user_upn }}' + domain_admin_password: '{{ domain_password }}' + safe_mode_password: '{{ domain_password }}' + state: domain_controller + reboot: true + register: to_dc + +- name: get result of promote to DC with pending reboot + ansible.windows.win_powershell: + script: '{{ get_role_script }}' + register: to_dc_actual + +- name: assert promote to DC with pending reboot + assert: + that: + - to_dc is changed + - to_dc.reboot_required == False + - to_dc_actual.output[0]["Domain"] == domain_realm + - to_dc_actual.output[0]["DomainRole"] == "BackupDC" + - to_dc_actual.output[0]["HostName"] == "FOO" + +- name: promote to DC - idempotent + domain_controller: + dns_domain_name: '{{ domain_realm }}' + domain_admin_user: '{{ domain_user_upn }}' + domain_admin_password: '{{ domain_password }}' + safe_mode_password: '{{ domain_password }}' + state: domain_controller + reboot: true + register: to_dc_again + +- name: assert promote to DC - idempotent + assert: + that: + - not to_dc_again is changed + - to_dc_again.reboot_required == False + +# The following operations will run with the domain admin account now that the +# host is joined to the domain +- name: change connection user and password to domain account + set_fact: + ansible_user: '{{ domain_user_upn }}' + ansible_password: '{{ domain_password }}' + +- name: fail to change domain of DC + domain_controller: + dns_domain_name: bogus.local + domain_admin_user: '{{ domain_user_upn }}' + domain_admin_password: '{{ domain_password }}' + safe_mode_password: '{{ domain_password }}' + state: domain_controller + reboot: true + register: change_domain_fail + failed_when: + - change_domain_fail.msg != "The host FOO is a domain controller for the domain " ~ domain_realm ~ "; changing DC domains is not implemented" + +- name: fail with invalid username format + domain_controller: + dns_domain_name: '{{ domain_realm }}' + domain_admin_user: '{{ domain_username }}' # Must be a UPN or Netbios Name + domain_admin_password: '{{ domain_password }}' + safe_mode_password: '{{ domain_password }}' + state: domain_controller + reboot: true + register: invalid_user_fail + failed_when: + - invalid_user_fail.msg != "domain_admin_user must be in domain\\user or user@domain.com format" + +- name: set DC as member server - check mode + domain_controller: + dns_domain_name: '{{ domain_realm }}' + domain_admin_user: '{{ domain_user_upn }}' + domain_admin_password: '{{ domain_password }}' + local_admin_password: '{{ domain_password }}' + state: member_server + reboot: true + register: member_server_check + check_mode: true + +- name: get result of set DC as member server - check mode + ansible.windows.win_powershell: + script: '{{ get_role_script }}' + register: member_server_check_actual + +- name: assert set DC as member server - check mode + assert: + that: + - member_server_check is changed + - member_server_check.reboot_required == False + - member_server_check_actual.output[0]["Domain"] == domain_realm + - member_server_check_actual.output[0]["DomainRole"] == "BackupDC" + +- name: set DC as member server + domain_controller: + dns_domain_name: '{{ domain_realm }}' + domain_admin_user: '{{ domain_user_upn }}' + domain_admin_password: '{{ domain_password }}' + local_admin_password: '{{ domain_password }}' + state: member_server + reboot: true + register: member_server + +- name: get result of set DC as member server + ansible.windows.win_powershell: + script: '{{ get_role_script }}' + register: member_server_actual + +- name: assert set DC as member server + assert: + that: + - member_server is changed + - member_server.reboot_required == False + - member_server_actual.output[0]["Domain"] == domain_realm + - member_server_actual.output[0]["DomainRole"] == "MemberServer" + +- name: set DC as member server - idempotent + domain_controller: + dns_domain_name: '{{ domain_realm }}' + domain_admin_user: '{{ domain_user_upn }}' + domain_admin_password: '{{ domain_password }}' + local_admin_password: '{{ domain_password }}' + state: member_server + reboot: true + register: member_server_again + +- name: assert set DC as member server - idempotent + assert: + that: + - not member_server_again is changed + - member_server_again.reboot_required == False + +# Promote it once more to a DC to test an edge case where Ansible is unable to +# connect back until a reboot has occurred. +- name: promote to DC again + domain_controller: + dns_domain_name: '{{ domain_realm }}' + domain_admin_user: '{{ domain_user_upn }}' + domain_admin_password: '{{ domain_password }}' + safe_mode_password: '{{ domain_password }}' + state: domain_controller + reboot: true + register: to_dc_manual_reboot + +- name: get result of promote to DC with manual reboot + ansible.windows.win_powershell: + script: '{{ get_role_script }}' + register: to_dc_manual_reboot_actual + +- name: assert promote to DC with manual reboot + assert: + that: + - to_dc_manual_reboot is changed + - to_dc_manual_reboot.reboot_required == False + - to_dc_manual_reboot_actual.output[0]["Domain"] == domain_realm + - to_dc_manual_reboot_actual.output[0]["DomainRole"] == "BackupDC" diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/test.yml b/ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/test.yml new file mode 100644 index 000000000..69034d12f --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/test.yml @@ -0,0 +1,30 @@ +- name: ensure time is in sync + hosts: windows + gather_facts: no + tasks: + - name: get current host datetime + command: date +%s + changed_when: False + delegate_to: localhost + run_once: True + register: local_time + + - name: set datetime on Windows + ansible.windows.win_powershell: + parameters: + SecondsSinceEpoch: '{{ local_time.stdout | trim }}' + script: | + param($SecondsSinceEpoch) + + $utc = [System.DateTimeKind]::Utc + $epoch = New-Object -TypeName System.DateTime -ArgumentList 1970, 1, 1, 0, 0, 0, 0, $utc + $date = $epoch.AddSeconds($SecondsSinceEpoch) + + Set-Date -Date $date + +- name: run microsoft.ad.domain_controller tests + hosts: TEST + gather_facts: no + + tasks: + - import_tasks: tasks/main.yml diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/group/aliases b/ansible_collections/microsoft/ad/tests/integration/targets/group/aliases new file mode 100644 index 000000000..ccd8a25e8 --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/group/aliases @@ -0,0 +1,2 @@ +windows +shippable/windows/group1 diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/group/meta/main.yml b/ansible_collections/microsoft/ad/tests/integration/targets/group/meta/main.yml new file mode 100644 index 000000000..4ce45dcfb --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/group/meta/main.yml @@ -0,0 +1,2 @@ +dependencies: +- setup_domain diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/group/tasks/main.yml b/ansible_collections/microsoft/ad/tests/integration/targets/group/tasks/main.yml new file mode 100644 index 000000000..b5291e36c --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/group/tasks/main.yml @@ -0,0 +1,14 @@ +- name: remove temp group + group: + name: MyGroup + state: absent + +- block: + - import_tasks: tests.yml + + always: + - name: remove temp group + group: + name: MyGroup + identity: '{{ object_identity | default(omit) }}' + state: absent 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 new file mode 100644 index 000000000..bdb1b95b7 --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/group/tasks/tests.yml @@ -0,0 +1,516 @@ +- name: fail to create group without scope + group: + name: MyGroup + state: present + register: fail_no_scope + failed_when: fail_no_scope.msg != "scope must be set when state=present and the group does not exist" + +- name: create group - check + group: + name: MyGroup + state: present + scope: global + register: create_group_check + check_mode: true + +- name: get result of create group - check + object_info: + identity: '{{ create_group_check.distinguished_name }}' + register: create_group_check_actual + +- name: assert create group - check + assert: + that: + - create_group_check is changed + - create_group_check.distinguished_name == 'CN=MyGroup,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext + - create_group_check.object_guid == '00000000-0000-0000-0000-000000000000' + - create_group_check.sid == 'S-1-5-0000' + - create_group_check_actual.objects == [] + +- name: create group + group: + name: MyGroup + state: present + scope: global + register: create_group + +- set_fact: + object_identity: '{{ create_group.object_guid }}' + +- name: get result of create group + object_info: + identity: '{{ object_identity }}' + properties: + - groupType + - objectSid + register: create_group_actual + +- name: assert create group + assert: + that: + - create_group is changed + - create_group.distinguished_name == 'CN=MyGroup,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext + - create_group_actual.objects | length == 1 + - create_group.object_guid == create_group_actual.objects[0].ObjectGUID + - create_group.sid == create_group_actual.objects[0].objectSid.Sid + - create_group_actual.objects[0].groupType_AnsibleFlags == ["GROUP_TYPE_ACCOUNT_GROUP", "GROUP_TYPE_SECURITY_ENABLED"] + +- name: create group - idempotent + group: + name: MyGroup + state: present + scope: global + register: create_group_again + +- name: assert create group - idempotent + assert: + that: + - not create_group_again is changed + +- name: create ou to store group members + ou: + name: MyOU + state: present + register: ou_info + +- block: + - name: create test users + user: + name: My User {{ item }} + sam_account_name: my_user_{{ item }} + upn: user_{{ item }}@{{ domain_realm }} + state: present + path: '{{ ou_info.distinguished_name }}' + register: test_users + loop: + - 1 + - 2 + - 3 + - 4 + + - name: fail to find members to add to a group + group: + name: MyGroup + state: present + members: + add: + - my_user_1 + - fake-user + - my_user_2 + - another-user + register: fail_invalid_members + failed_when: 'fail_invalid_members.msg != "Failed to find the following ad objects for group members: ''fake-user'', ''another-user''"' + + - name: add members to a group - check + group: + name: MyGroup + state: present + members: + add: + - my_user_1 + - '{{ test_users.results[2].sid }}' + register: add_member_check + check_mode: true + + - name: get result of add members to a group - check + object_info: + identity: '{{ object_identity }}' + properties: + - member + register: add_member_check_actual + + - name: assert add members to a group - check + assert: + that: + - add_member_check is changed + - add_member_check_actual.objects[0].member == None + + - name: add members to a group + group: + name: MyGroup + state: present + members: + add: + - my_user_1 + - '{{ test_users.results[2].sid }}' + register: add_member + + - name: get result of add members to a group + object_info: + identity: '{{ object_identity }}' + properties: + - member + register: add_member_actual + + - name: assert add members to a group + assert: + that: + - add_member is changed + - add_member_actual.objects[0].member | length == 2 + - 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 + + - name: add members to a group - idempotent + group: + name: MyGroup + state: present + members: + add: + - user_1@{{ domain_realm }} + - '{{ test_users.results[2].object_guid }}' + register: add_member_again + + - name: assert add members to a group - idempotent + assert: + that: + - not add_member_again is changed + + - name: remove member from a group + group: + name: MyGroup + state: present + members: + remove: + - '{{ test_users.results[0].distinguished_name | upper }}' + - my_user_2 + register: remove_member + + - name: get result of remove member from a group + object_info: + identity: '{{ object_identity }}' + properties: + - member + register: remove_member_actual + + - name: assert remove member from a group + assert: + that: + - remove_member is changed + - remove_member_actual.objects[0].member == test_users.results[2].distinguished_name + + - name: remove member from a group - idempotent + group: + name: MyGroup + state: present + members: + remove: + - '{{ test_users.results[0].object_guid }}' + register: remove_member_again + + - name: assert remove member from a group - idempotent + assert: + that: + - not remove_member_again is changed + + - name: add and remove members from a group + group: + name: MyGroup + state: present + members: + add: + - my_user_1 + - user_2@{{ domain_realm }} + remove: + - my_user_3 + - my_user_4 + register: add_remove_member + + - name: get result of add and remove members from a group + object_info: + identity: '{{ object_identity }}' + properties: + - member + register: add_remove_member_actual + + - name: assert add and remove members from a group + assert: + that: + - add_remove_member is changed + - add_remove_member_actual.objects[0].member | length == 2 + - 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 + + - name: set members + group: + name: MyGroup + state: present + members: + set: + - my_user_1 + - my_user_3 + register: set_member + + - name: get result of set members + object_info: + identity: '{{ object_identity }}' + properties: + - member + register: set_member_actual + + - name: assert set members + assert: + that: + - set_member is changed + - set_member_actual.objects[0].member | length == 2 + - test_users.results[0].distinguished_name in set_member_actual.objects[0].member + - test_users.results[2].distinguished_name in set_member_actual.objects[0].member + + - name: set members - idempotent + group: + name: MyGroup + state: present + members: + set: + - My_user_1 + - '{{ test_users.results[2].sid }}' + register: set_member_again + + - name: assert set members - idempotent + assert: + that: + - not set_member_again is changed + + - name: unset all members + group: + name: MyGroup + state: present + members: + set: [] + register: unset_member + + - name: get result of unset all members + object_info: + identity: '{{ object_identity }}' + properties: + - member + register: unset_member_actual + + - name: assert unset all members + assert: + that: + - unset_member is changed + - unset_member_actual.objects[0].member == None + + - name: unset all members - idempotent + group: + name: MyGroup + state: present + members: + set: [] + register: unset_member_again + + - name: assert unset all members - idempotent + assert: + that: + - not unset_member_again is changed + + - name: remove group - check + group: + name: MyGroup + state: absent + register: remove_group_check + check_mode: true + + - name: get result of remove group - check + object_info: + identity: '{{ object_identity }}' + register: remove_group_check_actual + + - name: assert remove group - check + assert: + that: + - remove_group_check is changed + - remove_group_check_actual.objects | length == 1 + + - name: remove group + group: + name: MyGroup + state: absent + register: remove_group + + - name: get result of remove group + object_info: + identity: '{{ object_identity }}' + register: remove_group_actual + + - name: assert remove group + assert: + that: + - remove_group is changed + - remove_group_actual.objects == [] + + - name: remove group - idempotent + group: + name: MyGroup + state: absent + register: remove_group_again + + - name: assert remove group - idempotent + assert: + that: + - not remove_group_again is changed + + - name: fail to create group with invalid members + group: + name: MyGroup + state: present + scope: domainlocal + members: + add: + - my_user_1 + - fake-user + - my_user_2 + - another-user + register: fail_invalid_members + failed_when: 'fail_invalid_members.msg != "Failed to find the following ad objects for group members: ''fake-user'', ''another-user''"' + + - name: create group with custom options + group: + name: MyGroup + state: present + path: '{{ ou_info.distinguished_name }}' + display_name: My Display Name + description: My Description + scope: domainlocal + category: distribution + homepage: www.ansible.com + managed_by: Domain Admins + members: + add: + - my_user_1 + - '{{ test_users.results[1].object_guid }}' + set: + - '{{ test_users.results[2].sid }}' + sam_account_name: GroupSAM + register: group_custom + + - set_fact: + object_identity: '{{ group_custom.object_guid }}' + + - name: get result of create group with custom options + object_info: + identity: '{{ object_identity }}' + properties: + - Description + - DisplayName + - groupType + - managedBy + - member + - objectSid + - wWWHomePage + - sAMAccountName + register: group_custom_actual + + - name: assert create group with custom options + assert: + that: + - group_custom is changed + - group_custom.distinguished_name == "CN=MyGroup," ~ ou_info.distinguished_name + - group_custom_actual.objects[0].DistinguishedName == group_custom.distinguished_name + - group_custom_actual.objects[0].ObjectGUID == group_custom.object_guid + - group_custom_actual.objects[0].objectSid.Sid == group_custom.sid + - group_custom_actual.objects[0].Description == 'My Description' + - group_custom_actual.objects[0].DisplayName == 'My Display Name' + - group_custom_actual.objects[0].Name == 'MyGroup' + - group_custom_actual.objects[0].groupType_AnsibleFlags == ["GROUP_TYPE_RESOURCE_GROUP"] + - group_custom_actual.objects[0].managedBy == "CN=Domain Admins,CN=Users," ~ setup_domain_info.output[0].defaultNamingContext + - group_custom_actual.objects[0].member | length == 3 + - test_users.results[0].distinguished_name in group_custom_actual.objects[0].member + - test_users.results[1].distinguished_name in group_custom_actual.objects[0].member + - test_users.results[2].distinguished_name in group_custom_actual.objects[0].member + - group_custom_actual.objects[0].sAMAccountName == "GroupSAM" + - group_custom_actual.objects[0].wWWHomePage == "www.ansible.com" + + - name: create group with custom options - idempotent + group: + name: MyGroup + state: present + path: '{{ ou_info.distinguished_name }}' + display_name: My Display Name + description: My Description + scope: domainlocal + category: distribution + homepage: www.ansible.com + managed_by: CN=Domain Admins,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + members: + add: + - my_user_1 + - '{{ test_users.results[1].object_guid }}' + - '{{ test_users.results[2].sid }}' + sam_account_name: GroupSAM + register: group_custom_again + + - name: assert create group with custom options - idempotent + assert: + that: + - group_custom_again is not changed + + - name: edit group + group: + name: MyGroup + state: present + path: '{{ ou_info.distinguished_name }}' + display_name: my display name + description: '' + homepage: www.Ansible.com + members: + set: [] + sam_account_name: MyGroup + register: group_edit + + - name: get result of edit group + object_info: + identity: '{{ object_identity }}' + properties: + - Description + - DisplayName + - groupType + - member + - objectSid + - wWWHomePage + - sAMAccountName + register: group_edit_actual + + - name: assert edit group + assert: + that: + - group_edit is changed + - group_edit_actual.objects[0].DistinguishedName == group_edit.distinguished_name + - group_edit_actual.objects[0].ObjectGUID == group_edit.object_guid + - group_edit_actual.objects[0].objectSid.Sid == group_edit.sid + - group_edit_actual.objects[0].Description == None + - group_edit_actual.objects[0].DisplayName == 'my display name' + - group_edit_actual.objects[0].Name == 'MyGroup' + - group_edit_actual.objects[0].groupType_AnsibleFlags == ["GROUP_TYPE_RESOURCE_GROUP"] + - group_edit_actual.objects[0].member == None + - group_edit_actual.objects[0].sAMAccountName == "MyGroup" + - group_edit_actual.objects[0].wWWHomePage == "www.Ansible.com" + + - name: edit group scope and category + group: + name: MyGroup + state: present + path: '{{ ou_info.distinguished_name }}' + scope: universal + category: security + register: edit_scope + + - name: get result of edit group scope and category + object_info: + identity: '{{ object_identity }}' + properties: + - groupType + register: edit_scope_actual + + - name: assert edit group scope and category + assert: + that: + - edit_scope is changed + - edit_scope_actual.objects[0].groupType_AnsibleFlags == ["GROUP_TYPE_UNIVERSAL_GROUP", "GROUP_TYPE_SECURITY_ENABLED"] + + always: + - name: remove test ou + ou: + name: MyOU + state: absent + identity: '{{ ou_info.object_guid }}' diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/aliases b/ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/aliases new file mode 100644 index 000000000..b92c22d5a --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/aliases @@ -0,0 +1,3 @@ +windows +shippable/windows/group1 +needs/target/setup_domain diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/main.yml b/ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/main.yml new file mode 100644 index 000000000..14b4ae33a --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/main.yml @@ -0,0 +1,25 @@ +- name: run microsoft.ad.ldap tests + hosts: windows + gather_facts: false + + tasks: + - name: setup domain controller + import_role: + name: ../../setup_domain + + - name: setup domain certificates + import_role: + name: setup_certificate + vars: + dc_name: '{{ setup_domain_info.output[0].dnsHostName }}' + cert_path: /tmp/microsoft.ad-{{ inventory_hostname }} + + - name: run tests + import_role: + name: test + vars: + ldap_server: '{{ ansible_host | default(inventory_hostname) }}' + ldap_user: ldap-test@{{ domain_realm }} + ldap_pass: '{{ domain_password }}' + ldap_user_cert: /tmp/microsoft.ad-{{ inventory_hostname }}/user.pfx + ldap_ca_cert: /tmp/microsoft.ad-{{ inventory_hostname }}/ca.pem diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/roles/setup_certificate/files/generate_cert.sh b/ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/roles/setup_certificate/files/generate_cert.sh new file mode 100644 index 000000000..365757307 --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/roles/setup_certificate/files/generate_cert.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash + +set -o pipefail -eux + +TARGET="${1}" +PASSWORD="${2}" + +generate () { + NAME="${1}" + SUBJECT="${2}" + KEY="${3}" + CA_NAME="${4}" + CA_OPTIONS=("-CA" "${CA_NAME}.pem" "-CAkey" "${CA_NAME}.key" "-CAcreateserial") + + cat > openssl.conf << EOL +distinguished_name = req_distinguished_name + +[req_distinguished_name] + +[req] +basicConstraints = CA:FALSE +keyUsage = digitalSignature,keyEncipherment +extendedKeyUsage = serverAuth +subjectAltName = DNS:${SUBJECT} +EOL + + echo "Generating ${NAME} signed cert" + openssl req \ + -new \ + "-${KEY}" \ + -subj "/CN=${SUBJECT}" \ + -newkey rsa:2048 \ + -keyout "${NAME}.key" \ + -out "${NAME}.csr" \ + -config openssl.conf \ + -reqexts req \ + -passin pass:"${PASSWORD}" \ + -passout pass:"${PASSWORD}" + + openssl x509 \ + -req \ + -in "${NAME}.csr" \ + "-${KEY}" \ + -out "${NAME}.pem" \ + -days 365 \ + -extfile openssl.conf \ + -extensions req \ + -passin pass:"${PASSWORD}" \ + "${CA_OPTIONS[@]}" + + # PBE-SHA1-3DES/nomac is used for compatibility with Server 2016 and older + openssl pkcs12 \ + -export \ + -out "${NAME}.pfx" \ + -inkey "${NAME}.key" \ + -in "${NAME}.pem" \ + -keypbe PBE-SHA1-3DES \ + -certpbe PBE-SHA1-3DES \ + -nomac \ + -passin pass:"${PASSWORD}" \ + -passout pass:"${PASSWORD}" + + rm openssl.conf +} + +echo "Generating CA certificate" +openssl genrsa \ + -aes256 \ + -out ca.key \ + -passout pass:"${PASSWORD}" + +openssl req \ + -new \ + -x509 \ + -days 365 \ + -key ca.key \ + -out ca.pem \ + -subj "/CN=microsoft.ad root" \ + -passin pass:"${PASSWORD}" + +echo "Generating ${TARGET} LDAPS certificate" +generate ldaps "${TARGET}" sha256 ca diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/roles/setup_certificate/handlers/main.yml b/ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/roles/setup_certificate/handlers/main.yml new file mode 100644 index 000000000..471b5f24b --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/roles/setup_certificate/handlers/main.yml @@ -0,0 +1,10 @@ +- name: remove test user + microsoft.ad.user: + name: ldap-test + state: absent + +- name: remove test user cert + ansible.windows.win_file: + path: C:\Windows\TEMP\user.pfx + state: absent +
\ No newline at end of file diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/roles/setup_certificate/tasks/main.yml b/ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/roles/setup_certificate/tasks/main.yml new file mode 100644 index 000000000..8858e20cc --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/roles/setup_certificate/tasks/main.yml @@ -0,0 +1,196 @@ +- name: install Active Directory Certificate Services + ansible.windows.win_feature: + name: AD-Certificate + state: present + register: adcs_setup_res + +- name: reboot after ADCS install + ansible.windows.win_reboot: + when: adcs_setup_res.reboot_required + +- name: configure ADCS certification authority + ansible.windows.win_powershell: + script: | + $ErrorActionPreference = 'Stop' + $Ansible.Changed = $false + + $caParams = @{ + CAType = 'EnterpriseRootCa' + CryptoProviderName = 'RSA#Microsoft Software Key Storage Provider' + KeyLength = 2048 + HashAlgorithmName = 'SHA256' + Force = $true + } + try { + Install-AdcsCertificationAuthority @caParams + $Ansible.Changed = $true + } + catch [Microsoft.CertificateServices.Deployment.Common.CertificateServicesBaseSetupException] { + if ($_.Exception.Message -like 'The Certification Authority is already installed.*') { + return + } + throw + } + become: true + become_method: runas + become_user: SYSTEM + +- name: name ensure local cert dir exists + ansible.builtin.file: + path: '{{ cert_path }}' + state: directory + delegate_to: localhost + +- name: check if certificates have been generated + ansible.windows.win_stat: + path: C:\Windows\TEMP\ca.pem + register: cert_info + +- name: fetch CA cert from remote + ansible.builtin.fetch: + src: C:\Windows\TEMP\ca.pem + dest: '{{ cert_path }}/ca.pem' + flat: true + when: cert_info.stat.exists + +- name: generate CA and LDAPS certs + when: not cert_info.stat.exists + block: + - name: generate TLS certificates + ansible.builtin.script: + cmd: generate_cert.sh {{ dc_name | quote }} password + creates: '{{ cert_path }}/ca.pem' + chdir: '{{ cert_path }}' + delegate_to: localhost + + - name: copy across CA and LDAPS cert to remote target + ansible.windows.win_copy: + src: '{{ cert_path }}/{{ item }}' + dest: C:\Windows\TEMP\{{ item }} + loop: + - ca.pem + - ldaps.pfx + +- name: import CA certificate to trusted root CA + ansible.windows.win_certificate_store: + path: C:\Windows\TEMP\ca.pem + state: present + store_location: LocalMachine + store_name: Root + +- name: add custom CA to Forest NTAuthStore + ansible.windows.win_powershell: + script: | + $ErrorActionPreference = 'Stop' + $Ansible.Changed = $false + + $caCert = New-Object -TypeName System.Security.Cryptography.X509Certificates.X509Certificate2 -ArgumentList 'C:\Windows\TEMP\ca.pem' + $configRoot = (Get-ADRootDSE).configurationNamingContext + + $dn = "CN=NTAuthCertificates,CN=Public Key Services,CN=Services,$configRoot" + $obj = Get-ADObject -Identity $dn -Properties cACertificate + + $found = $false + foreach ($certBytes in $obj.cACertificate) { + $cert = New-Object -TypeName System.Security.Cryptography.X509Certificates.X509Certificate2 -ArgumentList @(,$certBytes) + if ($cert.Thumbprint -eq $caCert.Thumbprint) { + $found = $true + break + } + } + + if (-not $found) { + certutil.exe -dspublish C:\Windows\TEMP\ca.pem NTAuthCA + $Ansible.Changed = $true + } + become: true + become_method: runas + become_user: SYSTEM + +- name: create domain user + microsoft.ad.user: + name: ldap-test + upn: ldap-test@{{ domain_realm }} + description: ldap-test Domain Account + password: '{{ domain_password }}' + password_never_expires: true + update_password: when_changed + groups: + set: + - Domain Admins + - Domain Users + - Enterprise Admins + state: present + notify: remove test user + +- name: request User certificate + ansible.windows.win_powershell: + parameters: + Path: C:\Windows\TEMP\user.pfx + CertPass: '{{ domain_password }}' + script: | + [CmdletBinding()] + param ( + [string] + $Path, + + [string] + $CertPass + ) + $ErrorActionPreference = 'Stop' + $Ansible.Changed = $false + + if (Test-Path -LiteralPath $Path) { + return + } + + Push-Location Cert:\CurrentUser\My + $result = Get-Certificate -Template User -Url ldap: + Pop-Location + + if ($result.Status -ne "Issued") { + throw "Failed to request User certificate: $($result.Status)" + } + $Ansible.Changed = $true + + $cert = $result.Certificate + $certBytes = $result.Certificate.Export("Pfx", $CertPass) + [System.IO.File]::WriteAllBytes($Path, $certBytes) + notify: remove test user cert + vars: + ansible_become: true + ansible_become_method: runas + ansible_become_user: ldap-test@{{ domain_realm }} + ansible_become_pass: '{{ domain_password }}' + +- name: fetch certificate for user cert authentication + ansible.builtin.fetch: + src: C:\Windows\TEMP\user.pfx + dest: '{{ cert_path }}/user.pfx' + flat: true + +- name: import LDAPS certificate + ansible.windows.win_certificate_store: + path: C:\Windows\TEMP\ldaps.pfx + password: password + key_exportable: false + key_storage: machine + state: present + store_type: service + store_location: NTDS + store_name: My + register: ldaps_cert_info + +- name: register LDAPS certificate + ansible.windows.win_powershell: + script: | + $ErrorActionPreference = 'Stop' + $dse = [adsi]'LDAP://localhost/rootDSE' + [void]$dse.Properties['renewServerCertificate'].Add(1) + $dse.CommitChanges() + when: ldaps_cert_info is changed + vars: + ansible_become: true + ansible_become_method: runas + ansible_become_user: ldap-test@{{ domain_realm }} + ansible_become_pass: '{{ domain_password }}' diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/roles/test/tasks/invoke.yml b/ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/roles/test/tasks/invoke.yml new file mode 100644 index 000000000..722effb22 --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/roles/test/tasks/invoke.yml @@ -0,0 +1,14 @@ +- name: '{{ scenario }} - create temp inventory file' + ansible.builtin.copy: + content: '{{ inventory | to_nice_yaml(sort_keys=False) }}' + dest: /tmp/tmp-microsoft.ad.ldap.yml + delegate_to: localhost + +- name: '{{ scenario }} - run ansible-inventory' + ansible.builtin.command: ansible-inventory -i /tmp/tmp-microsoft.ad.ldap.yml --list + register: inventory_out_raw + delegate_to: localhost + +- name: '{{ scenario }} - get ansible-inventory output' + set_fact: + inventory_out: '{{ inventory_out_raw.stdout | from_json }}' 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 new file mode 100644 index 000000000..86b6d75e9 --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/roles/test/tasks/main.yml @@ -0,0 +1,527 @@ +- name: get domain controller info + microsoft.ad.object_info: + ldap_filter: '(objectClass=computer)' + properties: + - dNSHostName + register: dc_info_raw + +- name: make sure only 1 computer is present for start of tests + assert: + that: + - dc_info_raw.objects | length == 1 + +- set_fact: + dc_info: '{{ dc_info_raw.objects[0] }}' + +- import_tasks: invoke.yml + vars: + scenario: Failure connection invalid hostname + inventory: + plugin: microsoft.ad.ldap + server: failed + username: '{{ ldap_user }}' + password: '{{ ldap_pass }}' + +- name: assert failure connection invalid hostname + assert: + that: + - inventory_out._meta.hostvars == {} + - '"Failed to connect to failed:389" in inventory_out_raw.stderr' + +- import_tasks: invoke.yml + vars: + scenario: Failure connection blocked port + inventory: + plugin: microsoft.ad.ldap + server: '{{ ldap_server }}' + port: 1234 + username: '{{ ldap_user }}' + password: '{{ ldap_pass }}' + +- name: assert failure connection blocked port + assert: + that: + - inventory_out._meta.hostvars == {} + - '"Failed to connect to " ~ ldap_server ~ ":1234" in inventory_out_raw.stderr' + +- import_tasks: invoke.yml + vars: + scenario: Failure connection invalid port + inventory: + plugin: microsoft.ad.ldap + server: '{{ ldap_server }}' + port: 5985 + username: '{{ ldap_user }}' + password: '{{ ldap_pass }}' + +- name: assert failure connection invalid port + assert: + that: + - inventory_out._meta.hostvars == {} + - '"Received invalid data from the peer" in inventory_out_raw.stderr' + +- import_tasks: invoke.yml + vars: + scenario: LDAP + inventory: + plugin: microsoft.ad.ldap + server: '{{ ldap_server }}' + username: '{{ ldap_user }}' + password: '{{ ldap_pass }}' + +- name: assert LDAP inventory + assert: + that: &default-assertion + - inventory_out._meta.hostvars | length == 1 + - (inventory_out._meta.hostvars.keys() | list) == [dc_info.Name] + - (inventory_out._meta.hostvars[dc_info.Name].keys() | list) == ['ansible_host', 'microsoft_ad_distinguished_name'] + - inventory_out._meta.hostvars[dc_info.Name]['ansible_host'] == dc_info.dNSHostName + - inventory_out._meta.hostvars[dc_info.Name]['microsoft_ad_distinguished_name'] == dc_info.DistinguishedName + - inventory_out.ungrouped.hosts == [dc_info.Name] + +- import_tasks: invoke.yml + vars: + scenario: LDAP through environment variables + inventory: + plugin: microsoft.ad.ldap + environment: + MICROSOFT_AD_LDAP_SERVER: '{{ ldap_server }}' + MICROSOFT_AD_LDAP_USERNAME: '{{ ldap_user }}' + MICROSOFT_AD_LDAP_PASSWORD: '{{ ldap_pass }}' + +- name: assert LDAP inventory through environment variables + assert: + that: *default-assertion + +- import_tasks: invoke.yml + vars: + scenario: LDAPS + inventory: + plugin: microsoft.ad.ldap + server: '{{ ldap_server }}' + tls_mode: ldaps + ca_cert: '{{ ldap_ca_cert }}' + cert_validation: ignore_hostname + username: '{{ ldap_user }}' + password: '{{ ldap_pass }}' + +- name: assert LDAPS inventory + assert: + that: *default-assertion + +- import_tasks: invoke.yml + vars: + scenario: LDAPS through environment variables + inventory: + plugin: microsoft.ad.ldap + environment: + MICROSOFT_AD_LDAP_SERVER: '{{ ldap_server }}' + MICROSOFT_AD_LDAP_TLS_MODE: ldaps + MICROSOFT_AD_LDAP_CA_CERT: '{{ ldap_ca_cert }}' + MICROSOFT_AD_LDAP_CERT_VALIDATION: ignore_hostname + MICROSOFT_AD_LDAP_USERNAME: '{{ ldap_user }}' + MICROSOFT_AD_LDAP_PASSWORD: '{{ ldap_pass }}' + +- name: assert LDAPS inventory through environment variables + assert: + that: *default-assertion + +- import_tasks: invoke.yml + vars: + scenario: StartTLS + inventory: + plugin: microsoft.ad.ldap + server: '{{ ldap_server }}' + tls_mode: start_tls + ca_cert: '{{ ldap_ca_cert }}' + cert_validation: ignore_hostname + username: '{{ ldap_user }}' + password: '{{ ldap_pass }}' + +- name: assert StartTLS inventory + assert: + that: *default-assertion + +- import_tasks: invoke.yml + vars: + scenario: Simple auth + inventory: + plugin: microsoft.ad.ldap + server: '{{ ldap_server }}' + tls_mode: ldaps + ca_cert: '{{ lookup("file", ldap_ca_cert) }}' + cert_validation: ignore_hostname + username: '{{ ldap_user }}' + password: '{{ ldap_pass }}' + auth_protocol: simple + +- name: assert Simple auth inventory + assert: + that: *default-assertion + +- import_tasks: invoke.yml + vars: + scenario: Simple auth fails over LDAP + inventory: + plugin: microsoft.ad.ldap + server: '{{ ldap_server }}' + username: '{{ ldap_user }}' + password: '{{ ldap_pass }}' + auth_protocol: simple + +- name: assert simple auth failure over LDAP + assert: + that: + - inventory_out._meta.hostvars == {} + - '"Cannot use simple auth with encryption" in inventory_out_raw.stderr' + +- import_tasks: invoke.yml + vars: + scenario: Simple auth over LDAP with no encryption + inventory: + plugin: microsoft.ad.ldap + server: '{{ ldap_server }}' + username: '{{ ldap_user }}' + password: '{{ ldap_pass }}' + auth_protocol: simple + encrypt: false + +- name: assert Simple auth over LDAP with no encryption + assert: + that: *default-assertion + +- import_tasks: invoke.yml + vars: + scenario: Certificate auth with LDAPS + inventory: + plugin: microsoft.ad.ldap + server: '{{ ldap_server }}' + tls_mode: ldaps + ca_cert: '{{ ldap_ca_cert }}' + cert_validation: ignore_hostname + certificate: '{{ ldap_user_cert }}' + certificate_password: '{{ ldap_pass }}' + +- name: assert Certificate auth inventory with LDAPS + assert: + that: *default-assertion + +# Recent Windows Update seems to have broken this. Fails with: +# Received LDAPResult error bind failed - INVALID_CREDENTIALS - 80090317: LdapErr: DSID-0C090635, comment: The server did not receive any credentials via TLS, data 0, v4563 +# I cannot figure out why so just disabling the test for now. + +# - import_tasks: invoke.yml +# vars: +# scenario: Certificate auth with StartTLS +# inventory: +# plugin: microsoft.ad.ldap +# server: '{{ ldap_server }}' +# tls_mode: start_tls +# ca_cert: '{{ ldap_ca_cert }}' +# cert_validation: ignore_hostname +# certificate: '{{ ldap_user_cert }}' +# certificate_password: '{{ ldap_pass }}' + +# - name: assert Certificate auth inventory with StartTLS +# assert: +# that: *default-assertion + +- import_tasks: invoke.yml + vars: + scenario: TLS ignoring cert validation + inventory: + plugin: microsoft.ad.ldap + server: '{{ ldap_server }}' + tls_mode: ldaps + cert_validation: ignore + username: '{{ ldap_user }}' + password: '{{ ldap_pass }}' + +- name: assert TLS ignoring cert validation + assert: + that: *default-assertion + +- block: + - name: setup custom server data + ansible.windows.win_powershell: + depth: 3 + script: | + $ErrorActionPreference = 'Stop' + + $ou = New-ADOrganizationalUnit -Name '<My OU, !test''>' -PassThru + $adParams = @{ + Path = $ou.DistinguishedName + PassThru = $true + } + $subOU = New-ADOrganizationalUnit -Name SubOU @adParams + + $group1 = New-ADGroup -Name Group1 -GroupCategory Security -GroupScope Global @adParams + $group2 = New-ADGroup -Name Group2 -GroupCategory Security -GroupScope Global @adParams + + $comp1 = New-ADComputer -Name Comp1 -DNSHostName CustomName -OtherAttributes @{ + comment = 'comment 1' + 'msDS-AllowedToDelegateTo' = 'dns 1' + location = 'my_location' + } @adParams + $comp2 = New-ADComputer -Name Comp2 -SamAccountName Comp2Sam -Path $subOU.DistinguishedName -PassThru -OtherAttributes @{ + comment = 'comment 1' + 'msDS-AllowedToDelegateTo' = 'dns 2' + } + + Add-ADGroupMember -Identity $group1 -Members $comp1, $comp2 + Add-ADGroupMember -Identity $group2 -Members $comp1 + + $compMembers = @{ + Property = @( + 'DistinguishedName' + 'MemberOf' + @{N='RawMemberOf'; E={ + ,@($_.memberOf | ForEach-Object { + $b = (New-Object -TypeName System.Text.UTF8Encoding).GetBytes($_) + [System.Convert]::ToBase64String($b) + }) + }} + 'PwdLastSet' + @{N='SID'; E={$_.SID.Value}} + @{N='RawSID'; E={ + $b = New-Object -TypeName byte[] -ArgumentList $_.SID.BinaryLength + $_.SID.GetBinaryForm($b, 0) + [System.Convert]::ToBase64String($b) + }} + ) + } + + [PSCustomObject]@{ + OUId = $ou.ObjectGuid + OUPath = $ou.DistinguishedName + Comp1 = $comp1 | Get-ADComputer -Properties * | Select-Object @compMembers + Comp2 = $comp2 | Get-ADComputer -Properties * | Select-Object @compMembers + } + register: test_data + + - import_tasks: invoke.yml + vars: + scenario: Search with search_base and scope + inventory: + plugin: microsoft.ad.ldap + server: '{{ ldap_server }}' + username: '{{ ldap_user }}' + password: '{{ ldap_pass }}' + search_base: '{{ test_data.output[0]["OUPath"] }}' + search_scope: one_level + + - name: assert search with seach base and scope + assert: + that: + - inventory_out._meta.hostvars | length == 1 + - (inventory_out._meta.hostvars.keys() | list) == ["Comp1"] + - (inventory_out._meta.hostvars.Comp1.keys() | list) == ['ansible_host', 'microsoft_ad_distinguished_name'] + - inventory_out._meta.hostvars.Comp1.ansible_host == "CustomName" + - inventory_out._meta.hostvars.Comp1.microsoft_ad_distinguished_name == test_data.output[0].Comp1.DistinguishedName + - inventory_out.ungrouped.hosts == ["Comp1"] + + - import_tasks: invoke.yml + vars: + scenario: Search with filter + inventory: + plugin: microsoft.ad.ldap + server: '{{ ldap_server }}' + username: '{{ ldap_user }}' + password: '{{ ldap_pass }}' + filter: (sAMAccountName=Comp2Sam$) + + - name: assert search with seach base and scope + assert: + that: + - inventory_out._meta.hostvars | length == 1 + - (inventory_out._meta.hostvars.keys() | list) == ["Comp2"] + - (inventory_out._meta.hostvars.Comp2.keys() | list) == ['microsoft_ad_distinguished_name'] + - inventory_out._meta.hostvars.Comp2.microsoft_ad_distinguished_name == test_data.output[0].Comp2.DistinguishedName + - inventory_out.ungrouped.hosts == ["Comp2"] + + - import_tasks: invoke.yml + vars: + scenario: Set inventory_hostname from attributes + inventory: + plugin: microsoft.ad.ldap + server: '{{ ldap_server }}' + username: '{{ ldap_user }}' + password: '{{ ldap_pass }}' + search_base: '{{ test_data.output[0]["OUPath"] }}' + attributes: + sAMAccountName: + inventory_hostname: sAMAccountName[:-1] + ansible_host: inventory_hostname + + - name: assert set inventory_hostname from attributes + assert: + that: + - inventory_out._meta.hostvars | length == 2 + - (inventory_out._meta.hostvars.keys() | list | sort) == ["Comp1", "Comp2Sam"] + + - (inventory_out._meta.hostvars.Comp1.keys() | list | sort) == ['ansible_host', 'microsoft_ad_distinguished_name', 'sAMAccountName'] + - "inventory_out._meta.hostvars.Comp1.ansible_host == {'__ansible_unsafe': 'Comp1'}" + - inventory_out._meta.hostvars.Comp1.microsoft_ad_distinguished_name == test_data.output[0].Comp1.DistinguishedName + - "inventory_out._meta.hostvars.Comp1.sAMAccountName == {'__ansible_unsafe': 'Comp1$'}" + + - (inventory_out._meta.hostvars.Comp2Sam.keys() | list | sort) == ['ansible_host', 'microsoft_ad_distinguished_name', 'sAMAccountName'] + - "inventory_out._meta.hostvars.Comp2Sam.ansible_host == {'__ansible_unsafe': 'Comp2Sam'}" + - inventory_out._meta.hostvars.Comp2Sam.microsoft_ad_distinguished_name == test_data.output[0].Comp2.DistinguishedName + - "inventory_out._meta.hostvars.Comp2Sam.sAMAccountName == {'__ansible_unsafe': 'Comp2Sam$'}" + + - inventory_out.ungrouped.hosts | length == 2 + - inventory_out.ungrouped.hosts[0]['__ansible_unsafe'] in ['Comp1', 'Comp2Sam'] + - inventory_out.ungrouped.hosts[1]['__ansible_unsafe'] in ['Comp1', 'Comp2Sam'] + + - import_tasks: invoke.yml + vars: + scenario: Set inventory_hostname from compose + inventory: + plugin: microsoft.ad.ldap + server: '{{ ldap_server }}' + username: '{{ ldap_user }}' + password: '{{ ldap_pass }}' + search_base: '{{ test_data.output[0]["OUPath"] }}' + attributes: + sAMAccountName: + compose: + inventory_hostname: sAMAccountName[:-1] + ansible_host: inventory_hostname + + - name: assert set inventory_hostname from compose + assert: + that: + - inventory_out._meta.hostvars | length == 2 + - (inventory_out._meta.hostvars.keys() | list | sort) == ["Comp1", "Comp2Sam"] + + - (inventory_out._meta.hostvars.Comp1.keys() | list | sort) == ['ansible_host', 'microsoft_ad_distinguished_name', 'sAMAccountName'] + - "inventory_out._meta.hostvars.Comp1.ansible_host == {'__ansible_unsafe': 'Comp1'}" + - inventory_out._meta.hostvars.Comp1.microsoft_ad_distinguished_name == test_data.output[0].Comp1.DistinguishedName + - "inventory_out._meta.hostvars.Comp1.sAMAccountName == {'__ansible_unsafe': 'Comp1$'}" + + - (inventory_out._meta.hostvars.Comp2Sam.keys() | list | sort) == ['ansible_host', 'microsoft_ad_distinguished_name', 'sAMAccountName'] + - "inventory_out._meta.hostvars.Comp2Sam.ansible_host == {'__ansible_unsafe': 'Comp2Sam'}" + - inventory_out._meta.hostvars.Comp2Sam.microsoft_ad_distinguished_name == test_data.output[0].Comp2.DistinguishedName + - "inventory_out._meta.hostvars.Comp2Sam.sAMAccountName == {'__ansible_unsafe': 'Comp2Sam$'}" + + - inventory_out.ungrouped.hosts | length == 2 + - inventory_out.ungrouped.hosts[0]['__ansible_unsafe'] in ['Comp1', 'Comp2Sam'] + - inventory_out.ungrouped.hosts[1]['__ansible_unsafe'] in ['Comp1', 'Comp2Sam'] + + - import_tasks: invoke.yml + vars: + scenario: Search with composable options + inventory: + plugin: microsoft.ad.ldap + server: '{{ ldap_server }}' + username: '{{ ldap_user }}' + password: '{{ ldap_pass }}' + search_base: '{{ test_data.output[0]["OUPath"] }}' + attributes: + sAMAccountName: + objectSid: + nothing_sid: + this_sid: this + raw_sid: raw + raw_sid_filter: raw | microsoft.ad.as_sid + PwdLastSet: + location: + msDS-SupportedEncryptionTypes: + msDS-AllowedToDelegateTo: + msDS-AllowedToDelegateTo: + memberOf: + previous_reference: PwdLastSet | microsoft.ad.as_datetime + nothing_member: + this_member: this + raw_member: raw + computer_membership: this | map("regex_search", '^CN=(?P<name>.+?)((?<!\\),)', '\g<name>') | flatten + compose: + host_var: computer_sid + groups: + testing: true + production: '"Group2" in computer_membership' + keyed_groups: + - key: location | default(omit) + prefix: site + default_value: unknown + + - name: assert search with composable options + assert: + that: + - inventory_out._meta.hostvars | length == 2 + - (inventory_out._meta.hostvars.keys() | list | sort) == ["Comp1", "Comp2"] + + - (inventory_out._meta.hostvars.Comp1.keys() | list | sort) == ['ansible_host', 'computer_membership', 'location', 'microsoft_ad_distinguished_name', 'msDS-AllowedToDelegateTo', 'msDS_SupportedEncryptionTypes', 'nothing_member', 'nothing_sid', 'previous_reference', 'PwdLastSet', 'raw_member', 'raw_sid', 'raw_sid_filter', 'sAMAccountName', 'this_member', 'this_sid'] + - inventory_out._meta.hostvars.Comp1['ansible_host'] == 'CustomName' + - "inventory_out._meta.hostvars.Comp1['computer_membership'] == [{'__ansible_unsafe': 'Group2'}, {'__ansible_unsafe': 'Group1'}]" + - "inventory_out._meta.hostvars.Comp1['location'] == {'__ansible_unsafe': 'my_location'}" + - inventory_out._meta.hostvars.Comp1['microsoft_ad_distinguished_name'] == test_data.output[0].Comp1.DistinguishedName + - "inventory_out._meta.hostvars.Comp1['msDS-AllowedToDelegateTo'] == [{'__ansible_unsafe': 'dns 1'}]" + - inventory_out._meta.hostvars.Comp1['msDS_SupportedEncryptionTypes'] == None + - "inventory_out._meta.hostvars.Comp1['nothing_member'] == [{'__ansible_unsafe': test_data.output[0].Comp1.MemberOf[0]}, {'__ansible_unsafe': test_data.output[0].Comp1.MemberOf[1]}]" + - "inventory_out._meta.hostvars.Comp1['nothing_sid'] == {'__ansible_unsafe': test_data.output[0].Comp1.SID}" + - inventory_out._meta.hostvars.Comp1['previous_reference'] == test_data.output[0].Comp1.PwdLastSet | microsoft.ad.as_datetime + - inventory_out._meta.hostvars.Comp1['PwdLastSet'] == test_data.output[0].Comp1.PwdLastSet + - "inventory_out._meta.hostvars.Comp1['raw_member'] == [{'__ansible_unsafe': test_data.output[0].Comp1.RawMemberOf[0]}, {'__ansible_unsafe': test_data.output[0].Comp1.RawMemberOf[1]}]" + - "inventory_out._meta.hostvars.Comp1['raw_sid'] == [{'__ansible_unsafe': test_data.output[0].Comp1.RawSID}]" + - "inventory_out._meta.hostvars.Comp1['raw_sid_filter'] == [{'__ansible_unsafe': test_data.output[0].Comp1.SID}]" + - "inventory_out._meta.hostvars.Comp1['sAMAccountName'] == {'__ansible_unsafe': 'Comp1$'}" + - "inventory_out._meta.hostvars.Comp1['this_member'] == [{'__ansible_unsafe': test_data.output[0].Comp1.MemberOf[0]}, {'__ansible_unsafe': test_data.output[0].Comp1.MemberOf[1]}]" + - "inventory_out._meta.hostvars.Comp1['this_sid'] == {'__ansible_unsafe': test_data.output[0].Comp1.SID}" + + - (inventory_out._meta.hostvars.Comp2.keys() | list | sort) == ['computer_membership', 'location', 'microsoft_ad_distinguished_name', 'msDS-AllowedToDelegateTo', 'msDS_SupportedEncryptionTypes', 'nothing_member', 'nothing_sid', 'previous_reference', 'PwdLastSet', 'raw_member', 'raw_sid', 'raw_sid_filter', 'sAMAccountName', 'this_member', 'this_sid'] + - "inventory_out._meta.hostvars.Comp2['computer_membership'] == [{'__ansible_unsafe': 'Group1'}]" + - inventory_out._meta.hostvars.Comp2['location'] == None + - inventory_out._meta.hostvars.Comp2['microsoft_ad_distinguished_name'] == test_data.output[0].Comp2.DistinguishedName + - "inventory_out._meta.hostvars.Comp2['msDS-AllowedToDelegateTo'] == [{'__ansible_unsafe': 'dns 2'}]" + - inventory_out._meta.hostvars.Comp2['msDS_SupportedEncryptionTypes'] == None + - "inventory_out._meta.hostvars.Comp2['nothing_member'] == [{'__ansible_unsafe': test_data.output[0].Comp2.MemberOf[0]}]" + - "inventory_out._meta.hostvars.Comp2['nothing_sid'] == {'__ansible_unsafe': test_data.output[0].Comp2.SID}" + - inventory_out._meta.hostvars.Comp2['previous_reference'] == test_data.output[0].Comp2.PwdLastSet | microsoft.ad.as_datetime + - inventory_out._meta.hostvars.Comp2['PwdLastSet'] == test_data.output[0].Comp2.PwdLastSet + - "inventory_out._meta.hostvars.Comp2['raw_member'] == [{'__ansible_unsafe': test_data.output[0].Comp2.RawMemberOf[0]}]" + - "inventory_out._meta.hostvars.Comp2['raw_sid'] == [{'__ansible_unsafe': test_data.output[0].Comp2.RawSID}]" + - "inventory_out._meta.hostvars.Comp2['raw_sid_filter'] == [{'__ansible_unsafe': test_data.output[0].Comp2.SID}]" + - "inventory_out._meta.hostvars.Comp2['sAMAccountName'] == {'__ansible_unsafe': 'Comp2Sam$'}" + - "inventory_out._meta.hostvars.Comp2['this_member'] == [{'__ansible_unsafe': test_data.output[0].Comp2.MemberOf[0]}]" + - "inventory_out._meta.hostvars.Comp2['this_sid'] == {'__ansible_unsafe': test_data.output[0].Comp2.SID}" + + - inventory_out.production.hosts == ["Comp1"] + - inventory_out.site_my_location.hosts == ["Comp1"] + - inventory_out.site_unknown.hosts == ["Comp2"] + - inventory_out.testing.hosts | sort == ["Comp1", "Comp2"] + + - name: create multiple computer objects + ansible.windows.win_powershell: + parameters: + Path: '{{ test_data.output[0].OUPath }}' + script: | + param($Path) + + $ErrorActionPreference = 'Stop' + + 1..2010 | ForEach-Object { + New-ADComputer -Name "MultiComp$_" -Path $Path + } + + - import_tasks: invoke.yml + vars: + scenario: Search with large number of computer accounts + inventory: + plugin: microsoft.ad.ldap + server: '{{ ldap_server }}' + username: '{{ ldap_user }}' + password: '{{ ldap_pass }}' + filter: (name=MultiComp*) + + - name: assert search with large number of computer accounts + assert: + that: + - inventory_out._meta.hostvars | length == 2010 + + always: + - name: remove test OU + microsoft.ad.ou: + name: <My OU, !test'> + identity: '{{ test_data.output[0].OUId | default(omit) }}' + state: absent diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/runme.sh b/ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/runme.sh new file mode 100755 index 000000000..22564b656 --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/runme.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +set -eux + +ansible-playbook main.yml -i ../../inventory.winrm "$@" diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/membership/README.md b/ansible_collections/microsoft/ad/tests/integration/targets/membership/README.md new file mode 100644 index 000000000..9387dd0a6 --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/membership/README.md @@ -0,0 +1,34 @@ +# microsoft.ad.membership tests + +As this cannot be run in CI this is a brief guide on how to run these tests locally. +Run the following: + +```bash +vagrant up + +ansible-playbook setup.yml +``` + +It is a good idea to create a snapshot of both hosts before running the tests. +This allows you to reset the host back to a blank starting state if the tests need to be rerun. +To create a snaphost do the following: + +```bash +virsh snapshot-create-as --domain "membership_DC" --name "pretest" +virsh snapshot-create-as --domain "membership_TEST" --name "pretest" +``` + +To restore these snapshots run the following: + +```bash +virsh snapshot-revert --domain "membership_DC" --snapshotname "pretest" --running +virsh snapshot-revert --domain "membership_TEST" --snapshotname "pretest" --running +``` + +Once you are ready to run the tests run the following: + +```bash +ansible-playbook test.yml +``` + +Run `vagrant destroy` to remove the test VMs. diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/membership/Vagrantfile b/ansible_collections/microsoft/ad/tests/integration/targets/membership/Vagrantfile new file mode 100644 index 000000000..5341185c3 --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/membership/Vagrantfile @@ -0,0 +1,27 @@ +# -*- mode: ruby -*- +# vi: set ft=ruby : + +require 'yaml' + +inventory = YAML.load_file('inventory.yml') + +Vagrant.configure("2") do |config| + inventory['all']['children'].each do |group,details| + details['hosts'].each do |server,host_details| + config.vm.define server do |srv| + srv.vm.box = host_details['vagrant_box'] + srv.vm.hostname = server + srv.vm.network :private_network, + :ip => host_details['ansible_host'], + :libvirt__network_name => 'microsoft.ad', + :libvirt__domain_name => inventory['all']['vars']['domain_realm'] + + srv.vm.provider :libvirt do |l| + l.memory = 4096 + l.cpus = 2 + end + end + end + end +end + diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/membership/aliases b/ansible_collections/microsoft/ad/tests/integration/targets/membership/aliases new file mode 100644 index 000000000..a0187456c --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/membership/aliases @@ -0,0 +1,2 @@ +windows +unsupported # can never run in CI, see README.md
\ No newline at end of file diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/membership/ansible.cfg b/ansible_collections/microsoft/ad/tests/integration/targets/membership/ansible.cfg new file mode 100644 index 000000000..3a986973e --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/membership/ansible.cfg @@ -0,0 +1,3 @@ +[defaults] +inventory = inventory.yml +retry_files_enabled = False diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/membership/inventory.yml b/ansible_collections/microsoft/ad/tests/integration/targets/membership/inventory.yml new file mode 100644 index 000000000..06c916072 --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/membership/inventory.yml @@ -0,0 +1,22 @@ +all: + children: + windows: + hosts: + DC: + ansible_host: 192.168.11.10 + vagrant_box: jborean93/WindowsServer2022 + TEST: + ansible_host: 192.168.11.11 + vagrant_box: jborean93/WindowsServer2022 + vars: + ansible_port: 5985 + ansible_connection: psrp + + vars: + ansible_user: vagrant + ansible_password: vagrant + domain_username: vagrant-domain + domain_user_upn: '{{ domain_username }}@{{ domain_realm | upper }}' + domain_password: VagrantPass1 + domain_realm: ad.test + domain_dn_base: DC=ad,DC=test diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/membership/setup.yml b/ansible_collections/microsoft/ad/tests/integration/targets/membership/setup.yml new file mode 100644 index 000000000..5fcc09dfd --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/membership/setup.yml @@ -0,0 +1,82 @@ +- name: create domain controller + hosts: DC + gather_facts: no + + tasks: + - name: get network connection names + ansible.windows.win_powershell: + parameters: + IPAddress: '{{ ansible_host }}' + script: | + param ($IPAddress) + + $Ansible.Changed = $false + + Get-CimInstance -ClassName Win32_NetworkAdapter -Filter "Netenabled='True'" | + ForEach-Object -Process { + $config = Get-CimInstance -ClassName Win32_NetworkAdapterConfiguration -Filter "Index='$($_.Index)'" + if ($config.IPAddress -contains $IPAddress) { + $_.NetConnectionID + } + } + register: connection_name + + - name: set the DNS for the internal adapters to localhost + ansible.windows.win_dns_client: + adapter_names: + - '{{ connection_name.output[0] }}' + dns_servers: + - 127.0.0.1 + + - name: ensure domain exists and DC is promoted as a domain controller + microsoft.ad.domain: + dns_domain_name: '{{ domain_realm }}' + safe_mode_password: '{{ domain_password }}' + reboot: true + + - ansible.windows.win_feature: + name: RSAT-AD-PowerShell + state: present + + - name: create domain username + microsoft.ad.user: + name: '{{ domain_username }}' + upn: '{{ domain_user_upn }}' + description: '{{ domain_username }} Domain Account' + password: '{{ domain_password }}' + password_never_expires: yes + update_password: when_changed + groups: + add: + - Domain Admins + state: present + +- name: setup test host + hosts: TEST + gather_facts: no + + tasks: + - name: get network connection names + ansible.windows.win_powershell: + parameters: + IPAddress: '{{ ansible_host }}' + script: | + param ($IPAddress) + + $Ansible.Changed = $false + + Get-CimInstance -ClassName Win32_NetworkAdapter -Filter "Netenabled='True'" | + ForEach-Object -Process { + $config = Get-CimInstance -ClassName Win32_NetworkAdapterConfiguration -Filter "Index='$($_.Index)'" + if ($config.IPAddress -contains $IPAddress) { + $_.NetConnectionID + } + } + register: connection_name + + - name: set DNS for the private adapter to point to the DC + ansible.windows.win_dns_client: + adapter_names: + - '{{ connection_name.output[0] }}' + dns_servers: + - '{{ hostvars["DC"]["ansible_host"] }}' 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 new file mode 100644 index 000000000..e4fa96c8e --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/membership/tasks/main.yml @@ -0,0 +1,631 @@ +- set_fact: + get_result_script: | + $Ansible.Changed = $false + $cs = Get-CimInstance -ClassName Win32_ComputerSystem -Property Domain, PartOfDomain, Workgroup + $domainName = if ($cs.PartOfDomain) { + try { + [System.DirectoryServices.ActiveDirectory.Domain]::GetComputerDomain().Name + } + catch [System.Security.Authentication.AuthenticationException] { + $cs.Domain + } + } + else { + $null + } + + [PSCustomObject]@{ + HostName = $env:COMPUTERNAME + PartOfDomain = $cs.PartOfDomain + DnsDomainName = $domainName + WorkgroupName = $cs.Workgroup + } + + get_ad_result_script: | + $Ansible.Changed = $false + Get-ADComputer -Filter { Name -ne 'DC' } -Properties DistinguishedName, Name, Enabled | + Select-Object -Property DistinguishedName, Name, Enabled + +- name: join domain - check mode + membership: + dns_domain_name: '{{ domain_realm }}' + domain_admin_user: '{{ domain_user_upn }}' + domain_admin_password: '{{ domain_password }}' + state: domain + reboot: true + check_mode: true + register: join_domain_check + +- name: get result of join domain - check mode + ansible.windows.win_powershell: + script: '{{ get_result_script }}' + register: join_domain_check_actual + +- name: assert join domain - check mode + assert: + that: + - join_domain_check is changed + - join_domain_check.reboot_required == False + - join_domain_check_actual.output[0]["DnsDomainName"] == None + - join_domain_check_actual.output[0]["HostName"] == "TEST" + - join_domain_check_actual.output[0]["PartOfDomain"] == False + - join_domain_check_actual.output[0]["WorkgroupName"] == "WORKGROUP" + +- name: join domain with reboot + membership: + dns_domain_name: '{{ domain_realm }}' + domain_admin_user: '{{ domain_user_upn }}' + domain_admin_password: '{{ domain_password }}' + state: domain + reboot: true + register: join_domain + +- name: get result of join domain with reboot + ansible.windows.win_powershell: + script: '{{ get_result_script }}' + register: join_domain_actual + +- name: get ad result of join domain with reboot + ansible.windows.win_powershell: + script: '{{ get_ad_result_script }}' + delegate_to: DC + register: join_domain_ad_actual + +- name: assert join domain with reboot + assert: + that: + - join_domain is changed + - join_domain.reboot_required == False + - join_domain_actual.output[0]["DnsDomainName"] == domain_realm + - join_domain_actual.output[0]["HostName"] == "TEST" + - join_domain_actual.output[0]["PartOfDomain"] == True + - join_domain_actual.output[0]["WorkgroupName"] == None + - join_domain_ad_actual.output | length == 1 + - join_domain_ad_actual.output[0]["Name"] == "TEST" + - join_domain_ad_actual.output[0]["Enabled"] == True + +- name: join domain with reboot - idempotent + membership: + dns_domain_name: '{{ domain_realm }}' + domain_admin_user: '{{ domain_user_upn }}' + domain_admin_password: '{{ domain_password }}' + state: domain + reboot: true + register: join_domain_again + +- name: assert join domain with reboot - idempotent + assert: + that: + - not join_domain_again is changed + - join_domain_again.reboot_required == False + +- name: fail to change domain of already joined host + membership: + dns_domain_name: fake.realm + domain_admin_user: '{{ domain_user_upn }}' + domain_admin_password: '{{ domain_password }}' + state: domain + reboot: true + register: fail_cannot_rename_domain + failed_when: + - fail_cannot_rename_domain.msg != "Host is already joined to '" ~ domain_realm ~ "', switching domains is not implemented" + +- name: rename hostname of domain joined host - check mode + membership: + dns_domain_name: '{{ domain_realm }}' + domain_admin_user: '{{ domain_user_upn }}' + domain_admin_password: '{{ domain_password }}' + hostname: OTHER + state: domain + reboot: true + register: rename_host_domain_check + check_mode: True + +- name: get result of rename hostname of domain joined host - check mode + ansible.windows.win_powershell: + script: '{{ get_result_script }}' + register: rename_host_domain_check_actual + +- name: get ad result of rename hostname of domain joined host - check mode + ansible.windows.win_powershell: + script: '{{ get_ad_result_script }}' + delegate_to: DC + register: rename_host_domain_check_ad_actual + +- name: assert rename hostname of domain joined host - check mode + assert: + that: + - rename_host_domain_check is changed + - rename_host_domain_check.reboot_required == False + - rename_host_domain_check_actual.output[0]["DnsDomainName"] == domain_realm + - rename_host_domain_check_actual.output[0]["HostName"] == "TEST" + - rename_host_domain_check_actual.output[0]["PartOfDomain"] == True + - rename_host_domain_check_actual.output[0]["WorkgroupName"] == None + - rename_host_domain_check_ad_actual.output | length == 1 + - rename_host_domain_check_ad_actual.output[0]["Name"] == "TEST" + - rename_host_domain_check_ad_actual.output[0]["Enabled"] == True + +- name: rename hostname of domain joined host + membership: + dns_domain_name: '{{ domain_realm }}' + domain_admin_user: '{{ domain_user_upn }}' + domain_admin_password: '{{ domain_password }}' + hostname: OTHER + state: domain + reboot: true + register: rename_host_domain + +- name: get result of rename hostname of domain joined host + ansible.windows.win_powershell: + script: '{{ get_result_script }}' + register: rename_host_domain_actual + +- name: get ad result of rename hostname of domain joined host + ansible.windows.win_powershell: + script: '{{ get_ad_result_script }}' + delegate_to: DC + register: rename_host_domain_ad_actual + +- name: assert join domain + assert: + that: + - rename_host_domain is changed + - rename_host_domain.reboot_required == False + - rename_host_domain_actual.output[0]["DnsDomainName"] == domain_realm + - rename_host_domain_actual.output[0]["HostName"] == "OTHER" + - rename_host_domain_actual.output[0]["PartOfDomain"] == True + - rename_host_domain_actual.output[0]["WorkgroupName"] == None + - rename_host_domain_ad_actual.output | length == 1 + - rename_host_domain_ad_actual.output[0]["Name"] == "OTHER" + - rename_host_domain_ad_actual.output[0]["Enabled"] == True + +- name: change domain to workgroup - check mode + membership: + workgroup_name: TEST + domain_admin_user: '{{ domain_user_upn }}' + domain_admin_password: '{{ domain_password }}' + state: workgroup + register: to_workgroup_check + check_mode: true + +- name: get result of change domain to workgroup - check mode + ansible.windows.win_powershell: + script: '{{ get_result_script }}' + register: to_workgroup_check_actual + +- name: get ad result of change domain to workgroup - check mode + ansible.windows.win_powershell: + script: '{{ get_ad_result_script }}' + delegate_to: DC + register: to_workgroup_check_ad_actual + +- name: assert change domain to workgroup - check mode + assert: + that: + - to_workgroup_check is changed + - to_workgroup_check.reboot_required == True + - to_workgroup_check_actual.output[0]["DnsDomainName"] == domain_realm + - to_workgroup_check_actual.output[0]["HostName"] == "OTHER" + - to_workgroup_check_actual.output[0]["PartOfDomain"] == True + - to_workgroup_check_actual.output[0]["WorkgroupName"] == None + - to_workgroup_check_ad_actual.output | length == 1 + - to_workgroup_check_ad_actual.output[0]["Name"] == "OTHER" + - to_workgroup_check_ad_actual.output[0]["Enabled"] == True + +- name: change domain to workgroup + membership: + workgroup_name: TEST + domain_admin_user: '{{ domain_user_upn }}' + domain_admin_password: '{{ domain_password }}' + state: workgroup + register: to_workgroup + +- set_fact: + local_user: OTHER\{{ ansible_user }} + +- ansible.windows.win_reboot: + when: to_workgroup.reboot_required + vars: + # To avoid conflicts with the domain account with the same name we + # explicitly prefix the user to be the local account. Failing to do so + # will have the connection fail as it will try to talk to the DC which + # ends up failing. + ansible_user: '{{ local_user }}' + +- name: get result of change domain to workgroup + ansible.windows.win_powershell: + script: '{{ get_result_script }}' + register: to_workgroup_actual + +- name: get ad result of change domain to workgroup + ansible.windows.win_powershell: + script: '{{ get_ad_result_script }}' + delegate_to: DC + register: to_workgroup_ad_actual + +- name: assert change domain to workgroup + assert: + that: + - to_workgroup is changed + - to_workgroup.reboot_required == True + - to_workgroup_actual.output[0]["DnsDomainName"] == None + - to_workgroup_actual.output[0]["HostName"] == "OTHER" + - to_workgroup_actual.output[0]["PartOfDomain"] == False + - to_workgroup_actual.output[0]["WorkgroupName"] == "TEST" + - to_workgroup_ad_actual.output | length == 1 + - to_workgroup_ad_actual.output[0]["Name"] == "OTHER" + - to_workgroup_ad_actual.output[0]["Enabled"] == False + +- name: remove orphaned AD account for later tests + microsoft.ad.computer: + name: OTHER + state: absent + delegate_to: DC + +- name: change domain to workgroup - idempotent + membership: + workgroup_name: TEST + domain_admin_user: '{{ domain_user_upn }}' + domain_admin_password: '{{ domain_password }}' + state: workgroup + reboot: true + register: to_workgroup_again + +- name: assert change domain to workgroup - idempotent + assert: + that: + - not to_workgroup_again is changed + - to_workgroup_again.reboot_required == False + +- name: change workgroup - check mode + membership: + workgroup_name: TEST2 + domain_admin_user: '{{ domain_user_upn }}' + domain_admin_password: '{{ domain_password }}' + state: workgroup + reboot: true + register: change_workgroup_check + check_mode: true + +- name: get result of change workgroup - check mode + ansible.windows.win_powershell: + script: '{{ get_result_script }}' + register: change_workgroup_check_actual + +- name: assert change workgroup - check mode + assert: + that: + - change_workgroup_check is changed + - change_workgroup_check.reboot_required == False + - change_workgroup_check_actual.output[0]["DnsDomainName"] == None + - change_workgroup_check_actual.output[0]["HostName"] == "OTHER" + - change_workgroup_check_actual.output[0]["PartOfDomain"] == False + - change_workgroup_check_actual.output[0]["WorkgroupName"] == "TEST" + +- name: change workgroup + membership: + workgroup_name: TEST2 + domain_admin_user: '{{ domain_user_upn }}' + domain_admin_password: '{{ domain_password }}' + state: workgroup + reboot: true + register: change_workgroup + +- name: get result of change workgroup + ansible.windows.win_powershell: + script: '{{ get_result_script }}' + register: change_workgroup_actual + +- name: assert change workgroup + assert: + that: + - change_workgroup is changed + - change_workgroup.reboot_required == False + - change_workgroup_actual.output[0]["DnsDomainName"] == None + - change_workgroup_actual.output[0]["HostName"] == "OTHER" + - change_workgroup_actual.output[0]["PartOfDomain"] == False + - change_workgroup_actual.output[0]["WorkgroupName"] == "TEST2" + +- name: change just the hostname - check mode + membership: + workgroup_name: TEST2 + domain_admin_user: '{{ domain_user_upn }}' + domain_admin_password: '{{ domain_password }}' + state: workgroup + reboot: true + hostname: FOO + register: change_hostname_check + check_mode: true + +- name: get result of change just the hostname - check mode + ansible.windows.win_powershell: + script: '{{ get_result_script }}' + register: change_hostname_check_actual + +- name: assert change just the hostname - check mode + assert: + that: + - change_hostname_check is changed + - change_hostname_check.reboot_required == False + - change_hostname_check_actual.output[0]["DnsDomainName"] == None + - change_hostname_check_actual.output[0]["HostName"] == "OTHER" + - change_hostname_check_actual.output[0]["PartOfDomain"] == False + - change_hostname_check_actual.output[0]["WorkgroupName"] == "TEST2" + +- name: change just the hostname + membership: + workgroup_name: TEST2 + domain_admin_user: '{{ domain_user_upn }}' + domain_admin_password: '{{ domain_password }}' + state: workgroup + reboot: true + hostname: FOO + register: change_hostname + +- name: get result of change just the hostname + ansible.windows.win_powershell: + script: '{{ get_result_script }}' + register: change_hostname_actual + +- name: assert change just the hostname - check mode + assert: + that: + - change_hostname is changed + - change_hostname.reboot_required == False + - change_hostname_actual.output[0]["DnsDomainName"] == None + - change_hostname_actual.output[0]["HostName"] == "FOO" + - change_hostname_actual.output[0]["PartOfDomain"] == False + - change_hostname_actual.output[0]["WorkgroupName"] == "TEST2" + +- name: create custom OU + ansible.windows.win_powershell: + script: | + $ou = New-ADOrganizationalUnit -Name MyHosts -PassThru + $ou.DistinguishedName + delegate_to: DC + register: custom_ou + +- name: join domain with hostname and OU - check mode + membership: + dns_domain_name: '{{ domain_realm }}' + domain_admin_user: '{{ domain_user_upn }}' + domain_admin_password: '{{ domain_password }}' + hostname: BAR + domain_ou_path: '{{ custom_ou.output[0] }}' + state: domain + register: join_ou_check + check_mode: true + +- name: get result of join domain with hostname and OU - check mode + ansible.windows.win_powershell: + script: '{{ get_result_script }}' + register: join_ou_check_actual + +- name: assert change just the hostname - check mode + assert: + that: + - join_ou_check is changed + - join_ou_check.reboot_required == True + - join_ou_check_actual.output[0]["DnsDomainName"] == None + - join_ou_check_actual.output[0]["HostName"] == "FOO" + - join_ou_check_actual.output[0]["PartOfDomain"] == False + - join_ou_check_actual.output[0]["WorkgroupName"] == "TEST2" + +- name: join domain with hostname and OU + membership: + dns_domain_name: '{{ domain_realm }}' + domain_admin_user: '{{ domain_user_upn }}' + domain_admin_password: '{{ domain_password }}' + hostname: BAR + domain_ou_path: '{{ custom_ou.output[0] }}' + state: domain + register: join_ou + +- ansible.windows.win_reboot: + when: join_ou.reboot_required + +- name: get result of join domain with hostname and OU + ansible.windows.win_powershell: + script: '{{ get_result_script }}' + register: join_ou_actual + +- name: get ad result of join domain with hostname and OU + ansible.windows.win_powershell: + script: '{{ get_ad_result_script }}' + register: join_ou_ad_actual + delegate_to: DC + +- name: assert change just the hostname + assert: + that: + - join_ou is changed + - join_ou.reboot_required == True + - join_ou_actual.output[0]["DnsDomainName"] == domain_realm + - join_ou_actual.output[0]["HostName"] == "BAR" + - join_ou_actual.output[0]["PartOfDomain"] == True + - join_ou_actual.output[0]["WorkgroupName"] == None + - join_ou_ad_actual.output | length == 1 + - join_ou_ad_actual.output[0]["Name"] == "BAR" + - join_ou_ad_actual.output[0]["Enabled"] == True + - join_ou_ad_actual.output[0]["DistinguishedName"] == "CN=BAR," ~ custom_ou.output[0] + +- name: change domain to workgroup with hostname change - check mode + membership: + workgroup_name: WORKGROUP + domain_admin_user: '{{ domain_user_upn }}' + domain_admin_password: '{{ domain_password }}' + hostname: FOO + state: workgroup + register: to_workgroup_hostname_check + check_mode: true + +- name: get result of change domain to workgroup with hostname change - check mode + ansible.windows.win_powershell: + script: '{{ get_result_script }}' + register: to_workgroup_hostname_check_actual + +- name: get ad result of change domain to workgroup with hostname change - check mode + ansible.windows.win_powershell: + script: '{{ get_ad_result_script }}' + delegate_to: DC + register: to_workgroup_hostname_check_ad_actual + +- name: assert change domain to workgroup with hostname change - check mode + assert: + that: + - to_workgroup_hostname_check is changed + - to_workgroup_hostname_check.reboot_required == True + - to_workgroup_hostname_check_actual.output[0]["DnsDomainName"] == domain_realm + - to_workgroup_hostname_check_actual.output[0]["HostName"] == "BAR" + - to_workgroup_hostname_check_actual.output[0]["PartOfDomain"] == True + - to_workgroup_hostname_check_actual.output[0]["WorkgroupName"] == None + - to_workgroup_hostname_check_ad_actual.output | length == 1 + - to_workgroup_hostname_check_ad_actual.output[0]["Name"] == "BAR" + - to_workgroup_hostname_check_ad_actual.output[0]["Enabled"] == True + +- name: change domain to workgroup with hostname change + membership: + workgroup_name: WORKGROUP + domain_admin_user: '{{ domain_user_upn }}' + domain_admin_password: '{{ domain_password }}' + hostname: FOO + state: workgroup + reboot: true + register: to_workgroup_hostname + +- name: get result of change domain to workgroup with hostname change + ansible.windows.win_powershell: + script: '{{ get_result_script }}' + register: to_workgroup_hostname_actual + +- name: get ad result of change domain to workgroup with hostname change + ansible.windows.win_powershell: + script: '{{ get_ad_result_script }}' + delegate_to: DC + register: to_workgroup_hostname_ad_actual + +- name: assert change domain to workgroup with hostname change + assert: + that: + - to_workgroup_hostname is changed + - to_workgroup_hostname.reboot_required == False + - to_workgroup_hostname_actual.output[0]["DnsDomainName"] == None + - to_workgroup_hostname_actual.output[0]["HostName"] == "FOO" + - to_workgroup_hostname_actual.output[0]["PartOfDomain"] == False + - to_workgroup_hostname_actual.output[0]["WorkgroupName"] == "WORKGROUP" + - to_workgroup_hostname_ad_actual.output | length == 1 + - to_workgroup_hostname_ad_actual.output[0]["Name"] == "BAR" + - to_workgroup_hostname_ad_actual.output[0]["Enabled"] == False + +- name: remove orphaned AD account for later tests + microsoft.ad.computer: + name: BAR + state: absent + delegate_to: DC + +- name: create computer object + microsoft.ad.computer: + name: My, Computer + path: CN=Users,{{ domain_dn_base }} + sam_account_name: MyComp$ + state: present + delegate_to: DC + register: comp_account + +- name: get offline join blob + microsoft.ad.offline_join: + identity: '{{ comp_account.object_guid }}' + delegate_to: DC + register: offline_join + +- name: get computer object info + microsoft.ad.object_info: + identity: '{{ comp_account.object_guid }}' + properties: + - pwdLastSet + delegate_to: DC + register: comp_account_pre_join + +- name: join domain by offline blob - check + microsoft.ad.membership: + offline_join_blob: '{{ offline_join.blob }}' + state: domain + register: offline_join_check + check_mode: true + +- name: get result of join domain by offline blob - check + ansible.windows.win_powershell: + script: '{{ get_result_script }}' + register: offline_join_check_actual + +- name: get result of join domain by offline blob comp info - check + microsoft.ad.object_info: + identity: '{{ comp_account.object_guid }}' + properties: + - pwdLastSet + delegate_to: DC + register: offline_join_check_ad_actual + +- name: assert join domain by offline blob - check + assert: + that: + - offline_join_check is changed + - offline_join_check.reboot_required == true + - offline_join_check_actual.output[0].DnsDomainName == None + - offline_join_check_actual.output[0].PartOfDomain == False + - offline_join_check_ad_actual.objects[0].pwdLastSet == comp_account_pre_join.objects[0].pwdLastSet + +- name: join domain by offline blob + microsoft.ad.membership: + offline_join_blob: '{{ offline_join.blob }}' + state: domain + reboot: true + register: offline_join_res + +- name: get result of join domain by offline blob + ansible.windows.win_powershell: + script: '{{ get_result_script }}' + register: offline_join_actual + +- name: get result of join domain by offline blob comp info + microsoft.ad.object_info: + identity: '{{ comp_account.object_guid }}' + properties: + - pwdLastSet + delegate_to: DC + register: offline_join_ad_actual + +- name: assert join domain by offline blob + assert: + that: + - offline_join_res is changed + - offline_join_res.reboot_required == false + - offline_join_actual.output[0].DnsDomainName == domain_realm + - offline_join_actual.output[0].PartOfDomain == True + - offline_join_ad_actual.objects[0].pwdLastSet > offline_join_check_ad_actual.objects[0].pwdLastSet + +- name: join domain by offline blob - idempotent + microsoft.ad.membership: + offline_join_blob: '{{ offline_join.blob }}' + state: domain + register: offline_join_again + +- name: assert join domain by offline blob - idempotent + assert: + that: + - not offline_join_again is changed + +- name: change domain to workgroup + membership: + workgroup_name: WORKGROUP + domain_admin_user: '{{ domain_user_upn }}' + domain_admin_password: '{{ domain_password }}' + state: workgroup + reboot: true + +- name: remove orphaned AD account for later tests + microsoft.ad.computer: + name: My, Computer + path: CN=Users,{{ domain_dn_base }} + state: absent + delegate_to: DC diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/membership/test.yml b/ansible_collections/microsoft/ad/tests/integration/targets/membership/test.yml new file mode 100644 index 000000000..238d92005 --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/membership/test.yml @@ -0,0 +1,30 @@ +- name: ensure time is in sync + hosts: windows + gather_facts: no + tasks: + - name: get current host datetime + command: date +%s + changed_when: False + delegate_to: localhost + run_once: True + register: local_time + + - name: set datetime on Windows + ansible.windows.win_powershell: + parameters: + SecondsSinceEpoch: '{{ local_time.stdout | trim }}' + script: | + param($SecondsSinceEpoch) + + $utc = [System.DateTimeKind]::Utc + $epoch = New-Object -TypeName System.DateTime -ArgumentList 1970, 1, 1, 0, 0, 0, 0, $utc + $date = $epoch.AddSeconds($SecondsSinceEpoch) + + Set-Date -Date $date + +- name: run microsoft.ad.membership tests + hosts: TEST + gather_facts: no + + tasks: + - import_tasks: tasks/main.yml diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/object/aliases b/ansible_collections/microsoft/ad/tests/integration/targets/object/aliases new file mode 100644 index 000000000..ccd8a25e8 --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/object/aliases @@ -0,0 +1,2 @@ +windows +shippable/windows/group1 diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/object/defaults/main.yml b/ansible_collections/microsoft/ad/tests/integration/targets/object/defaults/main.yml new file mode 100644 index 000000000..e557e2d01 --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/object/defaults/main.yml @@ -0,0 +1,2 @@ +object_name: My, Contact +object_dn: CN=My\, Contact,{{ setup_domain_info.output[0].defaultNamingContext }} diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/object/meta/main.yml b/ansible_collections/microsoft/ad/tests/integration/targets/object/meta/main.yml new file mode 100644 index 000000000..4ce45dcfb --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/object/meta/main.yml @@ -0,0 +1,2 @@ +dependencies: +- setup_domain diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/object/tasks/main.yml b/ansible_collections/microsoft/ad/tests/integration/targets/object/tasks/main.yml new file mode 100644 index 000000000..1945e3a90 --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/object/tasks/main.yml @@ -0,0 +1,164 @@ +- name: add test attributes to the schema + object: + name: '{{ item.name }}' + path: '{{ setup_domain_info.output[0].schemaNamingContext }}' + state: present + type: attributeSchema + attributes: + set: + adminDescription: '{{ item.description }}' + lDAPDisplayName: '{{ item.name }}' + attributeId: 1.3.6.1.4.1.2312.99999.{{ item.id }} + attributeSyntax: '{{ item.syntax }}' + omSyntax: '{{ item.om_syntax }}' + isSingleValued: '{{ item.single_value }}' + systemOnly: false + isMemberOfPartialAttributeSet: false + searchFlags: 0 + showInAdvancedViewOnly: false + loop: + # https://social.technet.microsoft.com/wiki/contents/articles/52570.active-directory-syntaxes-of-attributes.aspx + - name: ansible-BoolSingle + description: Test Attribute for Boolean Single + id: 1 + syntax: 2.5.5.8 + om_syntax: 1 + single_value: true + - name: ansible-BoolMulti + description: Test Attribute for Boolean Multi + id: 2 + syntax: 2.5.5.8 + om_syntax: 1 + single_value: false + + - name: ansible-BytesSingle + description: Test Attribute for Bytes Single + id: 3 + syntax: 2.5.5.10 + om_syntax: 4 + single_value: true + - name: ansible-BytesMulti + description: Test Attribute for Bytes Multi + id: 4 + syntax: 2.5.5.10 + om_syntax: 4 + single_value: false + + - name: ansible-DateTimeSingle + description: Test Attribute for DateTime Single + id: 5 + syntax: 2.5.5.11 + om_syntax: 24 + single_value: true + - name: ansible-DateTimeMulti + description: Test Attribute for DateTime Multi + id: 6 + syntax: 2.5.5.11 + om_syntax: 24 + single_value: false + + - name: ansible-IntSingle + description: Test Attribute for Integer Single + id: 7 + syntax: 2.5.5.16 + om_syntax: 65 + single_value: true + - name: ansible-IntMulti + description: Test Attribute for Integer Multi + id: 8 + syntax: 2.5.5.16 + om_syntax: 65 + single_value: false + + - name: ansible-SDSingle + description: Test Attribute for SD Single + id: 9 + syntax: 2.5.5.15 + om_syntax: 66 + single_value: true + - name: ansible-SDMulti + description: Test Attribute for SD Multi + id: 10 + syntax: 2.5.5.15 + om_syntax: 66 + single_value: false + + - name: ansible-StringSingle + description: Test Attribute for String Single + id: 11 + syntax: 2.5.5.12 + om_syntax: 64 + single_value: true + - name: ansible-StringMulti + description: Test Attribute for String Multi + id: 12 + syntax: 2.5.5.12 + om_syntax: 64 + single_value: false + + register: schema_attributes + become: true + become_method: runas + become_user: SYSTEM + +- name: create auxilary class to house the new attributes + object: + name: ansibleTesting + path: '{{ setup_domain_info.output[0].schemaNamingContext }}' + type: classSchema + attributes: + set: + adminDescription: Test auxilary class for Ansible microsoft.ad attribute tests + adminDisplayName: ansibleTesting + lDAPDisplayName: ansibleTesting + governsId: 1.3.6.1.4.1.2312.99999 + objectClassCategory: 3 + systemOnly: false + subclassOf: top + # This is unfortunately not idempotent, the set must be the OID but + # Get-ADObject returns the lDAPDisplayName + mayContain: '{{ schema_attributes.results | map(attribute="item.id") | map("regex_replace", "^(.*)$", "1.3.6.1.4.1.2312.99999.\1") | list }}' + when: schema_attributes is changed + register: schema_class + become: true + become_method: runas + become_user: SYSTEM + +- name: add auxilary class to the contact class + object: + name: Contact + path: '{{ setup_domain_info.output[0].schemaNamingContext }}' + type: classSchema + attributes: + add: + auxiliaryClass: 1.3.6.1.4.1.2312.99999 + register: aux_reg + become: true + become_method: runas + become_user: SYSTEM + when: schema_class is changed + +- name: update schema + ansible.windows.win_powershell: + parameters: + Name: '{{ setup_domain_info.output[0].dnsHostName }}' + script: | + param($Name) + + $dse = New-Object -TypeName System.DirectoryServices.DirectoryEntry -ArgumentList "LDAP://$Name/RootDSE" + $dse.Put("SchemaUpdateNow", 1) + $dse.SetInfo() + become: true + become_method: runas + become_user: SYSTEM + when: aux_reg is changed + +- block: + - import_tasks: tests.yml + + always: + - name: remove temp object + object: + name: '{{ object_name }}' + identity: '{{ object_identity | default(omit) }}' + state: absent 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 new file mode 100644 index 000000000..b642ce6eb --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/object/tasks/tests.yml @@ -0,0 +1,1446 @@ +- name: create contact object - check + object: + name: '{{ object_name }}' + type: contact + state: present + register: create_check + check_mode: true + +- name: get result of create contact object - check + object_info: + identity: '{{ create_check.distinguished_name }}' + register: create_check_actual + +- name: assert create contact object - check + assert: + that: + - create_check is changed + - create_check.distinguished_name == object_dn + - create_check.object_guid == '00000000-0000-0000-0000-000000000000' + - create_check_actual.objects == [] + +- name: create contact object + object: + name: '{{ object_name }}' + type: contact + state: present + register: create + +- name: get result of create contact object + object_info: + identity: '{{ create_check.distinguished_name }}' + properties: + - objectClass + register: create_actual + +- name: assert create contact object + assert: + that: + - create is changed + - create_actual.objects | length == 1 + - create_actual.objects[0].ObjectClass == 'contact' + - create.distinguished_name == object_dn + - create.distinguished_name == create_actual.objects[0].DistinguishedName + - create.object_guid == create_actual.objects[0].ObjectGUID + +- set_fact: + object_identity: '{{ create.object_guid }}' + +- name: create contact object - idempotent + object: + name: '{{ object_name }}' + type: contact + state: present + register: create_again + +- name: assert create contact object - idempotent + assert: + that: + - not create_again is changed + - create_again.distinguished_name == create_actual.objects[0].DistinguishedName + - create_again.object_guid == create_actual.objects[0].ObjectGUID + +- name: fail to change type + object: + name: '{{ object_name }}' + state: present + type: failure + register: fail_change_type + failed_when: fail_change_type.msg != "Cannot change object type contact of existing object " ~ object_dn ~ " to failure" + +- name: rename and set display name of object - check + object: + name: My, Contact 2 + identity: '{{ object_identity }}' + display_name: Display Name + state: present + type: contact + register: rename_check + check_mode: true + +- name: get result of create contact object - check + object_info: + identity: '{{ object_identity }}' + properties: + - displayName + - name + register: rename_check_actual + +- name: assert rename and set display name of object - check + assert: + that: + - rename_check is changed + - rename_check.distinguished_name == 'CN=My\, Contact 2,' ~ setup_domain_info.output[0].defaultNamingContext + - rename_check.object_guid == object_identity + - rename_check_actual.objects[0].DisplayName == None + - rename_check_actual.objects[0].DistinguishedName == object_dn + - rename_check_actual.objects[0].Name == object_name + +- name: rename and set display name of object + object: + name: My, Contact 2 + identity: '{{ object_identity }}' + display_name: Display Name + state: present + type: contact + register: rename + +- name: get result of create contact object + object_info: + identity: '{{ object_identity }}' + properties: + - displayName + - name + register: rename_actual + +- name: assert rename and set display name of object + assert: + that: + - rename is changed + - rename.distinguished_name == 'CN=My\, Contact 2,' ~ setup_domain_info.output[0].defaultNamingContext + - rename.object_guid == object_identity + - rename_actual.objects[0].DisplayName == 'Display Name' + - rename_actual.objects[0].DistinguishedName == 'CN=My\, Contact 2,' ~ setup_domain_info.output[0].defaultNamingContext + - rename_actual.objects[0].Name == 'My, Contact 2' + +- name: rename and set display name of object - idempotent + object: + name: My, Contact 2 + identity: '{{ object_identity }}' + display_name: Display Name + state: present + type: contact + register: rename_again + +- name: assert rename and set display name of object - idempotent + assert: + that: + - not rename_again is changed + - rename_again.distinguished_name == 'CN=My\, Contact 2,' ~ setup_domain_info.output[0].defaultNamingContext + - rename_again.object_guid == object_identity + +- name: move and set description - check + object: + name: My, Contact 2 + identity: '{{ object_identity }}' + path: CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + description: My Description + state: present + type: contact + register: move_check + check_mode: true + +- name: move and set description - check + object_info: + identity: '{{ object_identity }}' + properties: + - description + - name + register: move_check_actual + +- name: assert move and set description - check + assert: + that: + - move_check is changed + - move_check.distinguished_name == 'CN=My\, Contact 2,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext + - move_check.object_guid == object_identity + - move_check_actual.objects[0].Description == None + - move_check_actual.objects[0].DistinguishedName == 'CN=My\, Contact 2,' ~ setup_domain_info.output[0].defaultNamingContext + - move_check_actual.objects[0].Name == 'My, Contact 2' + +- name: move and set description + object: + name: My, Contact 2 + identity: '{{ object_identity }}' + path: CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + description: My Description + state: present + type: contact + register: move + +- name: move and set description + object_info: + identity: '{{ object_identity }}' + properties: + - description + - name + register: move_actual + +- name: assert move and set description + assert: + that: + - move is changed + - move.distinguished_name == 'CN=My\, Contact 2,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext + - move.object_guid == object_identity + - move_actual.objects[0].Description == 'My Description' + - move_actual.objects[0].DistinguishedName == 'CN=My\, Contact 2,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext + - move_actual.objects[0].Name == 'My, Contact 2' + +- name: move and set description - idempotent + object: + name: My, Contact 2 + identity: '{{ object_identity }}' + path: CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + description: My Description + state: present + type: contact + register: move_again + +- name: assert move and set description - idempotent + assert: + that: + - not move_again is changed + - move_again.distinguished_name == 'CN=My\, Contact 2,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext + - move_again.object_guid == object_identity + +- name: rename and move - check + object: + name: '{{ object_name }}' + identity: '{{ object_identity }}' + path: '{{ setup_domain_info.output[0].defaultNamingContext }}' + state: present + type: contact + register: rename_and_move_check + check_mode: true + +- name: get result of rename and move - check + object_info: + identity: '{{ object_identity }}' + register: rename_and_move_check_actual + +- name: assert rename and move - check + assert: + that: + - rename_and_move_check is changed + - rename_and_move_check.distinguished_name == object_dn + - rename_and_move_check.object_guid == object_identity + - rename_and_move_check_actual.objects[0].DistinguishedName == 'CN=My\, Contact 2,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext + - rename_and_move_check_actual.objects[0].Name == 'My, Contact 2' + +- name: rename and move + object: + name: '{{ object_name }}' + identity: '{{ object_identity }}' + path: '{{ setup_domain_info.output[0].defaultNamingContext }}' + state: present + type: contact + register: rename_and_move + +- name: get result of rename and move + object_info: + identity: '{{ object_identity }}' + register: rename_and_move_actual + +- name: assert rename and move + assert: + that: + - rename_and_move is changed + - rename_and_move.distinguished_name == object_dn + - rename_and_move.object_guid == object_identity + - rename_and_move_actual.objects[0].DistinguishedName == object_dn + - rename_and_move_actual.objects[0].Name == object_name + +- name: remove object by name - check + object: + name: '{{ object_name }}' + state: absent + register: remove_check + check_mode: true + +- name: get result of remove by name - check + object_info: + identity: '{{ object_identity }}' + register: remove_check_actual + +- name: assert remove object by name - check + assert: + that: + - remove_check is changed + - remove_check.distinguished_name == object_dn + - remove_check.object_guid == object_identity + - remove_check_actual.objects | length == 1 + +- name: remove object by name + object: + name: '{{ object_name }}' + state: absent + register: remove + +- name: get result of remove by name + object_info: + identity: '{{ object_identity }}' + register: remove_actual + +- name: assert remove object by name - check + assert: + that: + - remove is changed + - remove.distinguished_name == object_dn + - remove.object_guid == object_identity + - remove_actual.objects == [] + +- name: remove object by name - idempotent + object: + name: '{{ object_name }}' + state: absent + register: remove_again + +- name: assert remove object by name - check + assert: + that: + - not remove_again is changed + - remove_again.distinguished_name == None + - remove_again.object_guid == None + +- name: create object protected from deletion + object: + name: My, Container + state: present + type: organizationalUnit + protect_from_deletion: true + register: create_deletion + +- set_fact: + object_identity: '{{ create_deletion.object_guid }}' + +- name: get result of create object protected from deletion + object_info: + identity: '{{ object_identity }}' + properties: + - ProtectedFromAccidentalDeletion + register: create_deletion_actual + +- name: assert create object protected from deletion + assert: + that: + - create_deletion is changed + - create_deletion.distinguished_name == 'OU=My\, Container,' ~ setup_domain_info.output[0].defaultNamingContext + - create_deletion_actual.objects[0].ProtectedFromAccidentalDeletion == true + +- name: unset protect from deletion + object: + name: My, Container + state: present + type: organizationalUnit + protect_from_deletion: false + register: unset_deletion + +- name: get result of unset protect from deletion + object_info: + identity: '{{ object_identity }}' + properties: + - ProtectedFromAccidentalDeletion + register: unset_deletion_actual + +- name: assert set protect from deletion + assert: + that: + - unset_deletion is changed + - unset_deletion.distinguished_name == 'OU=My\, Container,' ~ setup_domain_info.output[0].defaultNamingContext + - unset_deletion_actual.objects[0].ProtectedFromAccidentalDeletion == false + +- name: set protect from deletion + object: + name: My, Container + state: present + type: organizationalUnit + protect_from_deletion: true + register: set_deletion + +- name: get result of set protect from deletion + object_info: + identity: '{{ object_identity }}' + properties: + - ProtectedFromAccidentalDeletion + register: set_deletion_actual + +- name: assert set protect from deletion + assert: + that: + - set_deletion is changed + - set_deletion.distinguished_name == 'OU=My\, Container,' ~ setup_domain_info.output[0].defaultNamingContext + - set_deletion_actual.objects[0].ProtectedFromAccidentalDeletion == true + +- name: create sub ous for move test + ou: + name: '{{ item }}' + path: '{{ set_deletion.distinguished_name }}' + state: present + protect_from_deletion: true + register: sub_ous + loop: + - SubOU + - TestOU + +- name: move and rename object that is protected from deletion - check + object: + name: TestOU 2 + path: '{{ sub_ous.results[0].distinguished_name }}' + identity: '{{ sub_ous.results[1].object_guid }}' + type: organizationalUnit + register: move_ou_check + check_mode: true + +- name: get result of move and rename object that is protected from deletion - check + object_info: + identity: '{{ sub_ous.results[1].object_guid }}' + properties: + - ProtectedFromAccidentalDeletion + register: move_ou_check_actual + +- name: assert move and rename object that is protected from deletion - check + assert: + that: + - move_ou_check is changed + - move_ou_check.distinguished_name == 'OU=TestOU 2,' ~ sub_ous.results[0].distinguished_name + - move_ou_check_actual.objects[0].Name == 'TestOU' + - move_ou_check_actual.objects[0].DistinguishedName == sub_ous.results[1].distinguished_name + - move_ou_check_actual.objects[0].ProtectedFromAccidentalDeletion == true + +- name: move and rename object that is protected from deletion + object: + name: TestOU 2 + path: '{{ sub_ous.results[0].distinguished_name }}' + identity: '{{ sub_ous.results[1].object_guid }}' + type: organizationalUnit + register: move_ou + +- name: get result of move and rename object that is protected from deletion + object_info: + identity: '{{ sub_ous.results[1].object_guid }}' + properties: + - ProtectedFromAccidentalDeletion + register: move_ou_actual + +- name: assert move and rename object that is protected from deletion + assert: + that: + - move_ou is changed + - move_ou.distinguished_name == 'OU=TestOU 2,' ~ sub_ous.results[0].distinguished_name + - move_ou_actual.objects[0].Name == 'TestOU 2' + - move_ou_actual.objects[0].DistinguishedName == 'OU=TestOU 2,' ~ sub_ous.results[0].distinguished_name + - move_ou_actual.objects[0].ProtectedFromAccidentalDeletion == true + +- name: remove object that is protected from deletion - check + object: + name: My, Container + state: absent + type: organizationalUnit + register: remove_deletion_check + check_mode: true + +- name: get result of remove object that is protected from deletion - check + object_info: + identity: '{{ object_identity }}' + properties: + - ProtectedFromAccidentalDeletion + register: remove_deletion_actual_check + +- name: assert remove object that is protected from deletion - check + assert: + that: + - remove_deletion_check is changed + - remove_deletion_check.distinguished_name == 'OU=My\, Container,' ~ setup_domain_info.output[0].defaultNamingContext + - remove_deletion_actual_check.objects[0].ProtectedFromAccidentalDeletion == true + +- name: remove object that is protected from deletion + object: + name: My, Container + state: absent + type: organizationalUnit + register: remove_deletion + +- name: get result of remove object that is protected from deletion + object_info: + identity: '{{ object_identity }}' + register: remove_deletion_actual + +- name: assert remove object that is protected from deletion + assert: + that: + - remove_deletion is changed + - remove_deletion.distinguished_name == 'OU=My\, Container,' ~ setup_domain_info.output[0].defaultNamingContext + - remove_deletion_actual.objects == [] + +- name: create object with custom path, description, and display_name + object: + name: My, Container + description: Test Description + display_name: Display Name + path: CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + state: present + type: container + register: create_custom + +- set_fact: + object_identity: '{{ create_custom.object_guid }}' + +- name: get result of create object with custom path, description, and display_name + object_info: + identity: '{{ object_identity }}' + properties: + - description + - displayName + - objectClass + register: create_custom_actual + +- name: assert create object with custom path, description, and display_name + assert: + that: + - create_custom is changed + - create_custom.distinguished_name == 'CN=My\, Container,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext + - create_custom.object_guid == object_identity + - create_custom_actual.objects[0].Description == 'Test Description' + - create_custom_actual.objects[0].DisplayName == 'Display Name' + - create_custom_actual.objects[0].ObjectClass == 'container' + +- name: create child object in container with attributes + object: + name: Contact + path: CN=My\, Container,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + description: Test Description + display_name: Display Name + attributes: + add: + ansible-BoolSingle: false + ansible-BoolMulti: + - false + - true + ansible-BytesSingle: + type: bytes + value: Zm9v + ansible-BytesMulti: + - type: bytes + value: Zm9v + - type: bytes + value: YmFy + ansible-DateTimeSingle: + type: date_time + value: '1970-01-01T00:00:00Z' + ansible-DateTimeMulti: + - type: date_time + value: '1970-01-01T00:00:00+01:00' + - type: date_time + value: '1601-01-01T00:00:00.1-01:00' + ansible-IntSingle: 0 + ansible-IntMulti: + - 0 + - 1 + ansible-SDSingle: + type: security_descriptor + value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;WD) + ansible-SDMulti: + - type: security_descriptor + value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AN) + - type: security_descriptor + value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AU) + ansible-StringSingle: single + ansible-StringMulti: + - multi 1 + - multi 2 + type: contact + state: present + register: sub_object + +- name: get result of child object attributes + object_info: + identity: '{{ sub_object.object_guid }}' + properties: + - ansible-BoolSingle + - ansible-BoolMulti + - ansible-BytesSingle + - ansible-BytesMulti + - ansible-DateTimeSingle + - ansible-DateTimeMulti + - ansible-IntSingle + - ansible-IntMulti + - ansible-SDSingle + - ansible-SDMulti + - ansible-StringSingle + - ansible-StringMulti + register: sub_object_actual + +- name: assert create child object in container with attributes + assert: + that: + - sub_object is changed + - sub_object.distinguished_name == 'CN=Contact,CN=My\, Container,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext + - sub_object_actual.objects[0]['ansible-BoolSingle'] == False + - sub_object_actual.objects[0]['ansible-BoolMulti'] | length == 2 + - True in sub_object_actual.objects[0]['ansible-BoolMulti'] + - False in sub_object_actual.objects[0]['ansible-BoolMulti'] + - sub_object_actual.objects[0]['ansible-BytesSingle'] == "Zm9v" + - sub_object_actual.objects[0]['ansible-BytesMulti'] | length == 2 + - "'YmFy' in sub_object_actual.objects[0]['ansible-BytesMulti']" + - "'Zm9v' in sub_object_actual.objects[0]['ansible-BytesMulti']" + - sub_object_actual.objects[0]['ansible-DateTimeSingle'] == "1970-01-01T00:00:00.0000000Z" + - sub_object_actual.objects[0]['ansible-DateTimeMulti'] | length == 2 + - "'1601-01-01T01:00:00.0000000Z' in sub_object_actual.objects[0]['ansible-DateTimeMulti']" + - "'1969-12-31T23:00:00.0000000Z' in sub_object_actual.objects[0]['ansible-DateTimeMulti']" + - sub_object_actual.objects[0]['ansible-IntSingle'] == 0 + - sub_object_actual.objects[0]['ansible-IntMulti'] | length == 2 + - 0 in sub_object_actual.objects[0]['ansible-IntMulti'] + - 1 in sub_object_actual.objects[0]['ansible-IntMulti'] + - sub_object_actual.objects[0]['ansible-SDSingle'] == "O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;WD)" + - sub_object_actual.objects[0]['ansible-SDMulti'] | length == 2 + - "'O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AU)' in sub_object_actual.objects[0]['ansible-SDMulti']" + - "'O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AN)' in sub_object_actual.objects[0]['ansible-SDMulti']" + - sub_object_actual.objects[0]['ansible-StringSingle'] == "single" + - sub_object_actual.objects[0]['ansible-StringMulti'] | length == 2 + - "'multi 1' in sub_object_actual.objects[0]['ansible-StringMulti']" + - "'multi 2' in sub_object_actual.objects[0]['ansible-StringMulti']" + +- name: add attribute - check + object: + name: Contact + path: CN=My\, Container,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + type: contact + description: test description + display_name: display name + attributes: + add: + ansible-BoolMulti: + - false + - true + ansible-BytesMulti: + - type: bytes + value: Zm9v + - type: bytes + value: dGVzdA== + - type: bytes + value: Y2FmZQ== + ansible-DateTimeMulti: + - type: date_time + value: '1970-01-01T00:00:00+01:00' + - type: date_time + value: '1970-01-01T00:00:00' + - type: date_time + value: '2023-01-17T16:00:31+00:30' + ansible-IntMulti: + - 0 + - 2 + - 3 + ansible-SDMulti: + - type: security_descriptor + value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AN) + - type: security_descriptor + value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;DC) + - type: security_descriptor + value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;DG) + ansible-StringMulti: + - multi 1 + - '3' + - '4' + state: present + register: add_attr_check + check_mode: true + +- name: get result of add attribute - check + object_info: + identity: '{{ sub_object.object_guid }}' + properties: + - ansible-BoolMulti + - ansible-BytesMulti + - ansible-DateTimeMulti + - ansible-IntMulti + - ansible-SDMulti + - ansible-StringMulti + register: add_attr_check_actual + +- name: assert add attribute - check + assert: + that: + - add_attr_check is changed + - add_attr_check_actual.objects[0]['ansible-BoolMulti'] | length == 2 + - True in add_attr_check_actual.objects[0]['ansible-BoolMulti'] + - False in add_attr_check_actual.objects[0]['ansible-BoolMulti'] + - add_attr_check_actual.objects[0]['ansible-BytesMulti'] | length == 2 + - "'YmFy' in add_attr_check_actual.objects[0]['ansible-BytesMulti']" + - "'Zm9v' in add_attr_check_actual.objects[0]['ansible-BytesMulti']" + - add_attr_check_actual.objects[0]['ansible-DateTimeMulti'] | length == 2 + - "'1601-01-01T01:00:00.0000000Z' in add_attr_check_actual.objects[0]['ansible-DateTimeMulti']" + - "'1969-12-31T23:00:00.0000000Z' in add_attr_check_actual.objects[0]['ansible-DateTimeMulti']" + - add_attr_check_actual.objects[0]['ansible-IntMulti'] | length == 2 + - 0 in add_attr_check_actual.objects[0]['ansible-IntMulti'] + - 1 in add_attr_check_actual.objects[0]['ansible-IntMulti'] + - add_attr_check_actual.objects[0]['ansible-SDMulti'] | length == 2 + - "'O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AU)' in add_attr_check_actual.objects[0]['ansible-SDMulti']" + - "'O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AN)' in add_attr_check_actual.objects[0]['ansible-SDMulti']" + - add_attr_check_actual.objects[0]['ansible-StringMulti'] | length == 2 + - "'multi 1' in add_attr_check_actual.objects[0]['ansible-StringMulti']" + - "'multi 2' in add_attr_check_actual.objects[0]['ansible-StringMulti']" + +- name: add attribute + object: + name: Contact + path: CN=My\, Container,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + type: contact + description: test description + display_name: display name + attributes: + add: + ansible-BoolMulti: + - false + - true + ansible-BytesMulti: + - type: bytes + value: Zm9v + - type: bytes + value: dGVzdA== + - type: bytes + value: Y2FmZQ== + ansible-DateTimeMulti: + - type: date_time + value: '1970-01-01T00:00:00+01:00' + - type: date_time + value: '1970-01-01T00:00:00' + - type: date_time + value: '2023-01-17T16:00:31+00:30' + ansible-IntMulti: + - 0 + - 2 + - 3 + ansible-SDMulti: + - type: security_descriptor + value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AN) + - type: security_descriptor + value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;DC) + - type: security_descriptor + value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;DG) + ansible-StringMulti: + - multi 1 + - '3' + - '4' + state: present + register: add_attr + +- name: get result of add attribute + object_info: + identity: '{{ sub_object.object_guid }}' + properties: + - ansible-BoolMulti + - ansible-BytesMulti + - ansible-DateTimeMulti + - ansible-IntMulti + - ansible-SDMulti + - ansible-StringMulti + register: add_attr_actual + +- name: assert add attribute + assert: + that: + - add_attr is changed + - add_attr_actual.objects[0]['ansible-BoolMulti'] | length == 2 + - True in add_attr_actual.objects[0]['ansible-BoolMulti'] + - False in add_attr_actual.objects[0]['ansible-BoolMulti'] + - add_attr_actual.objects[0]['ansible-BytesMulti'] | length == 4 + - "'YmFy' in add_attr_actual.objects[0]['ansible-BytesMulti']" + - "'Zm9v' in add_attr_actual.objects[0]['ansible-BytesMulti']" + - "'dGVzdA==' in add_attr_actual.objects[0]['ansible-BytesMulti']" + - "'Y2FmZQ==' in add_attr_actual.objects[0]['ansible-BytesMulti']" + - add_attr_actual.objects[0]['ansible-DateTimeMulti'] | length == 4 + - "'1601-01-01T01:00:00.0000000Z' in add_attr_actual.objects[0]['ansible-DateTimeMulti']" + - "'1969-12-31T23:00:00.0000000Z' in add_attr_actual.objects[0]['ansible-DateTimeMulti']" + - "'1970-01-01T00:00:00.0000000Z' in add_attr_actual.objects[0]['ansible-DateTimeMulti']" + - "'2023-01-17T15:30:31.0000000Z' in add_attr_actual.objects[0]['ansible-DateTimeMulti']" + - add_attr_actual.objects[0]['ansible-IntMulti'] | length == 4 + - 0 in add_attr_actual.objects[0]['ansible-IntMulti'] + - 1 in add_attr_actual.objects[0]['ansible-IntMulti'] + - 2 in add_attr_actual.objects[0]['ansible-IntMulti'] + - 3 in add_attr_actual.objects[0]['ansible-IntMulti'] + - add_attr_actual.objects[0]['ansible-SDMulti'] | length == 4 + - "'O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AU)' in add_attr_actual.objects[0]['ansible-SDMulti']" + - "'O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AN)' in add_attr_actual.objects[0]['ansible-SDMulti']" + - "'O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;DC)' in add_attr_actual.objects[0]['ansible-SDMulti']" + - "'O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;DG)' in add_attr_actual.objects[0]['ansible-SDMulti']" + - add_attr_actual.objects[0]['ansible-StringMulti'] | length == 4 + - "'multi 1' in add_attr_actual.objects[0]['ansible-StringMulti']" + - "'multi 2' in add_attr_actual.objects[0]['ansible-StringMulti']" + - "'3' in add_attr_actual.objects[0]['ansible-StringMulti']" + - "'4' in add_attr_actual.objects[0]['ansible-StringMulti']" + +- name: add attribute - idempotent + object: + name: Contact + path: CN=My\, Container,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + type: contact + description: test description + display_name: display name + attributes: + add: + ansible-BoolMulti: + - false + - true + ansible-BytesMulti: + - type: bytes + value: Zm9v + - type: bytes + value: dGVzdA== + - type: bytes + value: Y2FmZQ== + ansible-DateTimeMulti: + - type: date_time + value: '1970-01-01T00:00:00+01:00' + - type: date_time + value: '1970-01-01T00:00:00' + - type: date_time + value: '2023-01-17T16:00:31+00:30' + ansible-IntMulti: + - 0 + - 2 + - 3 + ansible-SDMulti: + - type: security_descriptor + value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AN) + - type: security_descriptor + value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;DC) + - type: security_descriptor + value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;DG) + ansible-StringMulti: + - multi 1 + - '3' + - '4' + state: present + register: add_attr_again + +- name: assert add attribute - idempotent + assert: + that: + - not add_attr_again is changed + +- name: remove attribute - check + object: + name: Contact + path: CN=My\, Container,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + type: contact + attributes: + remove: + ansible-BoolMulti: + - type: bool + value: '' # Will be false when casted + ansible-BytesMulti: + - type: bytes + value: dGVzdA== + - type: bytes + value: Y2FmZQ== + ansible-DateTimeMulti: + - type: date_time + value: '1970-01-01T00:00:00' + - type: date_time + value: '2023-01-17T16:00:31+00:30' + ansible-IntMulti: + - type: int + value: '2' + - type: raw + value: 3 + ansible-SDMulti: + - type: security_descriptor + value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;DC) + - type: security_descriptor + value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;DG) + ansible-StringMulti: + - multi 1 + - type: string + value: 3 + - type: raw + value: '4' + state: present + register: remove_attr_check + check_mode: true + +- name: get result of remove attribute - check + object_info: + identity: '{{ sub_object.object_guid }}' + properties: + - ansible-BoolMulti + - ansible-BytesMulti + - ansible-DateTimeMulti + - ansible-IntMulti + - ansible-SDMulti + - ansible-StringMulti + register: remove_attr_check_actual + +- name: assert remove attribute - check + assert: + that: + - remove_attr_check is changed + - remove_attr_check_actual.objects[0]['ansible-BoolMulti'] | length == 2 + - True in remove_attr_check_actual.objects[0]['ansible-BoolMulti'] + - False in remove_attr_check_actual.objects[0]['ansible-BoolMulti'] + - remove_attr_check_actual.objects[0]['ansible-BytesMulti'] | length == 4 + - "'YmFy' in remove_attr_check_actual.objects[0]['ansible-BytesMulti']" + - "'Zm9v' in remove_attr_check_actual.objects[0]['ansible-BytesMulti']" + - "'dGVzdA==' in remove_attr_check_actual.objects[0]['ansible-BytesMulti']" + - "'Y2FmZQ==' in remove_attr_check_actual.objects[0]['ansible-BytesMulti']" + - remove_attr_check_actual.objects[0]['ansible-DateTimeMulti'] | length == 4 + - "'1601-01-01T01:00:00.0000000Z' in remove_attr_check_actual.objects[0]['ansible-DateTimeMulti']" + - "'1969-12-31T23:00:00.0000000Z' in remove_attr_check_actual.objects[0]['ansible-DateTimeMulti']" + - "'1970-01-01T00:00:00.0000000Z' in remove_attr_check_actual.objects[0]['ansible-DateTimeMulti']" + - "'2023-01-17T15:30:31.0000000Z' in remove_attr_check_actual.objects[0]['ansible-DateTimeMulti']" + - remove_attr_check_actual.objects[0]['ansible-IntMulti'] | length == 4 + - 0 in remove_attr_check_actual.objects[0]['ansible-IntMulti'] + - 1 in remove_attr_check_actual.objects[0]['ansible-IntMulti'] + - 2 in remove_attr_check_actual.objects[0]['ansible-IntMulti'] + - 3 in remove_attr_check_actual.objects[0]['ansible-IntMulti'] + - remove_attr_check_actual.objects[0]['ansible-SDMulti'] | length == 4 + - "'O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AU)' in remove_attr_check_actual.objects[0]['ansible-SDMulti']" + - "'O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AN)' in remove_attr_check_actual.objects[0]['ansible-SDMulti']" + - "'O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;DC)' in remove_attr_check_actual.objects[0]['ansible-SDMulti']" + - "'O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;DG)' in remove_attr_check_actual.objects[0]['ansible-SDMulti']" + - remove_attr_check_actual.objects[0]['ansible-StringMulti'] | length == 4 + - "'multi 1' in remove_attr_check_actual.objects[0]['ansible-StringMulti']" + - "'multi 2' in remove_attr_check_actual.objects[0]['ansible-StringMulti']" + - "'3' in remove_attr_check_actual.objects[0]['ansible-StringMulti']" + - "'4' in remove_attr_check_actual.objects[0]['ansible-StringMulti']" + +- name: remove attribute + object: + name: Contact + path: CN=My\, Container,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + type: contact + attributes: + remove: + ansible-BoolMulti: + - type: bool + value: '' # Will be false when casted + ansible-BytesMulti: + - type: bytes + value: dGVzdA== + - type: bytes + value: Y2FmZQ== + ansible-DateTimeMulti: + - type: date_time + value: '1970-01-01T00:00:00' + - type: date_time + value: '2023-01-17T16:00:31+00:30' + ansible-IntMulti: + - type: int + value: '2' + - type: raw + value: 3 + ansible-SDMulti: + - type: security_descriptor + value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;DC) + - type: security_descriptor + value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;DG) + ansible-StringMulti: + - type: string + value: 3 + - type: raw + value: '4' + state: present + register: remove_attr + +- name: get result of remove attribute + object_info: + identity: '{{ sub_object.object_guid }}' + properties: + - ansible-BoolMulti + - ansible-BytesMulti + - ansible-DateTimeMulti + - ansible-IntMulti + - ansible-SDMulti + - ansible-StringMulti + register: remove_attr_actual + +- name: assert remove attribute + assert: + that: + - remove_attr is changed + - remove_attr_actual.objects[0]['ansible-BoolMulti'] == True + - remove_attr_actual.objects[0]['ansible-BytesMulti'] | length == 2 + - "'YmFy' in remove_attr_actual.objects[0]['ansible-BytesMulti']" + - "'Zm9v' in remove_attr_actual.objects[0]['ansible-BytesMulti']" + - remove_attr_actual.objects[0]['ansible-DateTimeMulti'] | length == 2 + - "'1601-01-01T01:00:00.0000000Z' in remove_attr_actual.objects[0]['ansible-DateTimeMulti']" + - "'1969-12-31T23:00:00.0000000Z' in remove_attr_actual.objects[0]['ansible-DateTimeMulti']" + - remove_attr_actual.objects[0]['ansible-IntMulti'] | length == 2 + - 0 in remove_attr_actual.objects[0]['ansible-IntMulti'] + - 1 in remove_attr_actual.objects[0]['ansible-IntMulti'] + - remove_attr_actual.objects[0]['ansible-SDMulti'] | length == 2 + - "'O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AU)' in remove_attr_actual.objects[0]['ansible-SDMulti']" + - "'O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AN)' in remove_attr_actual.objects[0]['ansible-SDMulti']" + - remove_attr_actual.objects[0]['ansible-StringMulti'] | length == 2 + - "'multi 1' in remove_attr_actual.objects[0]['ansible-StringMulti']" + - "'multi 2' in remove_attr_actual.objects[0]['ansible-StringMulti']" + +- name: remove attribute - idempotent + object: + name: Contact + path: CN=My\, Container,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + type: contact + attributes: + remove: + ansible-BoolMulti: + - type: bool + value: '' # Will be false when casted + ansible-BytesMulti: + - type: bytes + value: dGVzdA== + - type: bytes + value: Y2FmZQ== + ansible-DateTimeMulti: + - type: date_time + value: '1970-01-01T00:00:00' + - type: date_time + value: '2023-01-17T16:00:31+00:30' + ansible-IntMulti: + - type: int + value: '2' + - type: raw + value: 3 + ansible-SDMulti: + - type: security_descriptor + value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;DC) + - type: security_descriptor + value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;DG) + ansible-StringMulti: + - type: string + value: 3 + - type: raw + value: '4' + state: present + register: remove_attr_again + +- name: assert remove attribute - idempotent + assert: + that: + - not remove_attr_again is changed + +- name: set attribute - check + object: + name: Contact + path: CN=My\, Container,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + type: contact + attributes: + set: + ansible-BoolSingle: true + ansible-BoolMulti: false + ansible-BytesSingle: + type: bytes + value: YmFy + ansible-BytesMulti: + - type: bytes + value: dGVzdA== + - type: bytes + value: YmFy + ansible-DateTimeSingle: + type: date_time + value: '1601-01-01T00:00:00Z' + ansible-DateTimeMulti: + - type: date_time + value: '1970-01-01T00:00:00' + - type: date_time + value: '1601-01-01T00:01:00.1-02:00' + ansible-IntSingle: 1 + ansible-IntMulti: + - 0 + - 3 + ansible-SDSingle: + type: security_descriptor + value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AN) + ansible-SDMulti: + - type: security_descriptor + value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;DC) + - type: security_descriptor + value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AU) + ansible-StringSingle: Single + ansible-StringMulti: + - multi 1 + - multi 3 + state: present + register: set_attr_check + check_mode: true + +- name: get result of set attribute - check + object_info: + identity: '{{ sub_object.object_guid }}' + properties: + - ansible-BoolSingle + - ansible-BoolMulti + - ansible-BytesSingle + - ansible-BytesMulti + - ansible-DateTimeSingle + - ansible-DateTimeMulti + - ansible-IntSingle + - ansible-IntMulti + - ansible-SDSingle + - ansible-SDMulti + - ansible-StringSingle + - ansible-StringMulti + register: set_attr_check_actual + +- name: assert set attribute - check + assert: + that: + - set_attr_check is changed + - set_attr_check_actual.objects[0]['ansible-BoolSingle'] == False + - set_attr_check_actual.objects[0]['ansible-BoolMulti'] == True + - set_attr_check_actual.objects[0]['ansible-BytesSingle'] == "Zm9v" + - set_attr_check_actual.objects[0]['ansible-BytesMulti'] | length == 2 + - "'YmFy' in set_attr_check_actual.objects[0]['ansible-BytesMulti']" + - "'Zm9v' in set_attr_check_actual.objects[0]['ansible-BytesMulti']" + - set_attr_check_actual.objects[0]['ansible-DateTimeSingle'] == "1970-01-01T00:00:00.0000000Z" + - set_attr_check_actual.objects[0]['ansible-DateTimeMulti'] | length == 2 + - "'1601-01-01T01:00:00.0000000Z' in set_attr_check_actual.objects[0]['ansible-DateTimeMulti']" + - "'1969-12-31T23:00:00.0000000Z' in set_attr_check_actual.objects[0]['ansible-DateTimeMulti']" + - set_attr_check_actual.objects[0]['ansible-IntSingle'] == 0 + - set_attr_check_actual.objects[0]['ansible-IntMulti'] | length == 2 + - 0 in set_attr_check_actual.objects[0]['ansible-IntMulti'] + - 1 in set_attr_check_actual.objects[0]['ansible-IntMulti'] + - set_attr_check_actual.objects[0]['ansible-SDSingle'] == "O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;WD)" + - set_attr_check_actual.objects[0]['ansible-SDMulti'] | length == 2 + - "'O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AU)' in set_attr_check_actual.objects[0]['ansible-SDMulti']" + - "'O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AN)' in set_attr_check_actual.objects[0]['ansible-SDMulti']" + - set_attr_check_actual.objects[0]['ansible-StringSingle'] == "single" + - set_attr_check_actual.objects[0]['ansible-StringMulti'] | length == 2 + - "'multi 1' in set_attr_check_actual.objects[0]['ansible-StringMulti']" + - "'multi 2' in set_attr_check_actual.objects[0]['ansible-StringMulti']" + +- name: set attribute + object: + name: Contact + path: CN=My\, Container,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + type: contact + attributes: + set: + ansible-BoolSingle: true + ansible-BoolMulti: false + ansible-BytesSingle: + type: bytes + value: YmFy + ansible-BytesMulti: + - type: bytes + value: dGVzdA== + - type: bytes + value: YmFy + ansible-DateTimeSingle: + type: date_time + value: '1601-01-01T00:00:00Z' + ansible-DateTimeMulti: + - type: date_time + value: '1970-01-01T00:00:00' + - type: date_time + value: '1601-01-01T00:01:00.0-02:00' + ansible-IntSingle: 1 + ansible-IntMulti: + - 0 + - 3 + ansible-SDSingle: + type: security_descriptor + value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AN) + ansible-SDMulti: + - type: security_descriptor + value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;DC) + - type: security_descriptor + value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AU) + ansible-StringSingle: Single + ansible-StringMulti: + - multi 1 + - multi 3 + state: present + register: set_attr + +- name: get result of set attribute + object_info: + identity: '{{ sub_object.object_guid }}' + properties: + - ansible-BoolSingle + - ansible-BoolMulti + - ansible-BytesSingle + - ansible-BytesMulti + - ansible-DateTimeSingle + - ansible-DateTimeMulti + - ansible-IntSingle + - ansible-IntMulti + - ansible-SDSingle + - ansible-SDMulti + - ansible-StringSingle + - ansible-StringMulti + register: set_attr_actual + +- name: assert set attribute + assert: + that: + - set_attr is changed + - set_attr_actual.objects[0]['ansible-BoolSingle'] == True + - set_attr_actual.objects[0]['ansible-BoolMulti'] == False + - set_attr_actual.objects[0]['ansible-BytesSingle'] == "YmFy" + - set_attr_actual.objects[0]['ansible-BytesMulti'] | length == 2 + - "'YmFy' in set_attr_actual.objects[0]['ansible-BytesMulti']" + - "'dGVzdA==' in set_attr_actual.objects[0]['ansible-BytesMulti']" + - set_attr_actual.objects[0]['ansible-DateTimeSingle'] == "1601-01-01T00:00:00.0000000Z" + - set_attr_actual.objects[0]['ansible-DateTimeMulti'] | length == 2 + - "'1601-01-01T02:01:00.0000000Z' in set_attr_actual.objects[0]['ansible-DateTimeMulti']" + - "'1970-01-01T00:00:00.0000000Z' in set_attr_actual.objects[0]['ansible-DateTimeMulti']" + - set_attr_actual.objects[0]['ansible-IntSingle'] == 1 + - set_attr_actual.objects[0]['ansible-IntMulti'] | length == 2 + - 0 in set_attr_actual.objects[0]['ansible-IntMulti'] + - 3 in set_attr_actual.objects[0]['ansible-IntMulti'] + - set_attr_actual.objects[0]['ansible-SDSingle'] == "O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AN)" + - set_attr_actual.objects[0]['ansible-SDMulti'] | length == 2 + - "'O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AU)' in set_attr_actual.objects[0]['ansible-SDMulti']" + - "'O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;DC)' in set_attr_actual.objects[0]['ansible-SDMulti']" + - set_attr_actual.objects[0]['ansible-StringSingle'] == "Single" + - set_attr_actual.objects[0]['ansible-StringMulti'] | length == 2 + - "'multi 1' in set_attr_actual.objects[0]['ansible-StringMulti']" + - "'multi 3' in set_attr_actual.objects[0]['ansible-StringMulti']" + +- name: set attribute - idempotent + object: + name: Contact + path: CN=My\, Container,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + type: contact + attributes: + set: + ansible-BoolSingle: true + ansible-BoolMulti: false + ansible-BytesSingle: + type: bytes + value: YmFy + ansible-BytesMulti: + - type: bytes + value: dGVzdA== + - type: bytes + value: YmFy + ansible-DateTimeSingle: + type: date_time + value: '1601-01-01T00:00:00Z' + ansible-DateTimeMulti: + - type: date_time + value: '1970-01-01T00:00:00' + - type: date_time + value: '1601-01-01T00:01:00.0-02:00' + ansible-IntSingle: 1 + ansible-IntMulti: + - 0 + - 3 + ansible-SDSingle: + type: security_descriptor + value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AN) + ansible-SDMulti: + - type: security_descriptor + value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;DC) + - type: security_descriptor + value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AU) + ansible-StringSingle: Single + ansible-StringMulti: + - multi 1 + - multi 3 + state: present + register: set_attr_again + +- name: assert set attribute - idempotent + assert: + that: + - not set_attr_again is changed + +- name: clear attribute - check + object: + name: Contact + path: CN=My\, Container,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + type: contact + attributes: + set: + ansible-BoolSingle: + ansible-BoolMulti: + ansible-BytesSingle: [] + ansible-BytesMulti: [] + ansible-DateTimeSingle: ~ + ansible-DateTimeMulti: ~ + ansible-IntSingle: + ansible-IntMulti: + ansible-SDSingle: + ansible-SDMulti: + ansible-StringSingle: + ansible-StringMulti: + state: present + register: clear_attr_check + check_mode: true + +- name: get result of clear attribute - check + object_info: + identity: '{{ sub_object.object_guid }}' + properties: + - ansible-BoolSingle + - ansible-BoolMulti + - ansible-BytesSingle + - ansible-BytesMulti + - ansible-DateTimeSingle + - ansible-DateTimeMulti + - ansible-IntSingle + - ansible-IntMulti + - ansible-SDSingle + - ansible-SDMulti + - ansible-StringSingle + - ansible-StringMulti + register: clear_attr_check_actual + +- name: assert clear attribute - check + assert: + that: + - clear_attr_check is changed + - clear_attr_check_actual.objects[0]['ansible-BoolSingle'] == True + - clear_attr_check_actual.objects[0]['ansible-BoolMulti'] == False + - clear_attr_check_actual.objects[0]['ansible-BytesSingle'] == "YmFy" + - clear_attr_check_actual.objects[0]['ansible-BytesMulti'] | length == 2 + - "'YmFy' in clear_attr_check_actual.objects[0]['ansible-BytesMulti']" + - "'dGVzdA==' in clear_attr_check_actual.objects[0]['ansible-BytesMulti']" + - clear_attr_check_actual.objects[0]['ansible-DateTimeSingle'] == "1601-01-01T00:00:00.0000000Z" + - clear_attr_check_actual.objects[0]['ansible-DateTimeMulti'] | length == 2 + - "'1601-01-01T02:01:00.0000000Z' in clear_attr_check_actual.objects[0]['ansible-DateTimeMulti']" + - "'1970-01-01T00:00:00.0000000Z' in clear_attr_check_actual.objects[0]['ansible-DateTimeMulti']" + - clear_attr_check_actual.objects[0]['ansible-IntSingle'] == 1 + - clear_attr_check_actual.objects[0]['ansible-IntMulti'] | length == 2 + - 0 in clear_attr_check_actual.objects[0]['ansible-IntMulti'] + - 3 in clear_attr_check_actual.objects[0]['ansible-IntMulti'] + - clear_attr_check_actual.objects[0]['ansible-SDSingle'] == "O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AN)" + - clear_attr_check_actual.objects[0]['ansible-SDMulti'] | length == 2 + - "'O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AU)' in clear_attr_check_actual.objects[0]['ansible-SDMulti']" + - "'O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;DC)' in clear_attr_check_actual.objects[0]['ansible-SDMulti']" + - clear_attr_check_actual.objects[0]['ansible-StringSingle'] == "Single" + - clear_attr_check_actual.objects[0]['ansible-StringMulti'] | length == 2 + - "'multi 1' in clear_attr_check_actual.objects[0]['ansible-StringMulti']" + - "'multi 3' in clear_attr_check_actual.objects[0]['ansible-StringMulti']" + +- name: clear attribute + object: + name: Contact + path: CN=My\, Container,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + type: contact + attributes: + set: + ansible-BoolSingle: + ansible-BoolMulti: + ansible-BytesSingle: [] + ansible-BytesMulti: [] + ansible-DateTimeSingle: ~ + ansible-DateTimeMulti: ~ + ansible-IntSingle: + ansible-IntMulti: + ansible-SDSingle: + ansible-SDMulti: + ansible-StringSingle: + ansible-StringMulti: + state: present + register: clear_attr + +- name: get result of clear attribute + object_info: + identity: '{{ sub_object.object_guid }}' + properties: + - ansible-BoolSingle + - ansible-BoolMulti + - ansible-BytesSingle + - ansible-BytesMulti + - ansible-DateTimeSingle + - ansible-DateTimeMulti + - ansible-IntSingle + - ansible-IntMulti + - ansible-SDSingle + - ansible-SDMulti + - ansible-StringSingle + - ansible-StringMulti + register: clear_attr_actual + +- name: assert clear attribute + assert: + that: + - clear_attr is changed + - clear_attr_actual.objects[0]['ansible-BoolSingle'] == None + - clear_attr_actual.objects[0]['ansible-BoolMulti'] == None + - clear_attr_actual.objects[0]['ansible-BytesSingle'] == None + - clear_attr_actual.objects[0]['ansible-BytesMulti'] == None + - clear_attr_actual.objects[0]['ansible-DateTimeSingle'] == None + - clear_attr_actual.objects[0]['ansible-DateTimeMulti'] == None + - clear_attr_actual.objects[0]['ansible-IntSingle'] == None + - clear_attr_actual.objects[0]['ansible-IntMulti'] == None + - clear_attr_actual.objects[0]['ansible-SDSingle'] == None + - clear_attr_actual.objects[0]['ansible-SDMulti'] == None + - clear_attr_actual.objects[0]['ansible-StringSingle'] == None + - clear_attr_actual.objects[0]['ansible-StringMulti'] == None + +- name: clear attribute - idempotent + object: + name: Contact + path: CN=My\, Container,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + type: contact + attributes: + set: + ansible-BoolSingle: + ansible-BoolMulti: + ansible-BytesSingle: [] + ansible-BytesMulti: [] + ansible-DateTimeSingle: ~ + ansible-DateTimeMulti: ~ + ansible-IntSingle: + ansible-IntMulti: + ansible-SDSingle: + ansible-SDMulti: + ansible-StringSingle: + ansible-StringMulti: + state: present + register: clear_attr_again + +- name: assert clear attribute - idempotent + assert: + that: + - not clear_attr_again is changed + +- name: unset display and description + object: + name: Contact + path: CN=My\, Container,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + type: contact + display_name: '' + description: '' + register: unset_normal + +- name: get result of unset display and description + object_info: + identity: '{{ sub_object.object_guid }}' + properties: + - DisplayName + - Description + register: unset_normal_actual + +- name: assert unset display and description + assert: + that: + - unset_normal is changed + - unset_normal_actual.objects[0].DisplayName == None + - unset_normal_actual.objects[0].Description == None + +- name: unset display and description - idempotent + object: + name: Contact + path: CN=My\, Container,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + type: contact + display_name: '' + description: '' + register: unset_normal_again + +- name: assert unset display and description - idempotent + assert: + that: + - not unset_normal_again is changed diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/object_info/aliases b/ansible_collections/microsoft/ad/tests/integration/targets/object_info/aliases new file mode 100644 index 000000000..ccd8a25e8 --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/object_info/aliases @@ -0,0 +1,2 @@ +windows +shippable/windows/group1 diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/object_info/handlers/main.yml b/ansible_collections/microsoft/ad/tests/integration/targets/object_info/handlers/main.yml new file mode 100644 index 000000000..fa30dcbb3 --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/object_info/handlers/main.yml @@ -0,0 +1,4 @@ +- name: remove test domain user + microsoft.ad.user: + name: '{{ test_user.distinguished_name }}' + state: absent diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/object_info/meta/main.yml b/ansible_collections/microsoft/ad/tests/integration/targets/object_info/meta/main.yml new file mode 100644 index 000000000..4ce45dcfb --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/object_info/meta/main.yml @@ -0,0 +1,2 @@ +dependencies: +- setup_domain diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/object_info/tasks/main.yml b/ansible_collections/microsoft/ad/tests/integration/targets/object_info/tasks/main.yml new file mode 100644 index 000000000..01cee436b --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/object_info/tasks/main.yml @@ -0,0 +1,186 @@ +- name: create test ad user + microsoft.ad.user: + name: Ansible Test + firstname: Ansible + surname: Test + company: Contoso R Us + password: Password01 + state: present + password_never_expires: yes + groups: + set: + - Domain Users + enabled: false + register: test_user + notify: remove test domain user + +- name: set a binary attribute and return other useful info missing from above + ansible.windows.win_powershell: + parameters: + SecurityId: '{{ test_user.sid }}' + script: | + param($SecurityId) + + Set-ADUser -Identity $SecurityId -Replace @{ audio = @([byte[]]@(1, 2, 3, 4), [byte[]]@(5, 6, 7, 8)) } + + $user = Get-ADUser -Identity $SecurityId -Properties modifyTimestamp, ObjectGUID + + [TimeZoneInfo]::ConvertTimeToUtc($user.modifyTimestamp).ToString('o') + $user.ObjectGUID.ToString() + ([System.Security.Principal.SecurityIdentifier]$SecurityId).Translate([System.Security.Principal.NTAccount]).Value + register: test_user_extras + +- name: set other test info for easier access + set_fact: + test_user_mod_date: '{{ test_user_extras.output[0] }}' + test_user_id: '{{ test_user_extras.output[1] }}' + test_user_name: '{{ test_user_extras.output[2] }}' + +- name: get properties for single user by DN + object_info: + identity: '{{ test_user.distinguished_name }}' + register: by_identity + check_mode: yes # Just verifies it runs in check mode + +- name: assert get properties for single user by DN + assert: + that: + - not by_identity is changed + - by_identity.objects | length == 1 + - by_identity.objects[0].keys() | list | length == 4 + - by_identity.objects[0].DistinguishedName == test_user.distinguished_name + - by_identity.objects[0].Name == 'Ansible Test' + - by_identity.objects[0].ObjectClass == 'user' + - by_identity.objects[0].ObjectGUID == test_user_id + +- name: get specific properties by GUID + object_info: + identity: '{{ test_user_id }}' + properties: + - audio # byte[] + - company # string + - department # not set + - logonCount # int + - modifyTimestamp # DateTime + - nTSecurityDescriptor # SecurityDescriptor as SDDL + - objectSID # SID + - ProtectedFromAccidentalDeletion # bool + - sAMAccountType # Test out the enum string attribute that we add + - userAccountControl # Test ou the enum string attribute that we add + register: by_guid_custom_props + +- name: assert get specific properties by GUID + assert: + that: + - not by_guid_custom_props is changed + - by_guid_custom_props.objects | length == 1 + - by_guid_custom_props.objects[0].DistinguishedName == test_user.distinguished_name + - by_guid_custom_props.objects[0].Name == 'Ansible Test' + - by_guid_custom_props.objects[0].ObjectClass == 'user' + - by_guid_custom_props.objects[0].ObjectGUID == test_user_id + - not by_guid_custom_props.objects[0].ProtectedFromAccidentalDeletion + - by_guid_custom_props.objects[0].audio == ['BQYHCA==', 'AQIDBA=='] + - by_guid_custom_props.objects[0].company == 'Contoso R Us' + - by_guid_custom_props.objects[0].department == None + - by_guid_custom_props.objects[0].logonCount == 0 + - by_guid_custom_props.objects[0].modifyTimestamp == test_user_mod_date + - by_guid_custom_props.objects[0].nTSecurityDescriptor.startswith('O:') + - by_guid_custom_props.objects[0].objectSID.Name == test_user_name + - by_guid_custom_props.objects[0].objectSID.Sid == test_user.sid + - by_guid_custom_props.objects[0].sAMAccountType == 805306368 + - by_guid_custom_props.objects[0].sAMAccountType_AnsibleFlags == ['SAM_USER_OBJECT'] + - by_guid_custom_props.objects[0].userAccountControl == 66050 + - by_guid_custom_props.objects[0].userAccountControl_AnsibleFlags == ['ADS_UF_ACCOUNTDISABLE', 'ADS_UF_NORMAL_ACCOUNT', 'ADS_UF_DONT_EXPIRE_PASSWD'] + +- name: get the groupType attribute + object_info: + filter: sAMAccountName -eq 'Domain Admins' + properties: + - groupType + register: group_type_prop + +- name: assert get the groupType attribute + assert: + that: + - group_type_prop.objects[0].groupType_AnsibleFlags == ["GROUP_TYPE_ACCOUNT_GROUP", "GROUP_TYPE_SECURITY_ENABLED"] + +- name: create computer object for enc type test + computer: + name: MyComputer + state: present + kerberos_encryption_types: + set: + - des + - rc4 + - aes128 + - aes256 + register: comp_info + +- block: + - name: get the supported encryption type attribute + object_info: + identity: '{{ comp_info.object_guid }}' + properties: + - msDS-SupportedEncryptionTypes + register: enc_type_prop + + - name: assert get the supported encryption type attribute + assert: + that: + - enc_type_prop.objects[0]["msDS-SupportedEncryptionTypes"] == 31 + - enc_type_prop.objects[0]["msDS-SupportedEncryptionTypes_AnsibleFlags"] == ["DES_CBC_CRC", "DES_CBC_MD5", "RC4_HMAC", "AES128_CTS_HMAC_SHA1_96", "AES256_CTS_HMAC_SHA1_96"] + + always: + - name: remove computer object + computer: + name: MyComputer + identity: '{{ comp_info.object_guid }}' + state: absent + +- name: get invalid property + object_info: + filter: sAMAccountName -eq 'Ansible Test' + properties: + - FakeProperty + register: invalid_prop_warning + +- name: assert get invalid property + assert: + that: + - not invalid_prop_warning is changed + - invalid_prop_warning.objects | length == 0 + - invalid_prop_warning.warnings | length == 1 + - '"Failed to retrieve properties for AD object" not in invalid_prop_warning.warnings[0]' + +- name: get by ldap filter returning multiple + object_info: + ldap_filter: (&(objectClass=computer)(objectCategory=computer)) + properties: '*' + register: multiple_ldap + +- name: assert get by ldap filter returning multiple + assert: + that: + - not multiple_ldap is changed + - multiple_ldap.objects | length > 0 + +- name: get by filter returning multiple + object_info: + filter: objectClass -eq 'computer' -and objectCategory -eq 'computer' + properties: '*' + register: multiple_filter + +- name: assert get by filter returning multiple + assert: + that: + - not multiple_filter is changed + - multiple_filter.objects | length > 0 + +- name: fail trying to use variable in filter + object_info: + filter: sAMAccountName -eq $domainUsername + properties: + - sAMAccountName + register: fail_filter_var + failed_when: + - '"Variable: ''domainUsername'' found in expression: $domainUsername is not defined." not in fail_filter_var.msg' diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/offline_join/aliases b/ansible_collections/microsoft/ad/tests/integration/targets/offline_join/aliases new file mode 100644 index 000000000..ccd8a25e8 --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/offline_join/aliases @@ -0,0 +1,2 @@ +windows +shippable/windows/group1 diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/offline_join/meta/main.yml b/ansible_collections/microsoft/ad/tests/integration/targets/offline_join/meta/main.yml new file mode 100644 index 000000000..4ce45dcfb --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/offline_join/meta/main.yml @@ -0,0 +1,2 @@ +dependencies: +- setup_domain diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/offline_join/tasks/main.yml b/ansible_collections/microsoft/ad/tests/integration/targets/offline_join/tasks/main.yml new file mode 100644 index 000000000..87f814655 --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/offline_join/tasks/main.yml @@ -0,0 +1,14 @@ +- name: remove temp computer + computer: + name: My, Computer + state: absent + +- block: + - import_tasks: tests.yml + + always: + - name: remove temp computer + computer: + name: My, Computer + identity: '{{ object_identity | default(omit) }}' + state: absent diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/offline_join/tasks/tests.yml b/ansible_collections/microsoft/ad/tests/integration/targets/offline_join/tasks/tests.yml new file mode 100644 index 000000000..c16237d40 --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/offline_join/tasks/tests.yml @@ -0,0 +1,150 @@ +- name: create computer account + computer: + name: My, Computer + state: present + sam_account_name: MyComputer + register: comp_account + +- set_fact: + object_identity: '{{ comp_account.object_guid }}' + +- name: get initial password info + object_info: + identity: '{{ object_identity }}' + properties: + - pwdLastSet + register: pwd_info + +- name: get blob - check + offline_join: + name: My, Computer + register: blob_check + check_mode: true + +- name: get result of get blob - check + object_info: + identity: '{{ object_identity }}' + properties: + - pwdLastSet + register: blob_check_actual + +- name: assert get blob - check + assert: + that: + - blob_check is changed + - blob_check.blob == '' + - blob_check_actual.objects[0].pwdLastSet == pwd_info.objects[0].pwdLastSet + +- name: get blob + offline_join: + name: My, Computer + register: blob + +- name: get result of get blob + object_info: + identity: '{{ object_identity }}' + properties: + - pwdLastSet + register: blob_actual + +- name: assert get blob + assert: + that: + - blob is changed + - blob.blob != None + - blob.blob != '' + - blob_actual.objects[0].pwdLastSet > pwd_info.objects[0].pwdLastSet + +- block: + - name: create blob in file + offline_join: + identity: '{{ object_identity }}' + blob_path: C:\Windows\TEMP\ansible-blob + provision_root_ca_certs: true + register: blob_path + + - name: get pwd result of create blob in file + object_info: + identity: '{{ object_identity }}' + properties: + - pwdLastSet + register: blob_path_ad_actual + + - name: get path result of create blob in file + ansible.windows.win_stat: + path: C:\Windows\TEMP\ansible-blob + register: blob_path_file_actual + + - name: assert create blob in file + assert: + that: + - blob_path is changed + - blob_path.blob == None + - blob_path_ad_actual.objects[0].pwdLastSet > blob_actual.objects[0].pwdLastSet + - blob_path_file_actual.stat.exists + + - name: create blob in file - idempotent + offline_join: + identity: '{{ object_identity }}' + blob_path: C:\Windows\TEMP\ansible-blob + register: blob_path_again + + - name: get pwd result of create blob in file - idempotent + object_info: + identity: '{{ object_identity }}' + properties: + - pwdLastSet + register: blob_path_ad_actual_again + + - name: get path result of create blob in file - idempotent + ansible.windows.win_stat: + path: C:\Windows\TEMP\ansible-blob + register: blob_path_file_actual_again + + - name: assert create blob in file - idempotent + assert: + that: + - not blob_path_again is changed + - blob_path_again.blob == None + - blob_path_ad_actual_again.objects[0].pwdLastSet == blob_path_ad_actual.objects[0].pwdLastSet + - blob_path_file_actual_again.stat.size == blob_path_file_actual.stat.size + - blob_path_file_actual_again.stat.lastwritetime == blob_path_file_actual.stat.lastwritetime + + always: + - name: remove temp blob file + ansible.windows.win_file: + path: C:\Windows\TEMP\ansible-blob + state: absent + +- name: move computer object + computer: + name: My, Computer + identity: '{{ object_identity }}' + path: CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + +- name: fail to find computer outside default path + offline_join: + name: My, Computer + register: fail_find + failed_when: '("Failed to find domain computer account ''CN=My\, Computer,CN=Computers," ~ setup_domain_info.output[0].defaultNamingContext) not in fail_find.msg' + +- name: get blob of computer in different path + offline_join: + name: My, Computer + path: CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + register: blob_ad_path + +- name: get result of get blob of computer in different pth + object_info: + identity: '{{ object_identity }}' + properties: + - pwdLastSet + register: blob_ad_path_actual + +- name: assert get blob of computer in different path + assert: + that: + - blob_ad_path is changed + - blob_ad_path.blob != None + - blob_ad_path.blob != '' + - blob_ad_path_actual.objects[0].pwdLastSet > blob_path_ad_actual_again.objects[0].pwdLastSet diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/ou/aliases b/ansible_collections/microsoft/ad/tests/integration/targets/ou/aliases new file mode 100644 index 000000000..ccd8a25e8 --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/ou/aliases @@ -0,0 +1,2 @@ +windows +shippable/windows/group1 diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/ou/meta/main.yml b/ansible_collections/microsoft/ad/tests/integration/targets/ou/meta/main.yml new file mode 100644 index 000000000..4ce45dcfb --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/ou/meta/main.yml @@ -0,0 +1,2 @@ +dependencies: +- setup_domain diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/ou/tasks/main.yml b/ansible_collections/microsoft/ad/tests/integration/targets/ou/tasks/main.yml new file mode 100644 index 000000000..b024959e7 --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/ou/tasks/main.yml @@ -0,0 +1,14 @@ +- name: remove temp OU + ou: + name: MyOU + state: absent + +- block: + - import_tasks: tests.yml + + always: + - name: remove temp OU + ou: + name: MyOU + identity: '{{ object_identity | default(omit) }}' + state: absent diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/ou/tasks/tests.yml b/ansible_collections/microsoft/ad/tests/integration/targets/ou/tasks/tests.yml new file mode 100644 index 000000000..49d06aefb --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/ou/tasks/tests.yml @@ -0,0 +1,254 @@ +- name: create ou - check + ou: + name: MyOU + state: present + register: create_ou_check + check_mode: true + +- name: get result of create ou - check + object_info: + identity: '{{ create_ou_check.distinguished_name }}' + register: create_ou_check_actual + +- name: assert create ou - check + assert: + that: + - create_ou_check is changed + - create_ou_check.distinguished_name == 'OU=MyOU,' ~ setup_domain_info.output[0].defaultNamingContext + - create_ou_check.object_guid == '00000000-0000-0000-0000-000000000000' + - create_ou_check_actual.objects == [] + +- name: create ou + ou: + name: MyOU + state: present + register: create_ou + +- set_fact: + object_identity: '{{ create_ou.object_guid }}' + +- name: get result of create ou + object_info: + identity: '{{ object_identity }}' + properties: + - ProtectedFromAccidentalDeletion + register: create_ou_actual + +- name: assert create ou + assert: + that: + - create_ou is changed + - create_ou_actual.objects | length == 1 + - create_ou.distinguished_name == 'OU=MyOU,' ~ setup_domain_info.output[0].defaultNamingContext + - create_ou.distinguished_name == create_ou_actual.objects[0].DistinguishedName + - create_ou.object_guid == create_ou_actual.objects[0].ObjectGUID + - create_ou_actual.objects[0].Name == 'MyOU' + - create_ou_actual.objects[0].ObjectClass == 'organizationalUnit' + - create_ou_actual.objects[0].ProtectedFromAccidentalDeletion == true + +- name: remove ou - check + ou: + name: MyOU + state: absent + register: remove_ou_check + check_mode: true + +- name: get result of remove ou - check + object_info: + identity: '{{ object_identity }}' + register: remove_ou_check_actual + +- name: assert remove ou - check + assert: + that: + - remove_ou_check is changed + - remove_ou_check_actual.objects | length == 1 + +- name: remove ou + ou: + name: MyOU + state: absent + register: remove_ou + +- name: get result of remove ou + object_info: + identity: '{{ object_identity }}' + register: remove_ou_actual + +- name: assert remove ou + assert: + that: + - remove_ou is changed + - remove_ou_actual.objects == [] + +- name: remove ou - idempotent + ou: + name: MyOU + state: absent + register: remove_ou_again + +- name: assert remove ou - idempotent + assert: + that: + - not remove_ou_again is changed + +- name: create parent OU + ou: + name: MyOU + state: present + register: parent_ou + +- set_fact: + object_identity: '{{ parent_ou.object_guid }}' + +- name: create ou with custom options + ou: + name: SubOU + path: '{{ parent_ou.distinguished_name }}' + state: present + city: Brisbane + country: AU + description: Custom Description + display_name: OU Display Name + managed_by: Domain Admins + postal_code: 4000 + state_province: QLD + street: Main St + protect_from_deletion: false + attributes: + set: + postOfficeBox: My Box + register: create_ou_custom + +- name: get result of create ou with custom options + object_info: + identity: '{{ create_ou_custom.object_guid }}' + properties: + - c + - Description + - DisplayName + - l + - managedBy + - postalcode + - postOfficeBox + - st + - street + - ProtectedFromAccidentalDeletion + register: create_ou_custom_actual + +- name: assert create ou with custom options + assert: + that: + - create_ou_custom is changed + - create_ou_custom_actual.objects | length == 1 + - create_ou_custom.distinguished_name == create_ou_custom_actual.objects[0].DistinguishedName + - create_ou_custom.object_guid == create_ou_custom_actual.objects[0].ObjectGUID + - create_ou_custom_actual.objects[0].l == 'Brisbane' + - create_ou_custom_actual.objects[0].c == 'AU' + - create_ou_custom_actual.objects[0].Description == 'Custom Description' + - create_ou_custom_actual.objects[0].DisplayName == 'OU Display Name' + - create_ou_custom_actual.objects[0].managedBy == 'CN=Domain Admins,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext + - create_ou_custom_actual.objects[0].postalcode == '4000' + - create_ou_custom_actual.objects[0].st == 'QLD' + - create_ou_custom_actual.objects[0].street == 'Main St' + - create_ou_custom_actual.objects[0].ProtectedFromAccidentalDeletion == False + - create_ou_custom_actual.objects[0].postOfficeBox == 'My Box' + +- name: change ou with custom options + ou: + name: SubOU + path: '{{ parent_ou.distinguished_name }}' + state: present + city: New York + country: US + description: Custom description + display_name: OU display Name + managed_by: Domain Users + postal_code: 10001 + state_province: '' + street: Main + attributes: + set: + postOfficeBox: My box + register: change_ou + +- name: get result of change ou with custom options + object_info: + identity: '{{ create_ou_custom.object_guid }}' + properties: + - c + - Description + - DisplayName + - l + - managedBy + - postalcode + - postOfficeBox + - st + - street + - ProtectedFromAccidentalDeletion + register: change_ou_actual + +- name: assert change ou with custom options + assert: + that: + - change_ou is changed + - change_ou.distinguished_name == create_ou_custom.distinguished_name + - change_ou.object_guid == create_ou_custom.object_guid + - change_ou_actual.objects[0].l == 'New York' + - change_ou_actual.objects[0].c == 'US' + - change_ou_actual.objects[0].Description == 'Custom description' + - change_ou_actual.objects[0].DisplayName == 'OU display Name' + - change_ou_actual.objects[0].managedBy == 'CN=Domain Users,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext + - change_ou_actual.objects[0].postalcode == '10001' + - change_ou_actual.objects[0].st == None + - change_ou_actual.objects[0].street == 'Main' + - change_ou_actual.objects[0].ProtectedFromAccidentalDeletion == False + - change_ou_actual.objects[0].postOfficeBox == 'My box' + +- name: create new sub OU + ou: + name: NewSubOU + path: '{{ parent_ou.distinguished_name }}' + state: present + register: new_parent_ou + +- name: move and rename ou - check + ou: + name: InnerOU + path: '{{ new_parent_ou.distinguished_name }}' + identity: '{{ create_ou_custom.object_guid }}' + register: move_rename_check + check_mode: true + +- name: get result of move and rename ou - check + object_info: + identity: '{{ create_ou_custom.object_guid }}' + register: move_rename_check_actual + +- name: assert move and rename ou - check + assert: + that: + - move_rename_check is changed + - move_rename_check.distinguished_name == 'OU=InnerOU,' ~ new_parent_ou.distinguished_name + - move_rename_check_actual.objects[0].Name == 'SubOU' + - move_rename_check_actual.objects[0].DistinguishedName == create_ou_custom_actual.objects[0].DistinguishedName + +- name: move and rename ou + ou: + name: InnerOU + path: '{{ new_parent_ou.distinguished_name }}' + identity: '{{ create_ou_custom.object_guid }}' + register: move_rename + +- name: get result of move and rename ou + object_info: + identity: '{{ create_ou_custom.object_guid }}' + register: move_rename_actual + +- name: assert move and rename ou + assert: + that: + - move_rename is changed + - move_rename.distinguished_name == 'OU=InnerOU,' ~ new_parent_ou.distinguished_name + - move_rename_actual.objects[0].Name == 'InnerOU' + - move_rename_actual.objects[0].DistinguishedName == 'OU=InnerOU,' ~ new_parent_ou.distinguished_name diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/setup_domain/defaults/main.yml b/ansible_collections/microsoft/ad/tests/integration/targets/setup_domain/defaults/main.yml new file mode 100644 index 000000000..5f70cc3f3 --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/setup_domain/defaults/main.yml @@ -0,0 +1,2 @@ +domain_realm: ansible.test +domain_password: Password123! diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/setup_domain/tasks/main.yml b/ansible_collections/microsoft/ad/tests/integration/targets/setup_domain/tasks/main.yml new file mode 100644 index 000000000..3376208c2 --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/setup_domain/tasks/main.yml @@ -0,0 +1,55 @@ +- name: ensure the ActiveDirectory module is installed + ansible.windows.win_feature: + name: + - RSAT-AD-PowerShell + state: present + +- name: check if domain is already set up + domain: + dns_domain_name: '{{ domain_realm }}' + safe_mode_password: '{{ domain_password }}' + check_mode: true + register: domain_res + +# If the domain is not already set up and this is running under the domain +# test target then run the tests for that target, otherwise do the bare +# minimum setup. This allows us to do more complex testing for that module +# if it is the only one to run. +- include_tasks: '{{ (run_domain_test | default(False)) | ternary(role_path ~ "/../domain/tasks/test.yml", "setup.yml") }}' + when: domain_res is changed + +# While usually the reboot waits until it is fully done before continuing I've seen Server 2019 in CI still waiting +# for things to initialise. By tested if ADWS is available with a simple check we can ensure the host is at least +# ready to test AD. Typically I've found it takes about 60 retries so doubling it should cover even an absolute worst# case. +- name: post domain setup test for ADWS to come online + ansible.windows.win_powershell: + parameters: + Delay: 5 + Retries: 120 + script: | + [CmdletBinding()] + param ( + [int]$Delay, + [int]$Retries + ) + $Ansible.Changed = $false + $attempts = 0 + $err = $null + while ($true) { + $attempts++ + try { + Get-ADRootDSE -ErrorAction Stop + break + } + catch { + if ($attempts -eq $Retries) { + throw + } + Start-Sleep -Seconds $Delay + } + } + $attempts + register: setup_domain_info + become: yes + become_method: runas + become_user: SYSTEM diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/setup_domain/tasks/setup.yml b/ansible_collections/microsoft/ad/tests/integration/targets/setup_domain/tasks/setup.yml new file mode 100644 index 000000000..8c7cce1f6 --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/setup_domain/tasks/setup.yml @@ -0,0 +1,5 @@ +- name: ensure domain is present + domain: + dns_domain_name: '{{ domain_realm }}' + safe_mode_password: '{{ domain_password }}' + reboot: true diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/user/aliases b/ansible_collections/microsoft/ad/tests/integration/targets/user/aliases new file mode 100644 index 000000000..ccd8a25e8 --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/user/aliases @@ -0,0 +1,2 @@ +windows +shippable/windows/group1 diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/user/meta/main.yml b/ansible_collections/microsoft/ad/tests/integration/targets/user/meta/main.yml new file mode 100644 index 000000000..4ce45dcfb --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/user/meta/main.yml @@ -0,0 +1,2 @@ +dependencies: +- setup_domain diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/user/tasks/main.yml b/ansible_collections/microsoft/ad/tests/integration/targets/user/tasks/main.yml new file mode 100644 index 000000000..6dc722602 --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/user/tasks/main.yml @@ -0,0 +1,14 @@ +- name: remove temp user + user: + name: MyUser + state: absent + +- block: + - import_tasks: tests.yml + + always: + - name: remove temp user + user: + name: MyUser + identity: '{{ object_identity | default(omit) }}' + state: absent 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 new file mode 100644 index 000000000..e06c54959 --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/targets/user/tasks/tests.yml @@ -0,0 +1,1065 @@ +- name: create user with defaults - check + user: + name: MyUser + state: present + register: default_user_check + check_mode: true + +- name: get result of create user with defaults - check + object_info: + ldap_filter: (sAMAccountName=MyUser) + register: default_user_check_actual + +- name: assert create user with defaults - check + assert: + that: + - default_user_check is changed + - default_user_check_actual.objects == [] + - default_user_check.distinguished_name == 'CN=MyUser,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext + - default_user_check.object_guid == '00000000-0000-0000-0000-000000000000' + - default_user_check.sid == 'S-1-5-0000' + +- name: create user with defaults + user: + name: MyUser + state: present + register: default_user + +- name: get result of create user with defaults + object_info: + ldap_filter: (sAMAccountName=MyUser) + properties: + - objectSid + - sAMAccountName + - userAccountControl + - userPrincipalName + register: default_user_actual + +- name: assert create user with defaults - check + assert: + that: + - default_user is changed + - default_user_actual.objects | length == 1 + - default_user.distinguished_name == 'CN=MyUser,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext + - default_user.object_guid == default_user_actual.objects[0].ObjectGUID + - default_user.sid == default_user_actual.objects[0].objectSid.Sid + - default_user_actual.objects[0].sAMAccountName == 'MyUser' + - default_user_actual.objects[0].userPrincipalName == None + - '"ADS_UF_ACCOUNTDISABLE" in default_user_actual.objects[0].userAccountControl_AnsibleFlags' + +- set_fact: + object_identity: '{{ default_user.object_guid }}' + object_sid: '{{ default_user.sid }}' + +- name: create user with defaults - idempotent + user: + name: MyUser + state: present + register: default_user_again + +- name: assert create user with defaults - idempotent + assert: + that: + - not default_user_again is changed + +- name: rename user - check + user: + name: MyUser2 + identity: '{{ object_identity }}' + register: rename_user_check + check_mode: true + +- name: get result of rename user - check + object_info: + identity: '{{ object_identity }}' + properties: + - sAMAccountName + register: rename_user_check_actual + +- name: assert rename user - check + assert: + that: + - rename_user_check is changed + - rename_user_check.distinguished_name == 'CN=MyUser2,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext + - rename_user_check_actual.objects[0].DistinguishedName == default_user.distinguished_name + - rename_user_check_actual.objects[0].Name == 'MyUser' + - rename_user_check_actual.objects[0].sAMAccountName == 'MyUser' + +- name: rename user + user: + name: MyUser2 + identity: '{{ object_identity }}' + register: rename_user + +- name: get result of rename user + object_info: + identity: '{{ object_identity }}' + properties: + - sAMAccountName + register: rename_user_actual + +- name: assert rename user + assert: + that: + - rename_user is changed + - rename_user.distinguished_name == 'CN=MyUser2,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext + - rename_user_actual.objects[0].DistinguishedName == 'CN=MyUser2,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext + - rename_user_actual.objects[0].Name == 'MyUser2' + - rename_user_actual.objects[0].sAMAccountName == 'MyUser' + +- name: rename user - idempotent + user: + name: MyUser2 + identity: '{{ object_identity }}' + register: rename_user_again + +- name: assert rename user - idempotent + assert: + that: + - not rename_user_again is changed + +- name: move user - check + user: + name: MyUser2 + identity: '{{ object_sid }}' # ID by SID + path: '{{ setup_domain_info.output[0].defaultNamingContext }}' + register: move_user_check + check_mode: true + +- name: get result of move user - check + object_info: + identity: '{{ object_identity }}' + properties: + - sAMAccountName + register: move_user_check_actual + +- name: assert move user - check + assert: + that: + - move_user_check is changed + - move_user_check.distinguished_name == 'CN=MyUser2,' ~ setup_domain_info.output[0].defaultNamingContext + - move_user_check_actual.objects[0].DistinguishedName == 'CN=MyUser2,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext + - move_user_check_actual.objects[0].Name == 'MyUser2' + - move_user_check_actual.objects[0].sAMAccountName == 'MyUser' + +- name: move user + user: + name: MyUser2 + identity: '{{ object_sid }}' # ID by SID + path: '{{ setup_domain_info.output[0].defaultNamingContext }}' + register: move_user + +- name: get result of move user + object_info: + identity: '{{ object_identity }}' + properties: + - sAMAccountName + register: move_user_actual + +- name: assert move user + assert: + that: + - move_user is changed + - move_user.distinguished_name == 'CN=MyUser2,' ~ setup_domain_info.output[0].defaultNamingContext + - move_user_actual.objects[0].DistinguishedName == 'CN=MyUser2,' ~ setup_domain_info.output[0].defaultNamingContext + - move_user_actual.objects[0].Name == 'MyUser2' + - move_user_actual.objects[0].sAMAccountName == 'MyUser' + +- name: move user - idempotent + user: + name: MyUser2 + identity: '{{ object_sid }}' # ID by SID + path: '{{ setup_domain_info.output[0].defaultNamingContext }}' + register: move_user_again + +- name: assert move user - idempotent + assert: + that: + - not move_user_again is changed + +- name: move user back + user: + name: MyUser + identity: MyUser # By sAMAccountName + path: CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + +- name: update password from blank - skip for on_create + user: + name: MyUser + password: Password123! + update_password: on_create + register: change_pass_on_create + +- name: assert update password from blank - skip for on_create + assert: + that: + - not change_pass_on_create is changed + +- name: update password - check + user: + name: MyUser + password: Password123! + update_password: when_changed + enabled: true + register: change_pass_check + check_mode: true + +- name: get result of update password - check + object_info: + identity: '{{ object_identity }}' + properties: + - pwdLastSet + - userAccountControl + register: change_pass_check_actual + +- name: assert update password - check + assert: + that: + - change_pass_check is changed + - change_pass_check_actual.objects[0].pwdLastSet == 0 + - '"ADS_UF_ACCOUNTDISABLE" in change_pass_check_actual.objects[0].userAccountControl_AnsibleFlags' + +- name: update password - check + user: + name: MyUser + password: Password123! + update_password: when_changed + enabled: true + register: change_pass + +- name: get result of update password + object_info: + identity: '{{ object_identity }}' + properties: + - pwdLastSet + - userAccountControl + register: change_pass_actual + +- name: assert update password + assert: + that: + - change_pass is changed + - change_pass_actual.objects[0].pwdLastSet != 0 + - '"ADS_UF_ACCOUNTDISABLE" not in change_pass_actual.objects[0].userAccountControl_AnsibleFlags' + +- name: update password - idempotent + user: + name: MyUser + password: Password123! + update_password: when_changed + enabled: true + register: change_pass_again + +- name: assert update password - idempotent + assert: + that: + - not change_pass_again is changed + +- name: force update password + user: + name: MyUser + password: Password123! + register: always_update_password + +- name: get result of force update password + object_info: + identity: '{{ object_identity }}' + properties: + - pwdLastSet + register: always_update_password_actual + +- name: assert force update password + assert: + that: + - always_update_password is changed + - always_update_password_actual.objects[0].pwdLastSet > change_pass_actual.objects[0].pwdLastSet + +- name: remove user - check + user: + name: MyUser + state: absent + register: remove_user_check + check_mode: true + +- name: get result of remove user - check + object_info: + identity: '{{ object_identity }}' + register: remove_user_check_actual + +- name: assert remove user - check + assert: + that: + - remove_user_check is changed + - remove_user_check_actual.objects | length == 1 + +- name: remove user + user: + name: MyUser + state: absent + register: remove_user + +- name: get result of remove user + object_info: + identity: '{{ object_identity }}' + register: remove_user_actual + +- name: assert remove user + assert: + that: + - remove_user is changed + - remove_user_actual.objects == [] + +- name: remove user - idempotent + user: + name: MyUser + state: absent + register: remove_user_again + +- name: assert remove user - idempotent + assert: + that: + - not remove_user_again is changed + +# https://github.com/ansible-collections/microsoft.ad/issues/25 +- name: create user with expired password + user: + name: MyUser + password: Password123! + password_expired: true + state: present + register: create_user_pass_expired + +- set_fact: + object_identity: '{{ create_user_pass_expired.object_guid }}' + object_sid: '{{ create_user_pass_expired.sid }}' + +- name: get result of create user with expired password + object_info: + identity: '{{ object_identity }}' + properties: + - pwdLastSet + register: create_user_pass_expired_actual + +- name: assert create user with expired password + assert: + that: + - create_user_pass_expired_actual.objects[0].pwdLastSet == 0 + +- name: remove expired password flag on existing user + user: + name: MyUser + password_expired: false + state: present + register: remove_password_expiry + +- name: get result of remove expired password flag on existing user + object_info: + identity: '{{ object_identity }}' + properties: + - pwdLastSet + register: remove_password_expiry_actual + +- name: assert remove expired password flag on existing user + assert: + that: + - remove_password_expiry_actual.objects[0].pwdLastSet > 0 + +- name: remove user + user: + name: MyUser + state: absent + +- name: create user with extra info - check + user: + name: MyUser + state: present + city: Brisbane + company: Red Hat + country: au + delegates: + set: + - CN=krbtgt,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + - CN=Administrator,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + description: User Description + display_name: User Name + email: user@EMAIL.COM + firstname: FirstName + groups: + set: + - Domain Admins + - Domain Users + password: Password123! + password_never_expires: true + path: '{{ setup_domain_info.output[0].defaultNamingContext }}' + postal_code: 4000 + sam_account_name: MyUserSam + spn: + set: + - HTTP/MyUser + state_province: QLD + street: Main + surname: LastName + upn: User@{{ domain_realm }} + user_cannot_change_password: true + attributes: + set: + comment: My comment + register: create_user_check + check_mode: true + +- name: get result of create user with extra info - check + object_info: + identity: '{{ create_user_check.distinguished_name }}' + register: create_user_actual_check + +- name: assert create user with extra info - check + assert: + that: + - create_user_check is changed + - create_user_actual_check.objects == [] + +- name: create user with extra info + user: + name: MyUser + state: present + city: Brisbane + company: Red Hat + country: au + delegates: + set: + - CN=krbtgt,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + - CN=Administrator,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + description: User Description + display_name: User Name + email: user@EMAIL.COM + firstname: FirstName + groups: + set: + - Domain Admins + - Domain Users + password: Password123! + password_never_expires: true + path: '{{ setup_domain_info.output[0].defaultNamingContext }}' + postal_code: 4000 + sam_account_name: MyUserSam + spn: + set: + - HTTP/MyUser + state_province: QLD + street: Main + surname: LastName + upn: User@{{ domain_realm }} + user_cannot_change_password: true + attributes: + set: + comment: My comment + register: create_user + +- set_fact: + object_identity: '{{ create_user.object_guid }}' + object_sid: '{{ create_user.sid }}' + +- name: get result of create user with extra info + object_info: + identity: '{{ object_identity }}' + properties: + - c + - comment + - company + - Description + - displayName + - givenName + - l + - mail + - memberOf + - msDS-AllowedToActOnBehalfOfOtherIdentity + - objectSid + - postalcode + - primaryGroupID + - pwdLastSet + - sAMAccountName + - servicePrincipalName + - sn + - st + - streetaddress + - userAccountControl + - userPrincipalName + register: create_user_actual + +- name: convert delegate SDDL to human readable string + ansible.windows.win_powershell: + parameters: + SDDL: '{{ create_user_actual.objects[0]["msDS-AllowedToActOnBehalfOfOtherIdentity"] }}' + script: | + param($SDDL) + + $sd = New-Object -TypeName System.DirectoryServices.ActiveDirectorySecurity + $sd.SetSecurityDescriptorSddlForm($SDDL, 'All') + $sd.GetAccessRules($true, $false, [Type][System.Security.Principal.NTAccount] + ).IdentityReference.Value | ForEach-Object { + ($_ -split '\\', 2)[-1].ToLowerInvariant() + } | Sort-Object + register: create_user_delegates + +- name: assert create user with extra info + assert: + that: + - create_user is changed + - create_user_actual.objects | length == 1 + - create_user.distinguished_name == 'CN=MyUser,' ~ setup_domain_info.output[0].defaultNamingContext + - create_user.object_guid == create_user_actual.objects[0].ObjectGUID + - create_user.sid == create_user_actual.objects[0].objectSid.Sid + - 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].c == 'au' + - create_user_actual.objects[0].comment == 'My comment' + - create_user_actual.objects[0].company == 'Red Hat' + - create_user_actual.objects[0].givenName == 'FirstName' + - create_user_actual.objects[0].l == 'Brisbane' + - create_user_actual.objects[0].mail == 'user@EMAIL.COM' + # Domain Users is the primaryGroupID entry + - 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].pwdLastSet > 0 + - create_user_actual.objects[0].sAMAccountName == 'MyUserSam' + - create_user_actual.objects[0].servicePrincipalName == 'HTTP/MyUser' + - create_user_actual.objects[0].sn == 'LastName' + - create_user_actual.objects[0].st == 'QLD' + - create_user_actual.objects[0].streetaddress == 'Main' + - create_user_actual.objects[0].userPrincipalName == 'User@' ~ domain_realm + - create_user_actual.objects[0].userAccountControl_AnsibleFlags == ["ADS_UF_NORMAL_ACCOUNT", "ADS_UF_DONT_EXPIRE_PASSWD"] + - create_user_delegates.output == ["administrator", "krbtgt"] + +- name: create user with extra info - idempotent + user: + name: MyUser + state: present + city: Brisbane + company: Red Hat + country: au + delegates: + set: + - CN=krbtgt,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + - CN=Administrator,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + description: User Description + display_name: User Name + email: user@EMAIL.COM + firstname: FirstName + groups: + set: + - Domain Admins + - Domain Users + password: Password123! + password_never_expires: true + path: '{{ setup_domain_info.output[0].defaultNamingContext }}' + postal_code: 4000 + sam_account_name: MyUserSam + spn: + set: + - HTTP/MyUser + state_province: QLD + street: Main + surname: LastName + upn: User@{{ domain_realm }} + update_password: when_changed + user_cannot_change_password: true + attributes: + set: + comment: My comment + register: create_user_again + +- name: assert create user with extra info - idempotent + assert: + that: + - not create_user_again is changed + +- name: update user settings - check + user: + name: MyUser + state: present + city: New York + company: Ansible + country: us + delegates: + set: + - CN=KRBTGT,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + description: User description + display_name: User name + email: User@EMAIL.COM + firstname: firstName + groups: + set: + - Domain Users + password: Password123! + password_never_expires: false + path: '{{ setup_domain_info.output[0].defaultNamingContext }}' + postal_code: 10001 + sam_account_name: myUserSam + spn: + set: + - HTTP/myUser + state_province: NY + street: main + surname: lastName + upn: user@{{ domain_realm }} + update_password: when_changed + user_cannot_change_password: false + attributes: + set: + comment: My Comment + register: update_user_check + check_mode: true + +- name: get result of update user settings - check + object_info: + identity: '{{ object_identity }}' + properties: + - c + - comment + - company + - Description + - displayName + - givenName + - l + - mail + - memberOf + - msDS-AllowedToActOnBehalfOfOtherIdentity + - objectSid + - postalcode + - primaryGroupID + - pwdLastSet + - sAMAccountName + - servicePrincipalName + - sn + - st + - streetaddress + - userAccountControl + - userPrincipalName + register: update_user_check_actual + +- name: assert update user settings - check + assert: + that: + - update_user_check is changed + - update_user_check.distinguished_name == 'CN=MyUser,' ~ setup_domain_info.output[0].defaultNamingContext + - update_user_check.object_guid == create_user_actual.objects[0].ObjectGUID + - update_user_check.sid == create_user_actual.objects[0].objectSid.Sid + - 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].c == 'au' + - update_user_check_actual.objects[0].comment == 'My comment' + - update_user_check_actual.objects[0].company == 'Red Hat' + - update_user_check_actual.objects[0].givenName == 'FirstName' + - update_user_check_actual.objects[0].l == 'Brisbane' + - 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].primaryGroupID == 513 # Domain Users + - 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' + - update_user_check_actual.objects[0].sn == 'LastName' + - update_user_check_actual.objects[0].st == 'QLD' + - update_user_check_actual.objects[0].streetaddress == 'Main' + - update_user_check_actual.objects[0].userPrincipalName == 'User@' ~ domain_realm + - update_user_check_actual.objects[0].userAccountControl_AnsibleFlags == ["ADS_UF_NORMAL_ACCOUNT", "ADS_UF_DONT_EXPIRE_PASSWD"] + - update_user_check_actual.objects[0]['msDS-AllowedToActOnBehalfOfOtherIdentity'] == create_user_actual.objects[0]['msDS-AllowedToActOnBehalfOfOtherIdentity'] + +- name: update user settings + user: + name: MyUser + state: present + city: New York + company: Ansible + country: us + delegates: + set: + - CN=KRBTGT,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + description: User description + display_name: User name + email: User@EMAIL.COM + firstname: firstName + groups: + set: + - Domain Users + password: Password123! + password_never_expires: false + path: '{{ setup_domain_info.output[0].defaultNamingContext }}' + postal_code: 10001 + sam_account_name: myUserSam + spn: + set: + - HTTP/myUser + state_province: NY + street: main + surname: lastName + upn: user@{{ domain_realm }} + update_password: when_changed + user_cannot_change_password: false + attributes: + set: + comment: My Comment + register: update_user + +- name: get result of update user settings + object_info: + identity: '{{ object_identity }}' + properties: + - c + - comment + - company + - Description + - displayName + - givenName + - l + - mail + - memberOf + - msDS-AllowedToActOnBehalfOfOtherIdentity + - objectSid + - postalcode + - primaryGroupID + - pwdLastSet + - sAMAccountName + - servicePrincipalName + - sn + - st + - streetaddress + - userAccountControl + - userPrincipalName + register: update_user_actual + +- name: convert delegate SDDL to human readable string + ansible.windows.win_powershell: + parameters: + SDDL: '{{ update_user_actual.objects[0]["msDS-AllowedToActOnBehalfOfOtherIdentity"] }}' + script: | + param($SDDL) + + $sd = New-Object -TypeName System.DirectoryServices.ActiveDirectorySecurity + $sd.SetSecurityDescriptorSddlForm($SDDL, 'All') + $sd.GetAccessRules($true, $false, [Type][System.Security.Principal.NTAccount] + ).IdentityReference.Value | ForEach-Object { + ($_ -split '\\', 2)[-1].ToLowerInvariant() + } | Sort-Object + register: update_user_delegates + +- name: assert update user settings + assert: + that: + - update_user is changed + - update_user.distinguished_name == 'CN=MyUser,' ~ setup_domain_info.output[0].defaultNamingContext + - update_user.object_guid == create_user_actual.objects[0].ObjectGUID + - update_user.sid == create_user_actual.objects[0].objectSid.Sid + - 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].c == 'us' + - update_user_actual.objects[0].comment == 'My Comment' + - update_user_actual.objects[0].company == 'Ansible' + - update_user_actual.objects[0].givenName == 'firstName' + - update_user_actual.objects[0].l == 'New York' + - update_user_actual.objects[0].mail == 'User@EMAIL.COM' + # Domain Users is the primaryGroupID entry + - update_user_actual.objects[0].memberOf == None + - update_user_actual.objects[0].postalcode == '10001' + - update_user_actual.objects[0].primaryGroupID == 513 # Domain Users + - update_user_actual.objects[0].pwdLastSet == create_user_actual.objects[0].pwdLastSet + - update_user_actual.objects[0].sAMAccountName == 'myUserSam' + - update_user_actual.objects[0].servicePrincipalName == 'HTTP/MyUser' + - update_user_actual.objects[0].sn == 'lastName' + - update_user_actual.objects[0].st == 'NY' + - update_user_actual.objects[0].streetaddress == 'main' + - update_user_actual.objects[0].userPrincipalName == 'user@' ~ domain_realm + - update_user_actual.objects[0].userAccountControl_AnsibleFlags == ["ADS_UF_NORMAL_ACCOUNT"] + - update_user_delegates.output == ["krbtgt"] + +- name: update delegates case insensitive + user: + name: MyUser + path: '{{ setup_domain_info.output[0].defaultNamingContext }}' + delegates: + set: + - CN=KrbTGT,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + register: update_delegates_insensitive + +- name: assert update delegates case insensitive + assert: + that: + - not update_delegates_insensitive is changed + +- name: update delegates + user: + name: MyUser + path: '{{ setup_domain_info.output[0].defaultNamingContext }}' + delegates: + set: + - CN=Administrator,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + - CN=Enterprise Admins,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }} + register: update_delegates + +- name: get result of update delegates + object_info: + identity: '{{ object_identity }}' + properties: + - msDS-AllowedToActOnBehalfOfOtherIdentity + register: update_delegates_actual_raw + +- name: convert delegate SDDL to human readable string + ansible.windows.win_powershell: + parameters: + SDDL: '{{ update_delegates_actual_raw.objects[0]["msDS-AllowedToActOnBehalfOfOtherIdentity"] }}' + script: | + param($SDDL) + + $sd = New-Object -TypeName System.DirectoryServices.ActiveDirectorySecurity + $sd.SetSecurityDescriptorSddlForm($SDDL, 'All') + $sd.GetAccessRules($true, $false, [Type][System.Security.Principal.NTAccount] + ).IdentityReference.Value | ForEach-Object { + ($_ -split '\\', 2)[-1].ToLowerInvariant() + } | Sort-Object + register: update_delegates_actual + +- name: assert update delegates + assert: + that: + - update_delegates is changed + - update_delegates_actual.output == ["administrator", "enterprise admins"] + +- name: unset string option + user: + name: MyUser + path: '{{ setup_domain_info.output[0].defaultNamingContext }}' + firstname: '' + register: unset_option + +- name: get result of unset string option + object_info: + identity: '{{ object_identity }}' + properties: + - givenName + register: unset_option_actual + +- name: assert unset string option + assert: + that: + - unset_option is changed + - unset_option_actual.objects[0].givenName == None + +- name: set groups - check + user: + name: MyUser + path: '{{ setup_domain_info.output[0].defaultNamingContext }}' + groups: + set: + - Domain Admins + register: set_groups_check + check_mode: true + +- name: get result of set groups - check + object_info: + identity: '{{ object_identity }}' + properties: + - memberOf + - primaryGroupID + register: set_groups_check_actual + +- name: assert set groups - check + assert: + that: + - set_groups_check is changed + - set_groups_check_actual.objects[0].memberOf == None + - set_groups_check_actual.objects[0].primaryGroupID == 513 # Domain Users + +- name: set groups + user: + name: MyUser + path: '{{ setup_domain_info.output[0].defaultNamingContext }}' + groups: + set: + - Domain Admins + register: set_groups + +- name: get result of set groups + object_info: + identity: '{{ object_identity }}' + properties: + - memberOf + - primaryGroupID + register: set_groups_actual + +- name: assert set groups - check + assert: + that: + - set_groups is changed + - set_groups.warnings | length == 1 + - '"the primary group of the user, skipping" in set_groups.warnings[0]' + - set_groups_actual.objects[0].memberOf == "CN=Domain Admins,CN=Users," ~ setup_domain_info.output[0].defaultNamingContext + - set_groups_actual.objects[0].primaryGroupID == 513 # Domain Users + +- name: fail to add group that is missing + user: + name: MyUser + path: '{{ setup_domain_info.output[0].defaultNamingContext }}' + groups: + add: + - Invalid + register: fail_missing_group + failed_when: + - '"Failed to locate group Invalid: Cannot find an object with identity" not in fail_missing_group.msg' + +- name: warn on group that is missing + user: + name: MyUser + path: '{{ setup_domain_info.output[0].defaultNamingContext }}' + groups: + add: + - Invalid + missing_behaviour: warn + register: warn_missing_group + +- name: assert warn on group that is missing + assert: + that: + - not warn_missing_group is changed + - warn_missing_group.warnings | length == 1 + - '"Failed to locate group Invalid but continuing on" in warn_missing_group.warnings[0]' + +- name: ignore on group that is missing + user: + name: MyUser + path: '{{ setup_domain_info.output[0].defaultNamingContext }}' + groups: + add: + - Invalid + missing_behaviour: ignore + register: ignore_missing_group + +- name: assert ignore on group that is missing + assert: + that: + - not ignore_missing_group is changed + - ignore_missing_group.warnings | default([]) | length == 0 + +- name: remove group + user: + name: MyUser + path: '{{ setup_domain_info.output[0].defaultNamingContext }}' + groups: + remove: + - domain admins + - Enterprise Admins + register: groups_remove + +- name: get result of remove groups + object_info: + identity: '{{ object_identity }}' + properties: + - memberOf + - primaryGroupID + register: groups_remove_actual + +- name: assert remove groups + assert: + that: + - groups_remove is changed + - groups_remove_actual.objects[0].memberOf == None + - groups_remove_actual.objects[0].primaryGroupID == 513 # Domain Users + +- name: add group + user: + name: MyUser + path: '{{ setup_domain_info.output[0].defaultNamingContext }}' + groups: + add: + - domain users + - domain admins + register: groups_add + +- name: get result of add groups + object_info: + identity: '{{ object_identity }}' + properties: + - memberOf + - primaryGroupID + register: groups_add_actual + +- name: assert add groups + assert: + that: + - groups_add is changed + - groups_add_actual.objects[0].memberOf == "CN=Domain Admins,CN=Users," ~ setup_domain_info.output[0].defaultNamingContext + - groups_add_actual.objects[0].primaryGroupID == 513 # Domain Users + +- name: set spns + user: + name: MyUser + path: '{{ setup_domain_info.output[0].defaultNamingContext }}' + spn: + set: + - HTTP/host + - HTTP/host.domain + - HTTP/host.domain:8080 + register: spn_set + +- name: get result of set spns + object_info: + identity: '{{ object_identity }}' + properties: + - servicePrincipalName + register: spn_set_actual + +- name: assert set spns + assert: + that: + - spn_set is changed + - spn_set_actual.objects[0].servicePrincipalName == ['HTTP/host.domain:8080', 'HTTP/host.domain', 'HTTP/host'] + +- name: remove spns + user: + name: MyUser + path: '{{ setup_domain_info.output[0].defaultNamingContext }}' + spn: + remove: + - HTTP/fake + - HTTP/Host.domain + register: spn_remove + +- name: get result of remove spns + object_info: + identity: '{{ object_identity }}' + properties: + - servicePrincipalName + register: spn_remove_actual + +- name: assert remove spns + assert: + that: + - spn_remove is changed + - spn_remove_actual.objects[0].servicePrincipalName == ['HTTP/host.domain:8080', 'HTTP/host'] + +- name: add spns + user: + name: MyUser + path: '{{ setup_domain_info.output[0].defaultNamingContext }}' + spn: + add: + - HTTP/Host.domain:8080 + - HTTP/fake + register: spn_add + +- name: get result of add spns + object_info: + identity: '{{ object_identity }}' + properties: + - servicePrincipalName + register: spn_add_actual + +- name: assert add spns + assert: + that: + - spn_add is changed + - spn_add_actual.objects[0].servicePrincipalName == ['HTTP/fake', 'HTTP/host.domain:8080', 'HTTP/host'] diff --git a/ansible_collections/microsoft/ad/tests/integration/windows-integration.controller.requirements.txt b/ansible_collections/microsoft/ad/tests/integration/windows-integration.controller.requirements.txt new file mode 100644 index 000000000..1a3ddde20 --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/integration/windows-integration.controller.requirements.txt @@ -0,0 +1,3 @@ +# Needed for microsoft.ad.ldap +pyspnego >= 0.8.0 +sansldap
\ No newline at end of file diff --git a/ansible_collections/microsoft/ad/tests/requirements.yml b/ansible_collections/microsoft/ad/tests/requirements.yml new file mode 100644 index 000000000..f5ed6c435 --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/requirements.yml @@ -0,0 +1,2 @@ +collections: +- name: ansible.windows diff --git a/ansible_collections/microsoft/ad/tests/sanity/ignore-2.12.txt b/ansible_collections/microsoft/ad/tests/sanity/ignore-2.12.txt new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/sanity/ignore-2.12.txt diff --git a/ansible_collections/microsoft/ad/tests/sanity/ignore-2.13.txt b/ansible_collections/microsoft/ad/tests/sanity/ignore-2.13.txt new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/sanity/ignore-2.13.txt diff --git a/ansible_collections/microsoft/ad/tests/sanity/ignore-2.14.txt b/ansible_collections/microsoft/ad/tests/sanity/ignore-2.14.txt new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/sanity/ignore-2.14.txt diff --git a/ansible_collections/microsoft/ad/tests/sanity/ignore-2.15.txt b/ansible_collections/microsoft/ad/tests/sanity/ignore-2.15.txt new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/sanity/ignore-2.15.txt diff --git a/ansible_collections/microsoft/ad/tests/sanity/ignore-2.16.txt b/ansible_collections/microsoft/ad/tests/sanity/ignore-2.16.txt new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/sanity/ignore-2.16.txt diff --git a/ansible_collections/microsoft/ad/tests/unit/__init__.py b/ansible_collections/microsoft/ad/tests/unit/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/unit/__init__.py diff --git a/ansible_collections/microsoft/ad/tests/unit/compat/__init__.py b/ansible_collections/microsoft/ad/tests/unit/compat/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/unit/compat/__init__.py diff --git a/ansible_collections/microsoft/ad/tests/unit/compat/mock.py b/ansible_collections/microsoft/ad/tests/unit/compat/mock.py new file mode 100644 index 000000000..3dcd2687f --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/unit/compat/mock.py @@ -0,0 +1,42 @@ +# (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/conftest.py b/ansible_collections/microsoft/ad/tests/unit/conftest.py new file mode 100644 index 000000000..e3f2ec4a0 --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/unit/conftest.py @@ -0,0 +1,43 @@ +"""Enable unit testing of Ansible collections. PYTEST_DONT_REWRITE""" +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import os +import os.path + +from ansible.utils.collection_loader._collection_finder import _AnsibleCollectionFinder + + +ANSIBLE_COLLECTIONS_PATH = os.path.abspath(os.path.join(__file__, '..', '..', '..', '..', '..')) + + +# this monkeypatch to _pytest.pathlib.resolve_package_path fixes PEP420 resolution for collections in pytest >= 6.0.0 +def collection_resolve_package_path(path): + """Configure the Python package path so that pytest can find our collections.""" + for parent in path.parents: + if str(parent) == ANSIBLE_COLLECTIONS_PATH: + return parent + + raise Exception('File "%s" not found in collection path "%s".' % (path, ANSIBLE_COLLECTIONS_PATH)) + + +def pytest_configure(): + """Configure this pytest plugin.""" + + try: + if pytest_configure.executed: + return + except AttributeError: + pytest_configure.executed = True + + # allow unit tests to import code from collections + + # noinspection PyProtectedMember + _AnsibleCollectionFinder(paths=[os.path.dirname(ANSIBLE_COLLECTIONS_PATH)])._install() # pylint: disable=protected-access + + # noinspection PyProtectedMember + from _pytest import pathlib as pytest_pathlib + pytest_pathlib.resolve_package_path = collection_resolve_package_path + + +pytest_configure() diff --git a/ansible_collections/microsoft/ad/tests/unit/mock/__init__.py b/ansible_collections/microsoft/ad/tests/unit/mock/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/unit/mock/__init__.py diff --git a/ansible_collections/microsoft/ad/tests/unit/mock/loader.py b/ansible_collections/microsoft/ad/tests/unit/mock/loader.py new file mode 100644 index 000000000..e5dff78c1 --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/unit/mock/loader.py @@ -0,0 +1,116 @@ +# (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 new file mode 100644 index 000000000..4f46ed913 --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/unit/mock/path.py @@ -0,0 +1,8 @@ +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 new file mode 100644 index 000000000..8652d2689 --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/unit/mock/procenv.py @@ -0,0 +1,90 @@ +# (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 new file mode 100644 index 000000000..dcce9c784 --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/unit/mock/vault_helper.py @@ -0,0 +1,39 @@ +# 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 new file mode 100644 index 000000000..1ef172159 --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/unit/mock/yaml_helper.py @@ -0,0 +1,124 @@ +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 new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/unit/modules/__init__.py diff --git a/ansible_collections/microsoft/ad/tests/unit/modules/utils.py b/ansible_collections/microsoft/ad/tests/unit/modules/utils.py new file mode 100644 index 000000000..8c9633ea9 --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/unit/modules/utils.py @@ -0,0 +1,50 @@ +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/__init__.py b/ansible_collections/microsoft/ad/tests/unit/plugins/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/unit/plugins/__init__.py diff --git a/ansible_collections/microsoft/ad/tests/unit/plugins/filter/__init__.py b/ansible_collections/microsoft/ad/tests/unit/plugins/filter/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/unit/plugins/filter/__init__.py 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 new file mode 100644 index 000000000..923d30b31 --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/unit/plugins/filter/test_ldap_converters.py @@ -0,0 +1,92 @@ +# Copyright (c) 2023 Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +import base64 +import typing as t +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 + + +@pytest.mark.parametrize("type", ["int", "str", "bytes"]) +def test_as_datetime(type: str) -> None: + # Last digit is too precise for datetime so will be ignored. + filetime = 133220025750000011 + + value: t.Union[int, str, bytes] + if type == "int": + value = filetime + elif type == "str": + value = str(filetime) + else: + value = str(filetime).encode() + + actual = as_datetime(value) + assert actual == "2023-02-27T20:16:15.000001+0000" + + +def test_as_datetime_with_format() -> None: + filetime = 133220025750000000 + + actual = as_datetime(filetime, format="%Y") + assert actual == "2023" + + +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"] + + +@pytest.mark.parametrize("type", ["str", "bytes"]) +def test_as_guid(type: str) -> None: + input_uuid = uuid.uuid4() + + value: t.Union[str, bytes] + if type == "str": + value = base64.b64encode(input_uuid.bytes_le).decode() + else: + value = input_uuid.bytes_le + + actual = as_guid(value) + assert actual == str(input_uuid) + + +def test_as_guid_from_list() -> None: + input_uuids = [uuid.uuid4(), uuid.uuid4()] + + actual = as_guid([v.bytes_le for v in input_uuids]) + assert actual == [str(input_uuids[0]), str(input_uuids[1])] + + +@pytest.mark.parametrize("type", ["str", "bytes"]) +def test_as_sid(type: str) -> None: + raw_sid = "AQUAAAAAAAUVAAAAMS9koSf9FmVJIPcjUAQAAA==" + + value: t.Union[str, bytes] + if type == "str": + value = raw_sid + else: + value = base64.b64decode(raw_sid) + + actual = as_sid(value) + assert actual == "S-1-5-21-2707697457-1696005415-603398217-1104" + + +def test_as_sid_from_list() -> None: + input_sids = ["AQUAAAAAAAUVAAAAHZN390Q1esyM03upUAQAAA==", "AQEAAAAAAAUTAAAA"] + + actual = as_sid(input_sids) + assert actual == ["S-1-5-21-4151808797-3430561092-2843464588-1104", "S-1-5-19"] + + +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"): + 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") diff --git a/ansible_collections/microsoft/ad/tests/unit/plugins/plugin_utils/__init__.py b/ansible_collections/microsoft/ad/tests/unit/plugins/plugin_utils/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/unit/plugins/plugin_utils/__init__.py diff --git a/ansible_collections/microsoft/ad/tests/unit/plugins/plugin_utils/_ldap/__init__.py b/ansible_collections/microsoft/ad/tests/unit/plugins/plugin_utils/_ldap/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/unit/plugins/plugin_utils/_ldap/__init__.py diff --git a/ansible_collections/microsoft/ad/tests/unit/plugins/plugin_utils/_ldap/test_certificate.py b/ansible_collections/microsoft/ad/tests/unit/plugins/plugin_utils/_ldap/test_certificate.py new file mode 100644 index 000000000..b5beb1e6a --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/unit/plugins/plugin_utils/_ldap/test_certificate.py @@ -0,0 +1,638 @@ +# Copyright (c) 2023 Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +import datetime +import pathlib +import ssl +import subprocess +import typing as t + +import pytest + +from ansible_collections.microsoft.ad.plugins.plugin_utils._ldap._certificate import ( + get_tls_server_end_point_data, + load_client_certificate, + load_trust_certificate, +) + +try: + from cryptography import x509 + from cryptography.exceptions import UnsupportedAlgorithm + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.serialization.pkcs12 import serialize_key_and_certificates + from cryptography.hazmat.primitives.asymmetric import rsa + from cryptography.hazmat.primitives.asymmetric import ed25519 + +except Exception: + pytest.skip("Cannot run certificate tests without cryptography") + + +class TlsServer(t.NamedTuple): + ca: "x509.Certificate" + ca_key: "rsa.RSAPrivateKey" + name: str + context: ssl.SSLContext + + +@pytest.fixture(scope="module") +def tls_server(tmp_path_factory: pytest.TempPathFactory) -> TlsServer: + cn = "microsoft.ad.test" + now = datetime.datetime.utcnow() + + ca_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + ca_name = x509.Name([x509.NameAttribute(x509.NameOID.COMMON_NAME, "microsoft.ad")]) + ca_cert = ( + x509.CertificateBuilder() + .subject_name(ca_name) + .issuer_name(ca_name) + .public_key(ca_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now) + .not_valid_after(now + datetime.timedelta(days=365)) + .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True) + .sign(ca_key, hashes.SHA256()) + ) + + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + + name = x509.Name([x509.NameAttribute(x509.NameOID.COMMON_NAME, cn)]) + + now = datetime.datetime.utcnow() + cert = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(ca_name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now) + .not_valid_after(now + datetime.timedelta(days=365)) + .sign(ca_key, hashes.SHA256()) + ) + cert_pem = cert.public_bytes(encoding=serialization.Encoding.PEM) + key_pem = key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ) + + tmpdir = tmp_path_factory.mktemp("cert") + cert_path = tmpdir / "microsoft.ad.test.pem" + try: + with open(cert_path, mode="wb") as fd: + fd.write(cert_pem) + fd.write(key_pem) + + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.verify_mode = ssl.VerifyMode.CERT_OPTIONAL + context.load_cert_chain(str(cert_path)) + context.load_verify_locations(cadata=ca_cert.public_bytes(serialization.Encoding.PEM).decode()) + + finally: + cert_path.unlink(missing_ok=True) + + return TlsServer(ca_cert, ca_key, cn, context) + + +@pytest.fixture(scope="module") +def client_certificate(tls_server: TlsServer) -> t.Tuple["x509.Certificate", "rsa.RSAPrivateKey"]: + now = datetime.datetime.utcnow() + + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + name = x509.Name([x509.NameAttribute(x509.NameOID.COMMON_NAME, "client-auth")]) + cert = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(tls_server.ca.subject) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now) + .not_valid_after(now + datetime.timedelta(days=365)) + .add_extension( + x509.SubjectAlternativeName( + [ + x509.OtherName( + x509.ObjectIdentifier("1.3.6.1.4.1.311.20.2.3"), + b"\x0c\x0d\x62\x6f\x62\x40\x6c\x6f\x63\x61\x6c\x68\x6f\x73\x74", + ), + ] + ), + False, + ) + .add_extension(x509.ExtendedKeyUsage([x509.oid.ExtendedKeyUsageOID.CLIENT_AUTH]), False) + .sign(tls_server.ca_key, hashes.SHA256()) + ) + + return cert, key + + +def test_get_tls_binding_data_no_data() -> None: + assert get_tls_server_end_point_data(None) is None + + +@pytest.mark.parametrize("algorithm", ["md5", "sha1", "sha256", "sha384", "sha512"]) +def test_get_tls_binding_data_rsa( + algorithm: str, +) -> None: + cert_algo, hash_algo = { + "md5": (hashes.MD5(), hashes.SHA256()), + "sha1": (hashes.SHA1(), hashes.SHA256()), + "sha256": (hashes.SHA256(), hashes.SHA256()), + "sha384": (hashes.SHA384(), hashes.SHA384()), + "sha512": (hashes.SHA512(), hashes.SHA512()), + }[algorithm] + + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + name = x509.Name([x509.NameAttribute(x509.NameOID.COMMON_NAME, "test")]) + now = datetime.datetime.utcnow() + try: + cert = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now) + .not_valid_after(now + datetime.timedelta(days=365)) + .add_extension(x509.ExtendedKeyUsage([x509.oid.ExtendedKeyUsageOID.SERVER_AUTH]), False) + .sign(key, cert_algo) + ).public_bytes(encoding=serialization.Encoding.DER) + except (UnsupportedAlgorithm, ValueError) as e: + pytest.skip(f"Hash algorithm is unavailable: {e}") + + digest = hashes.Hash(hash_algo) + digest.update(cert) + expected = b"tls-server-end-point:" + digest.finalize() + + actual = get_tls_server_end_point_data(cert) + assert actual == expected + + +def test_get_tls_binding_data_ed25519() -> None: + key = ed25519.Ed25519PrivateKey.generate() + name = x509.Name([x509.NameAttribute(x509.NameOID.COMMON_NAME, "test")]) + now = datetime.datetime.utcnow() + cert = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now) + .not_valid_after(now + datetime.timedelta(days=365)) + .add_extension(x509.ExtendedKeyUsage([x509.oid.ExtendedKeyUsageOID.SERVER_AUTH]), False) + .sign(key, None) + ).public_bytes(encoding=serialization.Encoding.DER) + + digest = hashes.Hash(hashes.SHA256()) + digest.update(cert) + expected = b"tls-server-end-point:" + digest.finalize() + + actual = get_tls_server_end_point_data(cert) + assert actual == expected + + +@pytest.mark.parametrize("format", ["pem", "der"]) +def test_trust_cert_file(format: str, tls_server: TlsServer, tmp_path: pathlib.Path) -> None: + context = ssl.create_default_context() + + if format == "pem": + encoding = serialization.Encoding.PEM + else: + encoding = serialization.Encoding.DER + + cert_file = tmp_path / "ca.pem" + cert_file.write_bytes(tls_server.ca.public_bytes(encoding)) + + load_trust_certificate( + context, + str(cert_file), + ) + cert_file.unlink(missing_ok=True) + + perform_handshake(context, tls_server) + + +def test_trust_cert_dir(tls_server: TlsServer, tmp_path: pathlib.Path) -> None: + context = ssl.create_default_context() + + cert_dir = tmp_path / "ca" + cert_dir.mkdir() + + cert_file = cert_dir / "ca.pem" + cert_file.write_bytes(tls_server.ca.public_bytes(serialization.Encoding.PEM)) + + # The c_rehash mechanism is not public and has changed in the past. Use + # OpenSSL to get the expected hash of the cert for this test. + cert_hash = ( + subprocess.check_output( + ["openssl", "x509", "-hash", "-noout", "-in", str(cert_file)], + ) + .decode() + .strip() + ) + cert_file = cert_file.rename(cert_dir / f"{cert_hash}.0") + + load_trust_certificate(context, str(cert_dir)) + + perform_handshake(context, tls_server) + + +def test_trust_cert_str(tls_server: TlsServer) -> None: + context = ssl.create_default_context() + load_trust_certificate( + context, + tls_server.ca.public_bytes(serialization.Encoding.PEM).decode(), + ) + + perform_handshake(context, tls_server) + + +@pytest.mark.parametrize( + "cert_first, password", + [ + (True, None), + (False, None), + (True, b"Password123!\xFF"), + (False, b"Password123!\xFF"), + ], +) +def test_client_auth_path_pem_file_combined( + cert_first: bool, + password: t.Optional[bytes], + tls_server: TlsServer, + client_certificate: t.Tuple["x509.Certificate", "rsa.RSAPrivateKey"], + tmp_path: pathlib.Path, +) -> None: + context = ssl.create_default_context() + load_trust_certificate( + context, + tls_server.ca.public_bytes(serialization.Encoding.PEM).decode(), + ) + + enc_algo = serialization.BestAvailableEncryption(password) if password else serialization.NoEncryption() + cert = client_certificate[0].public_bytes(encoding=serialization.Encoding.PEM) + key = client_certificate[1].private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=enc_algo, + ) + + cert_file = tmp_path / "client.pem" + + if cert_first: + cert_file.write_bytes(cert + b"\n" + key) + else: + cert_file.write_bytes(key + b"\n" + cert) + + load_client_certificate( + context, + str(cert_file), + password=password.decode("utf-8", errors="surrogateescape") if password else None, + ) + + cert_file.unlink() + + perform_handshake(context, tls_server, client_certificate[0]) + + +@pytest.mark.parametrize("password", [None, b"Password123!\xFF"]) +def test_client_auth_path_pem_file_separate_key( + password: t.Optional[bytes], + tls_server: TlsServer, + client_certificate: t.Tuple["x509.Certificate", "rsa.RSAPrivateKey"], + tmp_path: pathlib.Path, +) -> None: + context = ssl.create_default_context() + load_trust_certificate( + context, + tls_server.ca.public_bytes(serialization.Encoding.PEM).decode(), + ) + + enc_algo = serialization.BestAvailableEncryption(password) if password else serialization.NoEncryption() + cert = client_certificate[0].public_bytes(encoding=serialization.Encoding.PEM) + key = client_certificate[1].private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=enc_algo, + ) + + cert_file = tmp_path / "cert.pem" + cert_file.write_bytes(cert) + + key_file = tmp_path / "key.pem" + key_file.write_bytes(key) + + load_client_certificate( + context, + str(cert_file), + key=str(key_file), + password=password.decode("utf-8", errors="surrogateescape") if password else None, + ) + + cert_file.unlink() + key_file.unlink() + + perform_handshake(context, tls_server, client_certificate[0]) + + +@pytest.mark.parametrize("password", [None, b"Password123!\xFF"]) +def test_client_auth_pem_str_combined( + password: t.Optional[bytes], + tls_server: TlsServer, + client_certificate: t.Tuple["x509.Certificate", "rsa.RSAPrivateKey"], +) -> None: + context = ssl.create_default_context() + load_trust_certificate( + context, + tls_server.ca.public_bytes(serialization.Encoding.PEM).decode(), + ) + + enc_algo = serialization.BestAvailableEncryption(password) if password else serialization.NoEncryption() + cert = client_certificate[0].public_bytes(encoding=serialization.Encoding.PEM).decode() + key = ( + client_certificate[1] + .private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=enc_algo, + ) + .decode() + ) + + cert_data = cert + "\n" + key + + load_client_certificate( + context, + cert_data, + password=password.decode("utf-8", errors="surrogateescape") if password else None, + ) + + perform_handshake(context, tls_server, client_certificate[0]) + + +@pytest.mark.parametrize("password", [None, b"Password123!\xFF"]) +def test_client_auth_pem_str_separate_key( + password: t.Optional[bytes], + tls_server: TlsServer, + client_certificate: t.Tuple["x509.Certificate", "rsa.RSAPrivateKey"], +) -> None: + context = ssl.create_default_context() + load_trust_certificate( + context, + tls_server.ca.public_bytes(serialization.Encoding.PEM).decode(), + ) + + enc_algo = serialization.BestAvailableEncryption(password) if password else serialization.NoEncryption() + cert = client_certificate[0].public_bytes(encoding=serialization.Encoding.PEM).decode() + key = ( + client_certificate[1] + .private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=enc_algo, + ) + .decode() + ) + + load_client_certificate( + context, + cert, + key=key, + password=password.decode("utf-8", errors="surrogateescape") if password else None, + ) + + perform_handshake(context, tls_server, client_certificate[0]) + + +@pytest.mark.parametrize( + "cert_is_file, password", + [ + (True, None), + (False, None), + (True, b"Password123!\xFF"), + (False, b"Password123!\xFF"), + ], +) +def test_client_auth_pem_mixed( + cert_is_file: bool, + password: t.Optional[bytes], + tls_server: TlsServer, + client_certificate: t.Tuple["x509.Certificate", "rsa.RSAPrivateKey"], + tmp_path: pathlib.Path, +) -> None: + context = ssl.create_default_context() + load_trust_certificate( + context, + tls_server.ca.public_bytes(serialization.Encoding.PEM).decode(), + ) + + enc_algo = serialization.BestAvailableEncryption(password) if password else serialization.NoEncryption() + cert = client_certificate[0].public_bytes(encoding=serialization.Encoding.PEM).decode() + key = ( + client_certificate[1] + .private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=enc_algo, + ) + .decode() + ) + + cert_file = tmp_path / "cert.pem" + cert_file.write_text(cert) + + key_file = tmp_path / "key.pem" + key_file.write_text(key) + + load_client_certificate( + context, + str(cert_file) if cert_is_file else cert, + key=key if cert_is_file else str(key_file), + password=password.decode("utf-8", errors="surrogateescape") if password else None, + ) + + cert_file.unlink() + key_file.unlink() + + perform_handshake(context, tls_server, client_certificate[0]) + + +def test_client_auth_path_der_cert( + tls_server: TlsServer, + client_certificate: t.Tuple["x509.Certificate", "rsa.RSAPrivateKey"], + tmp_path: pathlib.Path, +) -> None: + context = ssl.create_default_context() + load_trust_certificate( + context, + tls_server.ca.public_bytes(serialization.Encoding.PEM).decode(), + ) + + cert = client_certificate[0].public_bytes(encoding=serialization.Encoding.DER) + key = client_certificate[1].private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + cert_file = tmp_path / "cert.crt" + cert_file.write_bytes(cert) + + key_file = tmp_path / "key.pem" + key_file.write_bytes(key) + + load_client_certificate(context, str(cert_file), key=str(key_file)) + + cert_file.unlink() + key_file.unlink() + + perform_handshake(context, tls_server, client_certificate[0]) + + +@pytest.mark.parametrize("password", [None, b"Password123!\xFF"]) +def test_client_auth_path_der_key( + password: t.Optional[bytes], + tls_server: TlsServer, + client_certificate: t.Tuple["x509.Certificate", "rsa.RSAPrivateKey"], + tmp_path: pathlib.Path, +) -> None: + context = ssl.create_default_context() + load_trust_certificate( + context, + tls_server.ca.public_bytes(serialization.Encoding.PEM).decode(), + ) + + enc_algo = serialization.BestAvailableEncryption(password) if password else serialization.NoEncryption() + cert = client_certificate[0].public_bytes(encoding=serialization.Encoding.PEM) + key = client_certificate[1].private_bytes( + encoding=serialization.Encoding.DER, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=enc_algo, + ) + cert_file = tmp_path / "cert.pem" + cert_file.write_bytes(cert) + + key_file = tmp_path / "key.crt" + key_file.write_bytes(key) + + load_client_certificate( + context, + str(cert_file), + key=str(key_file), + password=password.decode("utf-8", errors="surrogateescape") if password else None, + ) + + cert_file.unlink() + key_file.unlink() + + perform_handshake(context, tls_server, client_certificate[0]) + + +@pytest.mark.parametrize("password", [None, b"Password123!\xFF"]) +def test_client_auth_path_pfx_file( + password: t.Optional[bytes], + tls_server: TlsServer, + client_certificate: t.Tuple["x509.Certificate", "rsa.RSAPrivateKey"], + tmp_path: pathlib.Path, +) -> None: + context = ssl.create_default_context() + load_trust_certificate( + context, + tls_server.ca.public_bytes(serialization.Encoding.PEM).decode(), + ) + + enc_algo = serialization.BestAvailableEncryption(password) if password else serialization.NoEncryption() + data = serialize_key_and_certificates(b"FriendlyName", client_certificate[1], client_certificate[0], None, enc_algo) + + cert_file = tmp_path / "cert.pfx" + cert_file.write_bytes(data) + + load_client_certificate( + context, + str(cert_file), + password=password.decode("utf-8", errors="surrogateescape") if password else None, + ) + + cert_file.unlink() + + perform_handshake(context, tls_server, client_certificate[0]) + + +def test_client_auth_incorrect_password( + client_certificate: t.Tuple["x509.Certificate", "rsa.RSAPrivateKey"], +) -> None: + context = ssl.create_default_context() + + enc_algo = serialization.BestAvailableEncryption(b"Password01") + cert = client_certificate[0].public_bytes(encoding=serialization.Encoding.PEM).decode() + key = ( + client_certificate[1] + .private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=enc_algo, + ) + .decode() + ) + + with pytest.raises(ssl.SSLError): + load_client_certificate(context, key + cert) + + +def perform_handshake( + client: ssl.SSLContext, + server: TlsServer, + expected_client: t.Optional["x509.Certificate"] = None, +) -> None: + client_in = ssl.MemoryBIO() + client_out = ssl.MemoryBIO() + client_tls = client.wrap_bio( + client_in, + client_out, + server_side=False, + server_hostname=server.name, + ) + + server_in = ssl.MemoryBIO() + server_out = ssl.MemoryBIO() + server_tls = server.context.wrap_bio( + server_in, + server_out, + server_side=True, + ) + + in_token: t.Optional[bytes] = None + while True: + if in_token: + client_in.write(in_token) + + out_token: t.Optional[bytes] = None + try: + client_tls.do_handshake() + except ssl.SSLWantReadError: + pass + + out_token = client_out.read() + if not out_token: + break + + server_in.write(out_token) + try: + server_tls.do_handshake() + except ssl.SSLWantReadError: + pass + + in_token = server_out.read() + if not in_token: + break + + assert client_tls.version() == server_tls.version() + assert client_tls.cipher() == server_tls.cipher() + + if expected_client: + client_cert_bytes = server_tls.getpeercert(True) + assert client_cert_bytes is not None + client_cert = x509.load_der_x509_certificate(client_cert_bytes) + assert str(client_cert.subject) == str(expected_client.subject) + assert client_cert.public_bytes(serialization.Encoding.PEM) == expected_client.public_bytes( + serialization.Encoding.PEM + ) diff --git a/ansible_collections/microsoft/ad/tests/unit/plugins/plugin_utils/_ldap/test_laps.py b/ansible_collections/microsoft/ad/tests/unit/plugins/plugin_utils/_ldap/test_laps.py new file mode 100644 index 000000000..662230b65 --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/unit/plugins/plugin_utils/_ldap/test_laps.py @@ -0,0 +1,213 @@ +# Copyright (c) 2023 Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +import base64 +import uuid + +import pytest + +dpapi_ng = pytest.importorskip("dpapi_ng") + +from ansible_collections.microsoft.ad.plugins.plugin_utils._ldap.laps import LAPSDecryptor + + +def test_decrypt_laps_blob() -> None: + root_key = ( + b"\xDC\x24\xFF\x6D\xB1\x31\x70\x18" + b"\x8E\xC9\xFF\xB5\x11\xFA\x41\xA6" + b"\xEB\x51\xD0\x49\xFE\x8C\xFE\x27" + b"\xD6\x83\xD5\x1E\xD5\xA1\x0F\xAF" + b"\x64\x3F\x67\x2E\xE6\xED\x8F\x9F" + b"\x5E\x11\x18\x72\x7B\x3A\xA9\x38" + b"\x4F\xF9\x43\xFF\x5B\x04\xF3\x10" + b"\x53\x0B\x08\x3C\x34\x37\x99\x6E" + ) + cache = dpapi_ng.KeyCache() + cache.load_key(root_key, uuid.UUID("bac64fa8-e890-917c-1090-83e7b0f85996")) + decryptor = LAPSDecryptor() + decryptor._cache = cache + + laps_blob = ( + b"\xB6\x82\xD9\x01\x64\x7B\xAE\x07" + b"\xE4\x04\x00\x00\x00\x00\x00\x00" + b"\x30\x82\x04\x4E\x06\x09\x2A\x86" + b"\x48\x86\xF7\x0D\x01\x07\x03\xA0" + b"\x82\x04\x3F\x30\x82\x04\x3B\x02" + b"\x01\x02\x31\x82\x04\x07\xA2\x82" + b"\x04\x03\x02\x01\x04\x30\x82\x03" + b"\xC5\x04\x82\x03\x6C\x01\x00\x00" + b"\x00\x4B\x44\x53\x4B\x03\x00\x00" + b"\x00\x69\x01\x00\x00\x11\x00\x00" + b"\x00\x12\x00\x00\x00\xA8\x4F\xC6" + b"\xBA\x90\xE8\x7C\x91\x10\x90\x83" + b"\xE7\xB0\xF8\x59\x96\x08\x03\x00" + b"\x00\x18\x00\x00\x00\x18\x00\x00" + b"\x00\x44\x48\x50\x42\x00\x01\x00" + b"\x00\x87\xA8\xE6\x1D\xB4\xB6\x66" + b"\x3C\xFF\xBB\xD1\x9C\x65\x19\x59" + b"\x99\x8C\xEE\xF6\x08\x66\x0D\xD0" + b"\xF2\x5D\x2C\xEE\xD4\x43\x5E\x3B" + b"\x00\xE0\x0D\xF8\xF1\xD6\x19\x57" + b"\xD4\xFA\xF7\xDF\x45\x61\xB2\xAA" + b"\x30\x16\xC3\xD9\x11\x34\x09\x6F" + b"\xAA\x3B\xF4\x29\x6D\x83\x0E\x9A" + b"\x7C\x20\x9E\x0C\x64\x97\x51\x7A" + b"\xBD\x5A\x8A\x9D\x30\x6B\xCF\x67" + b"\xED\x91\xF9\xE6\x72\x5B\x47\x58" + b"\xC0\x22\xE0\xB1\xEF\x42\x75\xBF" + b"\x7B\x6C\x5B\xFC\x11\xD4\x5F\x90" + b"\x88\xB9\x41\xF5\x4E\xB1\xE5\x9B" + b"\xB8\xBC\x39\xA0\xBF\x12\x30\x7F" + b"\x5C\x4F\xDB\x70\xC5\x81\xB2\x3F" + b"\x76\xB6\x3A\xCA\xE1\xCA\xA6\xB7" + b"\x90\x2D\x52\x52\x67\x35\x48\x8A" + b"\x0E\xF1\x3C\x6D\x9A\x51\xBF\xA4" + b"\xAB\x3A\xD8\x34\x77\x96\x52\x4D" + b"\x8E\xF6\xA1\x67\xB5\xA4\x18\x25" + b"\xD9\x67\xE1\x44\xE5\x14\x05\x64" + b"\x25\x1C\xCA\xCB\x83\xE6\xB4\x86" + b"\xF6\xB3\xCA\x3F\x79\x71\x50\x60" + b"\x26\xC0\xB8\x57\xF6\x89\x96\x28" + b"\x56\xDE\xD4\x01\x0A\xBD\x0B\xE6" + b"\x21\xC3\xA3\x96\x0A\x54\xE7\x10" + b"\xC3\x75\xF2\x63\x75\xD7\x01\x41" + b"\x03\xA4\xB5\x43\x30\xC1\x98\xAF" + b"\x12\x61\x16\xD2\x27\x6E\x11\x71" + b"\x5F\x69\x38\x77\xFA\xD7\xEF\x09" + b"\xCA\xDB\x09\x4A\xE9\x1E\x1A\x15" + b"\x97\x3F\xB3\x2C\x9B\x73\x13\x4D" + b"\x0B\x2E\x77\x50\x66\x60\xED\xBD" + b"\x48\x4C\xA7\xB1\x8F\x21\xEF\x20" + b"\x54\x07\xF4\x79\x3A\x1A\x0B\xA1" + b"\x25\x10\xDB\xC1\x50\x77\xBE\x46" + b"\x3F\xFF\x4F\xED\x4A\xAC\x0B\xB5" + b"\x55\xBE\x3A\x6C\x1B\x0C\x6B\x47" + b"\xB1\xBC\x37\x73\xBF\x7E\x8C\x6F" + b"\x62\x90\x12\x28\xF8\xC2\x8C\xBB" + b"\x18\xA5\x5A\xE3\x13\x41\x00\x0A" + b"\x65\x01\x96\xF9\x31\xC7\x7A\x57" + b"\xF2\xDD\xF4\x63\xE5\xE9\xEC\x14" + b"\x4B\x77\x7D\xE6\x2A\xAA\xB8\xA8" + b"\x62\x8A\xC3\x76\xD2\x82\xD6\xED" + b"\x38\x64\xE6\x79\x82\x42\x8E\xBC" + b"\x83\x1D\x14\x34\x8F\x6F\x2F\x91" + b"\x93\xB5\x04\x5A\xF2\x76\x71\x64" + b"\xE1\xDF\xC9\x67\xC1\xFB\x3F\x2E" + b"\x55\xA4\xBD\x1B\xFF\xE8\x3B\x9C" + b"\x80\xD0\x52\xB9\x85\xD1\x82\xEA" + b"\x0A\xDB\x2A\x3B\x73\x13\xD3\xFE" + b"\x14\xC8\x48\x4B\x1E\x05\x25\x88" + b"\xB9\xB7\xD2\xBB\xD2\xDF\x01\x61" + b"\x99\xEC\xD0\x6E\x15\x57\xCD\x09" + b"\x15\xB3\x35\x3B\xBB\x64\xE0\xEC" + b"\x37\x7F\xD0\x28\x37\x0D\xF9\x2B" + b"\x52\xC7\x89\x14\x28\xCD\xC6\x7E" + b"\xB6\x18\x4B\x52\x3D\x1D\xB2\x46" + b"\xC3\x2F\x63\x07\x84\x90\xF0\x0E" + b"\xF8\xD6\x47\xD1\x48\xD4\x79\x54" + b"\x51\x5E\x23\x27\xCF\xEF\x98\xC5" + b"\x82\x66\x4B\x4C\x0F\x6C\xC4\x16" + b"\x59\x13\xE2\xBB\xB1\xC4\xA8\x36" + b"\x61\x13\x90\xEB\xA3\xA4\x67\x40" + b"\xF2\x9E\x7D\xAC\x49\xA4\xBE\x93" + b"\xC6\xFE\xBC\xFC\x1F\x13\xAA\xAF" + b"\xFE\xFE\x2A\x44\x17\x0E\x5B\x2E" + b"\x03\x17\x9B\x42\x82\x1D\xCD\x06" + b"\x2C\xAD\xEF\xA6\x6F\x3C\x60\xD5" + b"\xC5\x3C\x2B\x96\xAC\x08\x02\xA0" + b"\x7C\xCA\x7F\x51\x60\xD9\xF3\x3A" + b"\xE2\xFA\x87\xA5\x90\x61\x91\x6B" + b"\xFD\x89\x3D\x20\x72\x7D\xDE\xCA" + b"\x47\xD4\x21\x2D\xD9\x0D\x0F\x65" + b"\xB2\x42\x60\xBD\x9D\xC1\xF1\x19" + b"\x7B\x5E\x7B\xCE\x08\x05\x00\xC1" + b"\xEA\x95\xA4\xAB\x60\xBE\x3C\x13" + b"\x0F\xB6\xB0\x76\xFC\xA0\x6F\x8E" + b"\xE1\x39\x7E\x58\x84\x53\x6B\xF9" + b"\x03\x14\xAF\x12\xCF\xB3\x1A\x1A" + b"\xAC\x10\x51\x72\x83\x17\xF3\xCC" + b"\x28\x47\xDC\x3F\xE2\x54\x3A\x7E" + b"\xA1\xFF\x23\xB7\xC9\xD6\x0F\x6B" + b"\x1E\xD9\x20\xB4\xC6\x0B\x9D\xC4" + b"\xDF\x08\x7D\xD0\x95\x5D\x01\xDC" + b"\xFC\x4E\xBC\x9F\xF5\x33\x0C\xF0" + b"\xF1\x1B\x46\xF9\x4D\x16\x64\x36" + b"\x73\x52\xCF\xAD\xEC\x72\x17\x0F" + b"\x4A\xA2\xC8\x5A\xAF\x73\x6C\xAE" + b"\xF4\x7A\x21\x65\x4B\xBF\xDD\xAB" + b"\x34\x8D\xDD\x9C\x22\x8E\x2C\xDB" + b"\xD1\xAD\x08\xE1\x87\x31\xC6\xA4" + b"\x6E\xF4\xCF\x4C\xF1\xE3\x5E\x0B" + b"\x10\x65\xFA\x51\x0D\x53\x01\x88" + b"\xF3\x64\x00\x6F\x00\x6D\x00\x61" + b"\x00\x69\x00\x6E\x00\x2E\x00\x74" + b"\x00\x65\x00\x73\x00\x74\x00\x00" + b"\x00\x64\x00\x6F\x00\x6D\x00\x61" + b"\x00\x69\x00\x6E\x00\x2E\x00\x74" + b"\x00\x65\x00\x73\x00\x74\x00\x00" + b"\x00\x30\x53\x06\x09\x2B\x06\x01" + b"\x04\x01\x82\x37\x4A\x01\x30\x46" + b"\x06\x0A\x2B\x06\x01\x04\x01\x82" + b"\x37\x4A\x01\x01\x30\x38\x30\x36" + b"\x30\x34\x0C\x03\x53\x49\x44\x0C" + b"\x2D\x53\x2D\x31\x2D\x35\x2D\x32" + b"\x31\x2D\x34\x31\x35\x31\x38\x30" + b"\x38\x37\x39\x37\x2D\x33\x34\x33" + b"\x30\x35\x36\x31\x30\x39\x32\x2D" + b"\x32\x38\x34\x33\x34\x36\x34\x35" + b"\x38\x38\x2D\x35\x31\x32\x30\x0B" + b"\x06\x09\x60\x86\x48\x01\x65\x03" + b"\x04\x01\x2D\x04\x28\x8F\xC9\xA4" + b"\x80\xB4\x86\x54\x29\x23\x70\xE5" + b"\x13\x2C\xC3\x71\xCE\x0A\xA2\x1B" + b"\x42\x0D\x6C\xBF\x59\x10\x29\x91" + b"\xED\xEB\x3D\x1B\x79\x08\xDD\x15" + b"\x84\x19\x35\xFF\xA0\x30\x2B\x06" + b"\x09\x2A\x86\x48\x86\xF7\x0D\x01" + b"\x07\x01\x30\x1E\x06\x09\x60\x86" + b"\x48\x01\x65\x03\x04\x01\x2E\x30" + b"\x11\x04\x0C\x03\xDD\xDF\x73\xE1" + b"\x5C\x1C\xFB\xBD\x6C\x6F\x50\x02" + b"\x01\x10\x75\x32\x34\x64\x9C\x6E" + b"\xD7\xB0\xC7\xE0\xE4\x36\x90\xB1" + b"\x40\x7F\x20\xB3\xB8\x45\xE2\xFD" + b"\x62\xBE\x7C\x1B\x17\xAE\x0C\xC4" + b"\x4C\xD7\xD2\x4A\x97\xD9\x4E\x05" + b"\x4A\x06\x96\xC0\x73\xF2\x94\xF0" + b"\xAF\x85\xDB\xFF\xD2\x42\x3E\xEE" + b"\x0B\xCB\x24\xF1\xE6\x75\x7B\xA9" + b"\xB9\x56\x2F\xBA\x90\x49\x2D\xE8" + b"\x58\xE9\xCE\x7D\x30\xD3\x46\xA3" + b"\xDA\x81\x7A\xFA\x18\x92\x50\x72" + b"\xD2\x6B\x16\xB7\x56\xBD\x9C\xDE" + b"\xF4\x9D\xD5\x2A\x8B\x2A\x4D\xB0" + b"\x0D\xFB\xAA\x76\x9F\xAB\xE8\x7A" + b"\x33\xDC\xE8\xD4\x3B\xF8\x21\x9B" + b"\x35\x30\x66\x70\x8A\xB5\xDE\xB6" + b"\xFC\x05\xB2\x20\x22\x2C\x09\xD7" + b"\x74\x32\x16\x80\x83\x0A\xEE\x42" + b"\x15\x5D\x56\x22" + ) + actual = decryptor.decrypt(laps_blob) + assert actual['update_timestamp'] == 133281382308674404 + assert actual['flags'] == 0 + assert actual['encrypted_value'] == base64.b64encode(laps_blob[16:]).decode() + assert actual['value'] == '{"n":"Administrator","t":"1d982b607ae7b64","p":"6jr&}yK++{0Q}&"}' + assert 'debug' not in actual + + +def test_decrypt_invalid_laps_blob() -> None: + decryptor = LAPSDecryptor() + + laps_blob = ( + b"\xB6\x82\xD9\x01\x64\x7B\xAE\x07" + b"\x01\x00\x00\x00\x01\x00\x00\x00" + b"\x01" + ) + + actual = decryptor.decrypt(laps_blob) + assert actual['update_timestamp'] == 133281382308674404 + assert actual['flags'] == 1 + assert actual['encrypted_value'] == "AQ==" + assert 'value' not in actual + assert 'Failed to decrypt value due to error - NotEnougData' in actual['debug'] diff --git a/ansible_collections/microsoft/ad/tests/unit/plugins/plugin_utils/_ldap/test_schema.py b/ansible_collections/microsoft/ad/tests/unit/plugins/plugin_utils/_ldap/test_schema.py new file mode 100644 index 000000000..6238a387d --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/unit/plugins/plugin_utils/_ldap/test_schema.py @@ -0,0 +1,156 @@ +# Copyright (c) 2023 Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +import uuid + +import pytest + +sansldap = pytest.importorskip("sansldap") + +from ansible_collections.microsoft.ad.plugins.plugin_utils._ldap.schema import LDAPSchema + + +def test_cast_from_objectsid() -> None: + type_desc = sansldap.schema.AttributeTypeDescription( + "1.0", + names=["objectSid"], + syntax="foo", + single_value=True, + ) + schema = LDAPSchema({"objectsid": type_desc}) + + actual = schema.cast_object("objectSid", [b"\x01\x01\x00\x00\x00\x00\x00\x05\x13\x00\x00\x00"]) + assert actual == "S-1-5-19" + + +def test_cast_from_objectguid() -> None: + type_desc = sansldap.schema.AttributeTypeDescription( + "1.0", + names=["objectGuid"], + syntax="bar", + single_value=True, + ) + schema = LDAPSchema({"objectguid": type_desc}) + + value = uuid.uuid4() + actual = schema.cast_object("objectGuid", [value.bytes_le]) + assert actual == str(value) + + +@pytest.mark.parametrize("single_value", [True, False]) +def test_from_bool(single_value: bool) -> None: + type_desc = sansldap.schema.AttributeTypeDescription( + "1.0", + names=["myAttr"], + syntax="1.3.6.1.4.1.1466.115.121.1.7", + single_value=single_value, + ) + schema = LDAPSchema({"myattr": type_desc}) + + actual = schema.cast_object("myAttr", [b"TRUE", b"FALSE"]) + if single_value: + assert actual is True + else: + assert actual == [True, False] + + +@pytest.mark.parametrize( + "single_value, syntax", + [ + (True, "1.3.6.1.4.1.1466.115.121.1.27"), + (False, "1.3.6.1.4.1.1466.115.121.1.27"), + (True, "1.2.840.113556.1.4.906"), + (False, "1.2.840.113556.1.4.906"), + ], +) +def test_from_int(single_value: bool, syntax: str) -> None: + type_desc = sansldap.schema.AttributeTypeDescription( + "1.0", + names=["myAttr"], + syntax=syntax, + single_value=single_value, + ) + schema = LDAPSchema({"myattr": type_desc}) + + actual = schema.cast_object("myAttr", [b"1", b"2345678910"]) + if single_value: + assert actual == 1 + else: + assert actual == [1, 2345678910] + + +@pytest.mark.parametrize( + "single_value, syntax", + [ + (True, "1.3.6.1.4.1.1466.115.121.1.40"), + (False, "1.3.6.1.4.1.1466.115.121.1.40"), + (True, "1.2.840.113556.1.4.907"), + (False, "1.2.840.113556.1.4.907"), + (True, "OctetString"), + (False, "OctetString"), + ], +) +def test_from_bytes(single_value: bool, syntax: str) -> None: + type_desc = sansldap.schema.AttributeTypeDescription( + "1.0", + names=["myAttr"], + syntax=syntax, + single_value=single_value, + ) + schema = LDAPSchema({"myattr": type_desc}) + + actual = schema.cast_object("myAttr", [b"\x00", b"\x00\x01"]) + if single_value: + assert actual == "AA==" + else: + assert actual == ["AA==", "AAE="] + + +@pytest.mark.parametrize("single_value", [True, False]) +def test_from_string(single_value: bool) -> None: + type_desc = sansldap.schema.AttributeTypeDescription( + "1.0", + names=["myAttr"], + syntax="Something", + single_value=single_value, + ) + schema = LDAPSchema({"myattr": type_desc}) + + actual = schema.cast_object("myAttr", [b"caf\xc3\xa9\xFF", b"\x00\x7E\xDF\xFF"]) + if single_value: + assert actual == "café\uDCFF" + else: + assert actual == ["café\uDCFF", "\u0000~\uDCDF\uDCFF"] + + +def test_from_string_no_type_desc() -> None: + schema = LDAPSchema({}) + + actual = schema.cast_object("myAttr", [b"caf\xc3\xa9\xFF", b"\x00\x7E\xDF\xFF"]) + assert actual == ["café\uDCFF", "\u0000~\uDCDF\uDCFF"] + + +def test_single_value_empty_input() -> None: + type_desc = sansldap.schema.AttributeTypeDescription( + "1.0", + names=["myAttr"], + syntax="OctetString", + single_value=True, + ) + schema = LDAPSchema({"myattr": type_desc}) + + actual = schema.cast_object("myAttr", []) + assert actual is None + + +def test_multi_value_empty_input() -> None: + type_desc = sansldap.schema.AttributeTypeDescription( + "1.0", + names=["myAttr"], + syntax="OctetString", + single_value=False, + ) + schema = LDAPSchema({"myattr": type_desc}) + + actual = schema.cast_object("myAttr", []) + assert actual == [] diff --git a/ansible_collections/microsoft/ad/tests/unit/requirements.txt b/ansible_collections/microsoft/ad/tests/unit/requirements.txt new file mode 100644 index 000000000..91a51907a --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/unit/requirements.txt @@ -0,0 +1,6 @@ +setuptools > 0.6 # pytest-xdist installed via requirements does not work with very old setuptools (sanity_ok) +unittest2 ; python_version < '2.7' +importlib ; python_version < '2.7' +cryptography +dpapi-ng +sansldap
\ No newline at end of file diff --git a/ansible_collections/microsoft/ad/tests/utils/shippable/sanity.sh b/ansible_collections/microsoft/ad/tests/utils/shippable/sanity.sh new file mode 100755 index 000000000..f7165f06d --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/utils/shippable/sanity.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +set -o pipefail -eux + +if [ "${BASE_BRANCH:-}" ]; then + base_branch="origin/${BASE_BRANCH}" +else + base_branch="" +fi + +# shellcheck disable=SC2086 +ansible-test sanity --color -v --junit ${COVERAGE:+"$COVERAGE"} ${CHANGED:+"$CHANGED"} \ + --docker --base-branch "${base_branch}" --allow-disabled diff --git a/ansible_collections/microsoft/ad/tests/utils/shippable/shippable.sh b/ansible_collections/microsoft/ad/tests/utils/shippable/shippable.sh new file mode 100755 index 000000000..cca295366 --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/utils/shippable/shippable.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash + +set -o pipefail -eux + +declare -a args +IFS='/:' read -ra args <<< "$1" + +ansible_version="${args[0]}" +script="${args[1]}" + +function join { + local IFS="$1"; + shift; + echo "$*"; +} + +test="$(join / "${args[@]:1}")" + +docker images ansible/ansible +docker images quay.io/ansible/* +docker ps + +for container in $(docker ps --format '{{.Image}} {{.ID}}' | grep -v -e '^drydock/' -e '^quay.io/ansible/azure-pipelines-test-container:' | sed 's/^.* //'); do + docker rm -f "${container}" || true # ignore errors +done + +docker ps + +command -v python +python -V + +function retry +{ + # shellcheck disable=SC2034 + for repetition in 1 2 3; do + set +e + "$@" + result=$? + set -e + if [ ${result} == 0 ]; then + return ${result} + fi + echo "@* -> ${result}" + done + echo "Command '@*' failed 3 times!" + exit 1 +} + +command -v pip +pip --version +pip list --disable-pip-version-check +if [ "${ansible_version}" == "devel" ]; then + retry pip install https://github.com/ansible/ansible/archive/devel.tar.gz --disable-pip-version-check +else + retry pip install "https://github.com/ansible/ansible/archive/stable-${ansible_version}.tar.gz" --disable-pip-version-check +fi + +sudo chown "$(whoami)" "${PWD}/../../" + +export PYTHONIOENCODING='utf-8' + +if [ -n "${COVERAGE:-}" ]; then + # on-demand coverage reporting triggered by setting the COVERAGE environment variable to a non-empty value + export COVERAGE="--coverage" +elif [[ "${COMMIT_MESSAGE}" =~ ci_coverage ]]; then + # on-demand coverage reporting triggered by having 'ci_coverage' in the latest commit message + export COVERAGE="--coverage" +else + # on-demand coverage reporting disabled (default behavior, always-on coverage reporting remains enabled) + export COVERAGE="--coverage-check" +fi + +if [ -n "${COMPLETE:-}" ]; then + # disable change detection triggered by setting the COMPLETE environment variable to a non-empty value + export CHANGED="" +elif [[ "${COMMIT_MESSAGE}" =~ ci_complete ]]; then + # disable change detection triggered by having 'ci_complete' in the latest commit message + export CHANGED="" +else + # enable change detection (default behavior) + export CHANGED="--changed" +fi + +if [ "${IS_PULL_REQUEST:-}" == "true" ]; then + # run unstable tests which are targeted by focused changes on PRs + export UNSTABLE="--allow-unstable-changed" +else + # do not run unstable tests outside PRs + export UNSTABLE="" +fi + +if [[ "${COVERAGE:-}" == "--coverage" ]]; then + timeout=60 +else + timeout=50 +fi + +ansible-test env --dump --show --timeout "${timeout}" --color -v + +"tests/utils/shippable/${script}.sh" "${test}" diff --git a/ansible_collections/microsoft/ad/tests/utils/shippable/units.sh b/ansible_collections/microsoft/ad/tests/utils/shippable/units.sh new file mode 100755 index 000000000..bcf7a771a --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/utils/shippable/units.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -o pipefail -eux + +# shellcheck disable=SC2086 +ansible-test units --color -v --docker default ${COVERAGE:+"$COVERAGE"} ${CHANGED:+"$CHANGED"} + diff --git a/ansible_collections/microsoft/ad/tests/utils/shippable/windows.sh b/ansible_collections/microsoft/ad/tests/utils/shippable/windows.sh new file mode 100755 index 000000000..9b624e0b8 --- /dev/null +++ b/ansible_collections/microsoft/ad/tests/utils/shippable/windows.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash + +set -o pipefail -eux + +declare -a args +IFS='/:' read -ra args <<< "$1" + +version="${args[1]}" + +if [ "${#args[0]}" -gt 2 ]; then + target="shippable/windows/group${args[2]}/" +else + target="shippable/windows/" +fi + +stage="${S:-prod}" +provider="${P:-default}" + +# shellcheck disable=SC2086 +ansible-test windows-integration --color -v --retry-on-error "${target}" ${COVERAGE:+"$COVERAGE"} ${CHANGED:+"$CHANGED"} ${UNSTABLE:+"$UNSTABLE"} \ + --windows "${version}" --docker default --remote-terminate always --remote-stage "${stage}" --remote-provider "${provider}" |