diff --git a/.github/actions/docker-image-versions/versions.py b/.github/actions/docker-image-versions/versions.py index 6dc69ffb7..9d7fcea2d 100755 --- a/.github/actions/docker-image-versions/versions.py +++ b/.github/actions/docker-image-versions/versions.py @@ -8,6 +8,7 @@ from __future__ import (absolute_import, division, print_function) __metaclass__ = type +import os import sys import getopt @@ -16,13 +17,20 @@ import requests from urllib3.util.retry import Retry from requests.adapters import HTTPAdapter - +from warnings import warn from packaging import version TAG_URI = 'https://registry.hub.docker.com/v2/repositories/library/%s/tags?page_size=1024' +class WarningRetry(Retry): + def new(self, **kwargs): + if self.total > 0: + warn('Error on request. Retries remaining: %i' % (self.total,)) + return super().new(**kwargs) + + def main(argv): image = None include_prerelease = include_postrelease = False @@ -59,7 +67,7 @@ def main(argv): tag_url = TAG_URI % image sess = requests.Session() - retry = Retry(total=5, backoff_factor=0.2) + retry = WarningRetry(total=5, backoff_factor=0.2, respect_retry_after_header=False) adapter = HTTPAdapter(max_retries=retry) sess.mount('https://', adapter) @@ -71,10 +79,10 @@ def main(argv): try: vobj = version.parse(tag['name']) except Exception: - pass - - if vobj is None or isinstance(vobj, version.LegacyVersion): continue + else: + if not isinstance(vobj, version.Version): + continue if vobj.is_prerelease is include_prerelease and vobj.is_postrelease is include_postrelease: versions.append(vobj) @@ -104,7 +112,9 @@ def main(argv): keep.append(str(ver)) - print('::set-output name=versions::%s' % json.dumps(keep)) + with open(os.environ['GITHUB_OUTPUT'], 'a') as f: + f.write('versions=') + json.dump(keep, f) if __name__ == '__main__': diff --git a/.github/actions/macos-docker/action.yml b/.github/actions/macos-docker/action.yml deleted file mode 100644 index 70833113a..000000000 --- a/.github/actions/macos-docker/action.yml +++ /dev/null @@ -1,23 +0,0 @@ ---- -name: Install Docker on MacOS GitHub Runner -description: Install and configure docker for a MacOS GitHub runner, and export the environment variables. -branding: - icon: command - color: white -runs: - using: composite - steps: - - shell: bash - run: | - mkdir -p ~/.docker/machine/cache - curl -Lo ~/.docker/machine/cache/boot2docker.iso https://github.com/boot2docker/boot2docker/releases/download/v19.03.12/boot2docker.iso - - sudo mkdir -p /etc/vbox - sudo chown -R $(whoami) /etc/vbox - sudo echo '* 0.0.0.0/0 ::/0' > /etc/vbox/networks.conf - - brew install docker-machine docker - docker --version - docker-machine create --driver virtualbox default - docker-machine env default - docker-machine env default | sed 's/^export //;/^#/d;s/^#.*//' | tr -d '"' >> $GITHUB_ENV diff --git a/.github/workflows/ansible-builder.yml b/.github/workflows/ansible-builder.yml index 5b594f419..10aeb5c2d 100644 --- a/.github/workflows/ansible-builder.yml +++ b/.github/workflows/ansible-builder.yml @@ -3,10 +3,12 @@ name: ansible-builder on: push: paths: + - '.github/workflows/ansible-builder.yml' - 'meta/execution-environment.yml' - 'meta/ee-requirements.txt' pull_request: paths: + - '.github/workflows/ansible-builder.yml' - 'meta/execution-environment.yml' - 'meta/ee-requirements.txt' schedule: @@ -27,7 +29,7 @@ jobs: path: ansible_collections/${{ env.NAMESPACE }}/${{ env.COLLECTION_NAME }} - name: Set up Python - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: 3.9 diff --git a/.github/workflows/ansible-test.yml b/.github/workflows/ansible-test.yml index 7ec5c5632..8eac2e64b 100644 --- a/.github/workflows/ansible-test.yml +++ b/.github/workflows/ansible-test.yml @@ -40,6 +40,7 @@ jobs: - stable-2.11 - stable-2.12 - stable-2.13 + - stable-2.14 - devel steps: @@ -61,13 +62,13 @@ jobs: run: ln -s "${COLLECTION_PATH}/.github" .github - name: Set up Python - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: # it is just required to run that once as "ansible-test sanity" in the docker image # will run on all python versions it supports. python-version: 3.9 - # Install the head of the given branch (devel, stable-2.13) + # Install the head of the given branch (devel, stable-2.14) - name: Install ansible-base (${{ matrix.ansible }}) run: pip install https://github.com/ansible/ansible/archive/${{ matrix.ansible }}.tar.gz --disable-pip-version-check @@ -116,6 +117,7 @@ jobs: - stable-2.11 - stable-2.12 - stable-2.13 + - stable-2.14 - devel steps: @@ -135,7 +137,7 @@ jobs: run: ln -s "${COLLECTION_PATH}/.github" .github - name: Set up Python - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: # it is just required to run that once as "ansible-test units" in the docker image # will run on all python versions it supports. @@ -190,6 +192,7 @@ jobs: - stable-2.11 - stable-2.12 - stable-2.13 + - stable-2.14 - devel python: - '3.6' @@ -226,7 +229,7 @@ jobs: run: ln -s "${COLLECTION_PATH}/.github" .github - name: Set up Python - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: 3.9 @@ -293,31 +296,18 @@ jobs: local_test_invocation: runs-on: ${{ matrix.runner }} name: LI - ${{ matrix.runner }} (Ⓐ${{ matrix.ansible }}+py${{ matrix.python }}) - env: - # needed to prevent Ansible crashing on MacOS - OBJC_DISABLE_INITIALIZE_FORK_SAFETY: 'YES' strategy: fail-fast: false matrix: ansible: - - stable-2.12 - - stable-2.13 + - stable-2.14 + - devel python: - 3.9 runner: - ubuntu-latest - - macos-12 test_container: - default - exclude: - # To add to the fragility of testing docker stuff on MacOS, - # stable-2.13 test containers crash; unsure of exact cause - # but likely due to old versions of the runtimes. - # We'll just stick to 2.12 for now, better than nothing. - - runner: macos-12 - ansible: stable-2.13 - - runner: ubuntu-latest - ansible: stable-2.12 steps: - name: Initialize env vars @@ -327,7 +317,7 @@ jobs: COLLECTION_PATH=ansible_collections/${NAMESPACE}/${COLLECTION_NAME} COLLECTION_INTEGRATION_PATH=${COLLECTION_PATH}/tests/integration COLLECTION_INTEGRATION_TARGETS=${COLLECTION_INTEGRATION_PATH}/targets - DOCKER_TEST_INVOCATION="integration -v --color --retry-on-error --continue-on-error --python ${{ matrix.python }} --docker ${{ matrix.test_container }} ${{ github.event_name != 'schedule' && '--coverage' || '' }}" + DOCKER_TEST_INVOCATION="integration -v --color --retry-on-error --continue-on-error --controller docker:${{ matrix.test_container }},python=${{ matrix.python }} ${{ github.event_name != 'schedule' && '--coverage' || '' }}" - name: Check out code uses: actions/checkout@v3 @@ -338,7 +328,7 @@ jobs: run: ln -s "${COLLECTION_PATH}/.github" .github - name: Set up Python - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python }} @@ -355,12 +345,6 @@ jobs: with: collection: community.docker - - name: Install Docker on MacOS - if: ${{ startsWith(matrix.runner, 'macos') }} - timeout-minutes: 6 - # sometimes this hangs forever waiting for an IP - uses: ./.github/actions/macos-docker - - name: Pull Ansible test images timeout-minutes: 5 continue-on-error: true @@ -388,7 +372,6 @@ jobs: #TODO add capability in the Ansible side once vault_list and vault_delete exist - name: Run a third time, but delete Vault's cubbyhole contents first - if: startsWith(matrix.runner, 'ubuntu') working-directory: ${{ env.COLLECTION_PATH }} env: VAULT_TOKEN: 47542cbc-6bf8-4fba-8eda-02e0a0d29a0a diff --git a/.github/workflows/docs-push.yml b/.github/workflows/docs-push.yml index 658030e68..82a989507 100644 --- a/.github/workflows/docs-push.yml +++ b/.github/workflows/docs-push.yml @@ -38,7 +38,7 @@ jobs: if: github.repository == 'ansible-collections/community.hashi_vault' permissions: contents: read - needs: [build-docs] + needs: [validate-docs, build-docs] name: Publish Ansible Docs uses: ansible-community/github-docs-build/.github/workflows/_shared-docs-build-publish-surge.yml@main with: @@ -52,7 +52,7 @@ jobs: if: github.repository == 'ansible-collections/community.hashi_vault' permissions: contents: write - needs: [build-docs] + needs: [validate-docs, build-docs] name: Publish Ansible Docs uses: ansible-community/github-docs-build/.github/workflows/_shared-docs-build-publish-gh-pages.yml@main with: diff --git a/.github/workflows/github-release.yml b/.github/workflows/github-release.yml index 547d16e34..6fce7d2e6 100644 --- a/.github/workflows/github-release.yml +++ b/.github/workflows/github-release.yml @@ -18,9 +18,9 @@ jobs: uses: actions/checkout@v3 - name: Set up Python - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: - python-version: 3.8 + python-version: 3.9 - name: Install PyYaml run: pip install pyyaml diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 5cdaffc2c..f9ff0e57c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,119 @@ community.hashi_vault Release Notes .. contents:: Topics +v4.1.0 +====== + +Release Summary +--------------- + +This release brings new generic ``vault_list`` plugins from a new contributor! +There are also some deprecation notices for the next major version, and some updates to documentation attributes. + +Deprecated Features +------------------- + +- ansible-core - support for ``ansible-core`` versions ``2.11`` and ``2.12`` will be dropped in collection version ``5.0.0``, making ``2.13`` the minimum supported version of ``ansible-core`` (https://github.com/ansible-collections/community.hashi_vault/issues/340). +- hvac - the minimum version of ``hvac`` to be supported in collection version ``5.0.0`` will be at least ``1.0.2``; this minimum may be raised before ``5.0.0`` is released, so please subscribe to the linked issue and look out for new notices in the changelog (https://github.com/ansible-collections/community.hashi_vault/issues/324). + +New Plugins +----------- + +Lookup +~~~~~~ + +- vault_list - Perform a list operation against HashiCorp Vault + +New Modules +----------- + +- vault_list - Perform a list operation against HashiCorp Vault + +v4.0.0 +====== + +Release Summary +--------------- + +The next major version of the collection includes previously announced breaking changes to some default values, and improvements to module documentation with attributes that describe the use of action groups and check mode support. + +Minor Changes +------------- + +- modules - all modules now document their action group and support for check mode in their attributes documentation (https://github.com/ansible-collections/community.hashi_vault/issues/197). + +Breaking Changes / Porting Guide +-------------------------------- + +- auth - the default value for ``token_validate`` has changed from ``true`` to ``false``, as previously announced (https://github.com/ansible-collections/community.hashi_vault/issues/248). +- vault_kv2_get lookup - as previously announced, the default value for ``engine_mount_point`` in the ``vault_kv2_get`` lookup has changed from ``kv`` to ``secret`` (https://github.com/ansible-collections/community.hashi_vault/issues/279). + +v3.4.0 +====== + +Release Summary +--------------- + +This release includes a new module, fixes (another) ``requests`` header issue, and updates some inaccurate documentation. +This is the last planned release before v4.0.0. + +Minor Changes +------------- + +- vault_pki_generate_certificate - the documentation has been updated to match the argspec for the default values of options ``alt_names``, ``ip_sans``, ``other_sans``, and ``uri_sans`` (https://github.com/ansible-collections/community.hashi_vault/pull/318). + +Bugfixes +-------- + +- connection options - the ``namespace`` connection option will be forced into a string to ensure cmpatibility with recent ``requests`` versions (https://github.com/ansible-collections/community.hashi_vault/issues/309). + +New Modules +----------- + +- vault_kv2_delete - Delete one or more versions of a secret from HashiCorp Vault's KV version 2 secret store + +v3.3.1 +====== + +Release Summary +--------------- + +No functional changes in this release, this provides updated filter documentation for the public docsite. + +v3.3.0 +====== + +Release Summary +--------------- + +With the release of ``hvac`` version ``1.0.0``, we needed to update ``vault_token_create``'s support for orphan tokens. +The collection's changelog is now viewable in the Ansible documentation site. + +Minor Changes +------------- + +- vault_token_create - creation or orphan tokens uses ``hvac``'s new v1 method for creating orphans, or falls back to the v0 method if needed (https://github.com/ansible-collections/community.hashi_vault/issues/301). + +v3.2.0 +====== + +Release Summary +--------------- + +This release brings support for the ``azure`` auth method, adds ``412`` to the default list of HTTP status codes to be retried, and fixes a bug that causes failures in token auth with ``requests>=2.28.0``. + +Minor Changes +------------- + +- community.hashi_vault collection - add support for ``azure`` auth method, for Azure service principal, managed identity, or plain JWT access token (https://github.com/ansible-collections/community.hashi_vault/issues/293). +- community.hashi_vault retries - `HTTP status code 412 `__ has been added to the default list of codes to be retried, for the new `Server Side Consistent Token feature `__ in Vault Enterprise (https://github.com/ansible-collections/community.hashi_vault/issues/290). + +Bugfixes +-------- + +- community.hashi_vault plugins - tokens will be cast to a string type before being sent to ``hvac`` to prevent errors in ``requests`` when values are ``AnsibleUnsafe`` (https://github.com/ansible-collections/community.hashi_vault/issues/289). +- modules - fix a "variable used before assignment" that cannot be reached but causes sanity test failures (https://github.com/ansible-collections/community.hashi_vault/issues/296). + v3.1.0 ====== diff --git a/README.md b/README.md index cf9e8adff..f4f76a23e 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ If you use the Ansible package and don't update collections independently, use * * 2.11 * 2.12 * 2.13 +* 2.14 * devel (latest development commit) See [the CI configuration](https://github.com/ansible-collections/community.hashi_vault/blob/main/.github/workflows/ansible-test.yml) for the most accurate testing information. @@ -74,7 +75,7 @@ Once the new collection is published and the Zuul job is finished, add a release ## Release notes -See the [changelog](https://github.com/ansible-collections/community.hashi_vault/tree/main/CHANGELOG.rst). +See the [rendered changelog](https://ansible-collections.github.io/community.hashi_vault/branch/main/collections/community/hashi_vault/docsite/CHANGELOG.html) or the [raw generated changelog](https://github.com/ansible-collections/community.hashi_vault/tree/main/CHANGELOG.rst). ## FAQ diff --git a/changelogs/changelog.yaml b/changelogs/changelog.yaml index 9b841cc3a..2e44bb77c 100644 --- a/changelogs/changelog.yaml +++ b/changelogs/changelog.yaml @@ -501,3 +501,121 @@ releases: - 3.1.0.yml - licensing.yml release_date: '2022-07-17' + 3.2.0: + changes: + bugfixes: + - community.hashi_vault plugins - tokens will be cast to a string type before + being sent to ``hvac`` to prevent errors in ``requests`` when values are ``AnsibleUnsafe`` + (https://github.com/ansible-collections/community.hashi_vault/issues/289). + - modules - fix a "variable used before assignment" that cannot be reached but + causes sanity test failures (https://github.com/ansible-collections/community.hashi_vault/issues/296). + minor_changes: + - community.hashi_vault collection - add support for ``azure`` auth method, + for Azure service principal, managed identity, or plain JWT access token (https://github.com/ansible-collections/community.hashi_vault/issues/293). + - community.hashi_vault retries - `HTTP status code 412 `__ + has been added to the default list of codes to be retried, for the new `Server + Side Consistent Token feature `__ + in Vault Enterprise (https://github.com/ansible-collections/community.hashi_vault/issues/290). + release_summary: This release brings support for the ``azure`` auth method, + adds ``412`` to the default list of HTTP status codes to be retried, and fixes + a bug that causes failures in token auth with ``requests>=2.28.0``. + fragments: + - 289-handle-unsafe-strings.yml + - 290-retry-http-412.yml + - 293-support-azure-auth-method.yml + - 296-use-before-assignment.yml + - 3.2.0.yml + release_date: '2022-08-21' + 3.3.0: + changes: + minor_changes: + - vault_token_create - creation or orphan tokens uses ``hvac``'s new v1 method + for creating orphans, or falls back to the v0 method if needed (https://github.com/ansible-collections/community.hashi_vault/issues/301). + release_summary: 'With the release of ``hvac`` version ``1.0.0``, we needed + to update ``vault_token_create``''s support for orphan tokens. + + The collection''s changelog is now viewable in the Ansible documentation site.' + fragments: + - 3.3.0.yml + - 301-orphan-token-handling.yml + release_date: '2022-09-19' + 3.3.1: + changes: + release_summary: No functional changes in this release, this provides updated + filter documentation for the public docsite. + fragments: + - 3.3.1.yml + release_date: '2022-09-25' + 3.4.0: + changes: + bugfixes: + - connection options - the ``namespace`` connection option will be forced into + a string to ensure cmpatibility with recent ``requests`` versions (https://github.com/ansible-collections/community.hashi_vault/issues/309). + minor_changes: + - vault_pki_generate_certificate - the documentation has been updated to match + the argspec for the default values of options ``alt_names``, ``ip_sans``, + ``other_sans``, and ``uri_sans`` (https://github.com/ansible-collections/community.hashi_vault/pull/318). + release_summary: 'This release includes a new module, fixes (another) ``requests`` + header issue, and updates some inaccurate documentation. + + This is the last planned release before v4.0.0.' + fragments: + - 3.4.0.yml + - 309-stringify-namespace.yml + - 318-pki-argspec-doc-mismatch.yml + modules: + - description: Delete one or more versions of a secret from HashiCorp Vault's + KV version 2 secret store + name: vault_kv2_delete + namespace: '' + release_date: '2022-11-03' + 4.0.0: + changes: + breaking_changes: + - auth - the default value for ``token_validate`` has changed from ``true`` + to ``false``, as previously announced (https://github.com/ansible-collections/community.hashi_vault/issues/248). + - vault_kv2_get lookup - as previously announced, the default value for ``engine_mount_point`` + in the ``vault_kv2_get`` lookup has changed from ``kv`` to ``secret`` (https://github.com/ansible-collections/community.hashi_vault/issues/279). + minor_changes: + - modules - all modules now document their action group and support for check + mode in their attributes documentation (https://github.com/ansible-collections/community.hashi_vault/issues/197). + release_summary: The next major version of the collection includes previously + announced breaking changes to some default values, and improvements to module + documentation with attributes that describe the use of action groups and check + mode support. + fragments: + - 197-module-attributes.yml + - 248-token_validate-default.yml + - 279-vault_kv2_get-lookup-mount-default.yml + - 4.0.0.yml + release_date: '2022-11-05' + 4.1.0: + changes: + deprecated_features: + - ansible-core - support for ``ansible-core`` versions ``2.11`` and ``2.12`` + will be dropped in collection version ``5.0.0``, making ``2.13`` the minimum + supported version of ``ansible-core`` (https://github.com/ansible-collections/community.hashi_vault/issues/340). + - hvac - the minimum version of ``hvac`` to be supported in collection version + ``5.0.0`` will be at least ``1.0.2``; this minimum may be raised before ``5.0.0`` + is released, so please subscribe to the linked issue and look out for new + notices in the changelog (https://github.com/ansible-collections/community.hashi_vault/issues/324). + release_summary: 'This release brings new generic ``vault_list`` plugins from + a new contributor! + + There are also some deprecation notices for the next major version, and some + updates to documentation attributes.' + fragments: + - 324-deprecate-hvac.yml + - 325-fix attributes.yml + - 340-deprecate-core-211-212.yml + - 4.1.0.yml + modules: + - description: Perform a list operation against HashiCorp Vault + name: vault_list + namespace: '' + plugins: + lookup: + - description: Perform a list operation against HashiCorp Vault + name: vault_list + namespace: null + release_date: '2023-01-18' diff --git a/changelogs/fragments/289-handle-unsafe-strings.yml b/changelogs/fragments/289-handle-unsafe-strings.yml deleted file mode 100644 index d067fc256..000000000 --- a/changelogs/fragments/289-handle-unsafe-strings.yml +++ /dev/null @@ -1,3 +0,0 @@ ---- -bugfixes: - - community.hashi_vault plugins - tokens will be cast to a string type before being sent to ``hvac`` to prevent errors in ``requests`` when values are ``AnsibleUnsafe`` (https://github.com/ansible-collections/community.hashi_vault/issues/289). diff --git a/changelogs/fragments/290-retry-http-412.yml b/changelogs/fragments/290-retry-http-412.yml deleted file mode 100644 index 9a1629426..000000000 --- a/changelogs/fragments/290-retry-http-412.yml +++ /dev/null @@ -1,3 +0,0 @@ ---- -minor_changes: - - community.hashi_vault retries - `HTTP status code 412 `__ has been added to the default list of codes to be retried, for the new `Server Side Consistent Token feature `__ in Vault Enterprise (https://github.com/ansible-collections/community.hashi_vault/issues/290). diff --git a/codecov.yml b/codecov.yml index bb14293b7..1f0977656 100644 --- a/codecov.yml +++ b/codecov.yml @@ -7,6 +7,12 @@ ignore: fixes: - "ansible_collections/community/hashi_vault/::" +coverage: + status: + patch: + default: + target: 100% + flags: target_filter_vault_login_token: paths: @@ -20,6 +26,10 @@ flags: paths: - plugins/modules/vault_kv2_get.py + target_module_vault_list: + paths: + - plugins/modules/vault_list.py + target_module_vault_login: paths: - plugins/modules/vault_login.py @@ -44,6 +54,10 @@ flags: paths: - plugins/lookup/vault_kv2_get.py + target_lookup_vault_list: + paths: + - plugins/lookup/vault_list.py + target_lookup_vault_login: paths: - plugins/lookup/vault_login.py @@ -68,6 +82,10 @@ flags: paths: - plugins/module_utils/_auth_method_aws_iam.py + target_auth_azure: + paths: + - plugins/module_utils/_auth_method_azure.py + target_auth_cert: paths: - plugins/module_utils/_auth_method_cert.py diff --git a/docs/docsite/extra-docs.yml b/docs/docsite/extra-docs.yml index 916fb2dce..82e329fcf 100644 --- a/docs/docsite/extra-docs.yml +++ b/docs/docsite/extra-docs.yml @@ -1,5 +1,8 @@ --- sections: + - title: Changelog + toctree: + - CHANGELOG - title: Guides toctree: - filter_guide diff --git a/docs/docsite/rst/CHANGELOG.rst b/docs/docsite/rst/CHANGELOG.rst new file mode 120000 index 000000000..a3432b01d --- /dev/null +++ b/docs/docsite/rst/CHANGELOG.rst @@ -0,0 +1 @@ +../../../CHANGELOG.rst \ No newline at end of file diff --git a/docs/docsite/rst/filter_guide.rst b/docs/docsite/rst/filter_guide.rst index 29ddbab78..8cee2b987 100644 --- a/docs/docsite/rst/filter_guide.rst +++ b/docs/docsite/rst/filter_guide.rst @@ -3,6 +3,11 @@ Filter guide ============ +.. note:: + + Filter Plugins are now included with other :ref:`plugin documentation `. + + .. contents:: Filters .. _ansible_collections.community.hashi_vault.docsite.filter_guide.vault_login_token: @@ -111,7 +116,7 @@ Which produces: "msg": "s.drgLxu6ZtttSVn5Zkoy0huMR" } -This filter is the equivalent of reading into the dictionary directly, but it has the advantage of providing semantic meaning and automatically working against the differing output of both the module and the lookup. +This filter is the equivalent of reading into the dictionary directly, but it has the advantages of providing semantic meaning and automatically working against the differing output of modules and lookups. .. code-block:: yaml+jinja diff --git a/docs/docsite/rst/user_guide.rst b/docs/docsite/rst/user_guide.rst index fe997e579..a3f417800 100644 --- a/docs/docsite/rst/user_guide.rst +++ b/docs/docsite/rst/user_guide.rst @@ -31,7 +31,7 @@ The content in ``community.hashi_vault`` requires the `hvac .) to an URL here. +# You can replace either of or by "*" to match all values in that place, +# or use "*" for the collection name to match all collections. In the URL, you can use +# {namespace} and {name} for the two components of the collection name. If you want to use +# "{" or "}" in the URL, write "{{" or "}}" instead. Basically these are Python format +# strings (https://docs.python.org/3.8/library/string.html#formatstrings). +collection_url = { + * = "https://galaxy.ansible.com/{namespace}/{name}" +} + +# The same wildcard rules and formatting rules as for collection_url apply. +collection_install = { + * = "ansible-galaxy collection install {namespace}.{name}" +} diff --git a/docs/preview/build.sh b/docs/preview/build.sh index a19c8c81f..eeb0c4f8e 100755 --- a/docs/preview/build.sh +++ b/docs/preview/build.sh @@ -1,19 +1,25 @@ #!/usr/bin/env bash +# 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 + set -e pushd "${BASH_SOURCE%/*}" # Create collection documentation into temporary directory rm -rf temp-rst mkdir -p temp-rst -antsibull-docs collection \ +antsibull-docs \ + --config-file antsibull-docs.cfg \ + collection \ --use-current \ --dest-dir temp-rst \ community.hashi_vault # Copy collection documentation into source directory -rsync -avc --delete-after temp-rst/collections/ rst/collections/ +rsync -cprv --delete-after temp-rst/collections/ rst/collections/ # Build Sphinx site -sphinx-build -M html rst build -c . +sphinx-build -M html rst build -c . -W --keep-going popd diff --git a/docs/preview/conf.py b/docs/preview/conf.py index 6862c5968..02685b344 100644 --- a/docs/preview/conf.py +++ b/docs/preview/conf.py @@ -1,3 +1,7 @@ +# 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 + # This file only contains a selection of the most common options. For a full list see the # documentation: # http://www.sphinx-doc.org/en/master/config diff --git a/docs/preview/requirements.txt b/docs/preview/requirements.txt index 633de27cf..afc7e88e8 100644 --- a/docs/preview/requirements.txt +++ b/docs/preview/requirements.txt @@ -1,4 +1,8 @@ -antsibull-docs +# 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 + +antsibull-docs >= 1.0.0, < 2.0.0 ansible-pygments -sphinx -sphinx-ansible-theme +sphinx != 5.2.0.post0 # temporary, see https://github.com/ansible-community/antsibull-docs/issues/39, https://github.com/ansible-community/antsibull-docs/issues/40 +sphinx-ansible-theme >= 0.9.0 diff --git a/docs/preview/rst/index.rst b/docs/preview/rst/index.rst index dc3d17ed8..19db644bc 100644 --- a/docs/preview/rst/index.rst +++ b/docs/preview/rst/index.rst @@ -1,9 +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 + .. _docsite_root_index: Ansible collection documentation preview ======================================== -This docsite contains documentation from ``community.hashi_vault``. +This docsite contains documentation for ``community.hashi_vault``. .. toctree:: diff --git a/galaxy.yml b/galaxy.yml index d59d96f55..e96d480ef 100644 --- a/galaxy.yml +++ b/galaxy.yml @@ -2,7 +2,7 @@ namespace: community name: hashi_vault -version: 3.1.0 +version: 4.2.0-devel readme: README.md authors: - Julie Davila (@juliedavila) diff --git a/meta/ee-requirements.txt b/meta/ee-requirements.txt index a85197761..53393fd3e 100644 --- a/meta/ee-requirements.txt +++ b/meta/ee-requirements.txt @@ -5,3 +5,5 @@ urllib3 >= 1.15 boto3 # these are only needed if inferring AWS credentials or botocore # using a boto profile; including for completeness + +azure-identity # only needed when using a servide principal or managed identity diff --git a/meta/runtime.yml b/meta/runtime.yml index 94440a929..4a4663dda 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -4,7 +4,9 @@ action_groups: # let's keep this in alphabetical order vault: - vault_kv1_get + - vault_kv2_delete - vault_kv2_get + - vault_list - vault_login - vault_pki_generate_certificate - vault_read diff --git a/plugins/doc_fragments/attributes.py b/plugins/doc_fragments/attributes.py new file mode 100644 index 000000000..7536fa794 --- /dev/null +++ b/plugins/doc_fragments/attributes.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2022, Brian Scholer (@briantist) +# 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 + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +class ModuleDocFragment(object): + + DOCUMENTATION = r''' +options: {} +attributes: + check_mode: + description: Can run in C(check_mode) and return changed status prediction without modifying target. +''' + + ACTION_GROUP = r''' +options: {} +attributes: + action_group: + description: Use C(group/community.hashi_vault.vault) in C(module_defaults) to set defaults for this module. + support: full + membership: + - community.hashi_vault.vault +''' + + # Should be used together with the standard fragment + CHECK_MODE_READ_ONLY = r''' +options: {} +attributes: + check_mode: + support: full + details: + - This module is "read only" and operates the same regardless of check mode. +''' diff --git a/plugins/doc_fragments/auth.py b/plugins/doc_fragments/auth.py index b02436d35..ae07545d4 100644 --- a/plugins/doc_fragments/auth.py +++ b/plugins/doc_fragments/auth.py @@ -18,14 +18,17 @@ class ModuleDocFragment(object): - C(none) auth method was added in collection version C(1.2.0). - C(cert) auth method was added in collection version C(1.4.0). - C(aws_iam_login) was renamed C(aws_iam) in collection version C(2.1.0) and was removed in C(3.0.0). + - C(azure) auth method was added in collection version C(3.2.0). choices: - token - userpass - ldap - approle - aws_iam + - azure - jwt - cert + - kubernetes - none default: token type: str @@ -52,9 +55,8 @@ class ModuleDocFragment(object): description: - For token auth, will perform a C(lookup-self) operation to determine the token's validity before using it. - Disable if your token does not have the C(lookup-self) capability. - - The default value is C(true). - - The default value will change to C(false) in version 4.0.0. type: bool + default: false version_added: 0.2.0 username: description: Authentication user name. @@ -64,8 +66,9 @@ class ModuleDocFragment(object): type: str role_id: description: - - Vault Role ID or name. Used in C(approle), C(aws_iam), and C(cert) auth methods. + - Vault Role ID or name. Used in C(approle), C(aws_iam), C(azure) and C(cert) auth methods. - For C(cert) auth, if no I(role_id) is supplied, the default behavior is to try all certificate roles and return any one that matches. + - For C(azure) auth, I(role_id) is required. type: str secret_id: description: Secret ID to be used for Vault AppRole authentication. @@ -73,6 +76,15 @@ class ModuleDocFragment(object): jwt: description: The JSON Web Token (JWT) to use for JWT authentication to Vault. type: str + kubernetes_token: + description: The Kubernetes Token (JWT) to use for Kubernetes authentication to Vault. + type: str + version_added: 2.5.0 + kubernetes_token_path: + description: If no kubernetes_token is specified, will try to read the token from this path. + default: '/var/run/secrets/kubernetes.io/serviceaccount/token' + type: str + version_added: 2.5.0 aws_profile: description: The AWS profile type: str @@ -96,6 +108,34 @@ class ModuleDocFragment(object): required: False type: str version_added: '0.2.0' + azure_tenant_id: + description: + - The Azure Active Directory Tenant ID (also known as the Directory ID) of the service principal. Should be a UUID. + - >- + Required when using a service principal to authenticate to Vault, + e.g. required when both I(azure_client_id) and I(azure_client_secret) are specified. + - Optional when using managed identity to authenticate to Vault. + required: False + type: str + version_added: '3.2.0' + azure_client_id: + description: + - The client ID (also known as application ID) of the Azure AD service principal or managed identity. Should be a UUID. + - If not specified, will use the system assigned managed identity. + required: False + type: str + version_added: '3.2.0' + azure_client_secret: + description: The client secret of the Azure AD service principal. + required: False + type: str + version_added: '3.2.0' + azure_resource: + description: The resource URL for the application registered in Azure Active Directory. Usually should not be changed from the default. + required: False + type: str + default: https://management.azure.com/ + version_added: '3.2.0' cert_auth_public_key: description: For C(cert) auth, path to the certificate file to authenticate with, in PEM format. type: path @@ -234,6 +274,35 @@ class ModuleDocFragment(object): - section: hashi_vault_collection key: aws_iam_server_id version_added: 1.4.0 + azure_tenant_id: + env: + - name: ANSIBLE_HASHI_VAULT_AZURE_TENANT_ID + ini: + - section: hashi_vault_collection + key: azure_tenant_id + vars: + - name: ansible_hashi_vault_azure_tenant_id + azure_client_id: + env: + - name: ANSIBLE_HASHI_VAULT_AZURE_CLIENT_ID + ini: + - section: hashi_vault_collection + key: azure_client_id + vars: + - name: ansible_hashi_vault_azure_client_id + azure_client_secret: + env: + - name: ANSIBLE_HASHI_VAULT_AZURE_CLIENT_SECRET + vars: + - name: ansible_hashi_vault_azure_client_secret + azure_resource: + env: + - name: ANSIBLE_HASHI_VAULT_AZURE_RESOURCE + ini: + - section: hashi_vault_collection + key: azure_resource + vars: + - name: ansible_hashi_vault_azure_resource cert_auth_public_key: env: - name: ANSIBLE_HASHI_VAULT_CERT_AUTH_PUBLIC_KEY @@ -246,4 +315,17 @@ class ModuleDocFragment(object): ini: - section: hashi_vault_collection key: cert_auth_private_key + kubernetes_token: + env: + - name: ANSIBLE_HASHI_VAULT_KUBERNETES_TOKEN + vars: + - name: ansible_hashi_vault_kubernetes_token + kubernetes_token_path: + env: + - name: ANSIBLE_HASHI_VAULT_KUBERNETES_TOKEN_PATH + ini: + - section: hashi_vault_collection + key: kubernetes_token_path + vars: + - name: ansible_hashi_vault_kubernetes_token_path ''' diff --git a/plugins/doc_fragments/token_create.py b/plugins/doc_fragments/token_create.py index bd1dfd519..30031f076 100644 --- a/plugins/doc_fragments/token_create.py +++ b/plugins/doc_fragments/token_create.py @@ -15,15 +15,12 @@ class ModuleDocFragment(object): orphan: description: - When C(true), uses the C(/create-orphan) API endpoint, which requires C(sudo) (but not C(root)) to create an orphan. - - Implies I(no_parent=true). - - B(NOTE:) as of this writing, the underlying endpoint in the C(hvac) library to support this is deprecated and scheduled for removal in C(v1.0.0). - - If I(orphan=true) and we cannot access the intended endpoint, the call will be attempted with the C(/create) endpoint, which requires root. - - If a replacement is provided in C(hvac), we will add support for it. + - With C(hvac>=1.0.0), requires collection version C(>=3.3.0). type: bool default: false no_parent: description: - - This option only has effect if used by a C(root) or C(sudo) caller, or in combination with I(orphan=true). + - This option only has effect if used by a C(root) or C(sudo) caller and only when I(orphan=false). - When C(true), the token created will not have a parent. type: bool no_default_policy: diff --git a/plugins/filter/vault_login_token.yml b/plugins/filter/vault_login_token.yml new file mode 100644 index 000000000..e2946bafe --- /dev/null +++ b/plugins/filter/vault_login_token.yml @@ -0,0 +1,98 @@ +# (c) 2022, Brian Scholer (@briantist) +# 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 +--- +DOCUMENTATION: + name: vault_login_token + short_description: Extracts the Vault token from a login or token creation + version_added: 2.2.0 + description: + - Extracts the token value from the structure returned by a Vault token creation operation. + seealso: + - module: community.hashi_vault.vault_login + - module: community.hashi_vault.vault_token_create + - plugin: community.hashi_vault.vault_login + plugin_type: lookup + - plugin: community.hashi_vault.vault_token_create + plugin_type: lookup + - ref: Filter Guide + description: The C(community.hashi_vault) Filter Guide + notes: + - >- + This filter is the same as reading into the I(_input) dictionary directly, + but it provides semantic meaning and automatically works with the differing output of the modules and lookups. + See the Filter guide for more information. + options: + _input: + description: + - A dictionary matching the structure returned by a login or token creation. + type: dict + required: true + optional_field: + description: + - >- + If this field exists in the input dictionary, then the value of that field is used as the I(_input) value. + - >- + The default value deals with the difference between the output of lookup plugins, + and does not need to be changed in most cases. + - See the examples or the Filter guide for more information. + type: string + default: login + author: + - Brian Scholer (@briantist) + +EXAMPLES: | + - name: Set defaults + vars: + ansible_hashi_vault_url: https://vault:9801/ + ansible_hashi_vault_auth_method: userpass + ansible_hashi_vault_username: user + ansible_hashi_vault_password: "{{ lookup('env', 'MY_SECRET_PASSWORD') }}" + module_defaults: + community.hashi_vault.vault_login: + url: '{{ ansible_hashi_vault_url }}' + auth_method: '{{ ansible_hashi_vault_auth_method }}' + username: '{{ ansible_hashi_vault_username }}' + password: '{{ ansible_hashi_vault_password }}' + block: + - name: Perform a login with a lookup and display the token + vars: + login_response: "{{ lookup('community.hashi_vault.vault_login') }}" + debug: + msg: "The token is {{ login_response | community.hashi_vault.vault_login_token }}" + + - name: Perform a login with a module + community.hashi_vault.vault_login: + register: login_response + + - name: Display the token + debug: + msg: "The token is {{ login_response | community.hashi_vault.vault_login_token }}" + + - name: Use of optional_field + vars: + lookup_login_response: "{{ lookup('community.hashi_vault.vault_login') }}" + my_data: + something: somedata + vault_login: "{{ lookup_login_response }}" + + token_from_param: "{{ my_data | community.hashi_vault.vault_login_token(optional_field='vault_login') }}" + token_from_deref: "{{ my_data['vault_login'] | community.hashi_vault.vault_login_token }}" + # if the optional field doesn't exist, the dictionary itself is still checked + unused_optional: "{{ my_data['vault_login'] | community.hashi_vault.vault_login_token(optional_field='missing') }}" + block: + - name: Display the variables + ansible.builtin.debug: + var: '{{ item }}' + loop: + - my_data + - token_from_param + - token_from_deref + - unused_optional + +RETURN: + _value: + description: The token value. + returned: always + sample: s.nnrpog4i5gjizr6b8g1inwj3 + type: string diff --git a/plugins/lookup/hashi_vault.py b/plugins/lookup/hashi_vault.py index 1ea9b2c90..19240906a 100644 --- a/plugins/lookup/hashi_vault.py +++ b/plugins/lookup/hashi_vault.py @@ -61,6 +61,164 @@ - raw default: dict aliases: [ as ] + url: + ini: + - section: lookup_hashi_vault + key: url + deprecated: + why: collection-wide config section + version: 3.0.0 + collection_name: community.hashi_vault + alternatives: use section [hashi_vault_collection] + - section: hashi_vault_collection + key: url + version_added: 1.4.0 + proxies: + ini: + - section: lookup_hashi_vault + key: proxies + deprecated: + why: collection-wide config section + version: 3.0.0 + collection_name: community.hashi_vault + alternatives: use section [hashi_vault_collection] + - section: hashi_vault_collection + key: proxies + version_added: 1.4.0 + ca_cert: + ini: + - section: lookup_hashi_vault + key: ca_cert + version_added: 1.2.0 + deprecated: + why: collection-wide config section + version: 3.0.0 + collection_name: community.hashi_vault + alternatives: use section [hashi_vault_collection] + - section: hashi_vault_collection + key: ca_cert + version_added: 1.4.0 + namespace: + ini: + - section: lookup_hashi_vault + key: namespace + version_added: 0.2.0 + deprecated: + why: collection-wide config section + version: 3.0.0 + collection_name: community.hashi_vault + alternatives: use section [hashi_vault_collection] + - section: hashi_vault_collection + key: namespace + version_added: 1.4.0 + timeout: + ini: + - section: lookup_hashi_vault + key: timeout + deprecated: + why: collection-wide config section + version: 3.0.0 + collection_name: community.hashi_vault + alternatives: use section [hashi_vault_collection] + - section: hashi_vault_collection + key: timeout + version_added: 1.4.0 + retries: + ini: + - section: lookup_hashi_vault + key: retries + deprecated: + why: collection-wide config section + version: 3.0.0 + collection_name: community.hashi_vault + alternatives: use section [hashi_vault_collection] + - section: hashi_vault_collection + key: retries + version_added: 1.4.0 + retry_action: + ini: + - section: lookup_hashi_vault + key: retry_action + deprecated: + why: collection-wide config section + version: 3.0.0 + collection_name: community.hashi_vault + alternatives: use section [hashi_vault_collection] + - section: hashi_vault_collection + key: retry_action + version_added: 1.4.0 + auth_method: + ini: + - section: lookup_hashi_vault + key: auth_method + deprecated: + why: collection-wide config section + version: 3.0.0 + collection_name: community.hashi_vault + alternatives: use section [hashi_vault_collection] + - section: hashi_vault_collection + key: auth_method + version_added: 1.4.0 + token_path: + ini: + - section: lookup_hashi_vault + key: token_path + deprecated: + why: collection-wide config section + version: 3.0.0 + collection_name: community.hashi_vault + alternatives: use section [hashi_vault_collection] + - section: hashi_vault_collection + key: token_path + version_added: 1.4.0 + token_file: + ini: + - section: lookup_hashi_vault + key: token_file + deprecated: + why: collection-wide config section + version: 3.0.0 + collection_name: community.hashi_vault + alternatives: use section [hashi_vault_collection] + - section: hashi_vault_collection + key: token_file + version_added: 1.4.0 + token_validate: + ini: + - section: lookup_hashi_vault + key: token_validate + deprecated: + why: collection-wide config section + version: 3.0.0 + collection_name: community.hashi_vault + alternatives: use section [hashi_vault_collection] + - section: hashi_vault_collection + key: token_validate + version_added: 1.4.0 + role_id: + ini: + - section: lookup_hashi_vault + key: role_id + deprecated: + why: collection-wide config section + version: 3.0.0 + collection_name: community.hashi_vault + alternatives: use section [hashi_vault_collection] + - section: hashi_vault_collection + key: role_id + version_added: 1.4.0 + aws_iam_server_id: + ini: + - section: lookup_hashi_vault + key: aws_iam_server_id + deprecated: + why: collection-wide config section + version: 3.0.0 + collection_name: community.hashi_vault + alternatives: use section [hashi_vault_collection] + - section: hashi_vault_collection + key: aws_iam_server_id + version_added: 1.4.0 """ EXAMPLES = """ diff --git a/plugins/lookup/vault_kv2_get.py b/plugins/lookup/vault_kv2_get.py index b159eb1ca..bbd54ef8d 100644 --- a/plugins/lookup/vault_kv2_get.py +++ b/plugins/lookup/vault_kv2_get.py @@ -41,10 +41,7 @@ type: str required: True engine_mount_point: - description: - - The path where the secret backend is mounted. - - The default value is C(kv). - - The default value will change to C(secret) in version 4.0.0 to match the module's default. + default: secret version: description: Specifies the version to return. If not set the latest version is returned. type: int @@ -55,7 +52,7 @@ ansible.builtin.set_fact: response: "{{ lookup('community.hashi_vault.vault_kv2_get', 'hello', url='https://vault:8201') }}" # equivalent API path in 3.x.x is kv/data/hello - # equivalent API path in 4.0.0+ will be secret/data/hello + # equivalent API path in 4.0.0+ is secret/data/hello - name: Display the results ansible.builtin.debug: @@ -208,14 +205,7 @@ def run(self, terms, variables=None, **kwargs): client = self.helper.get_vault_client(**client_args) version = self._options_adapter.get_option_default('version') - engine_mount_point = self._options_adapter.get_option_default('engine_mount_point') - if engine_mount_point is None: - engine_mount_point = 'kv' - display.deprecated( - "The default value for 'engine_mount_point' will change from 'kv' to 'secret'.", - version='4.0.0', - collection_name='community.hashi_vault' - ) + engine_mount_point = self._options_adapter.get_option('engine_mount_point') try: self.authenticator.validate() diff --git a/plugins/lookup/vault_list.py b/plugins/lookup/vault_list.py new file mode 100644 index 000000000..56521c792 --- /dev/null +++ b/plugins/lookup/vault_list.py @@ -0,0 +1,183 @@ +# (c) 2023, Tom Kivlin (@tomkivlin) +# 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 + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +DOCUMENTATION = """ + name: vault_list + version_added: 4.1.0 + author: + - Tom Kivlin (@tomkivlin) + short_description: Perform a list operation against HashiCorp Vault + requirements: + - C(hvac) (L(Python library,https://hvac.readthedocs.io/en/stable/overview.html)) + - For detailed requirements, see R(the collection requirements page,ansible_collections.community.hashi_vault.docsite.user_guide.requirements). + description: + - Performs a generic list operation against a given path in HashiCorp Vault. + seealso: + - module: community.hashi_vault.vault_list + extends_documentation_fragment: + - community.hashi_vault.connection + - community.hashi_vault.connection.plugins + - community.hashi_vault.auth + - community.hashi_vault.auth.plugins + options: + _terms: + description: Vault path(s) to be listed. + type: str + required: true +""" + +EXAMPLES = """ +- name: List all secrets at a path + ansible.builtin.debug: + msg: "{{ lookup('community.hashi_vault.vault_list', 'secret/metadata', url='https://vault:8201') }}" + # For kv2, the path needs to follow the pattern 'mount_point/metadata' or 'mount_point/metadata/path' to list all secrets in that path + +- name: List access policies + ansible.builtin.debug: + msg: "{{ lookup('community.hashi_vault.vault_list', 'sys/policies/acl', url='https://vault:8201') }}" + +- name: Perform multiple list operations with a single Vault login + vars: + paths: + - secret/metadata + - sys/policies/acl + ansible.builtin.debug: + msg: "{{ lookup('community.hashi_vault.vault_list', *paths, auth_method='userpass', username=user, password=pwd) }}" + +- name: Perform multiple list operations with a single Vault login in a loop + vars: + paths: + - secret/metadata + - sys/policies/acl + ansible.builtin.debug: + msg: '{{ item }}' + loop: "{{ query('community.hashi_vault.vault_list', *paths, auth_method='userpass', username=user, password=pwd) }}" + +- name: Perform list operations with a single Vault login in a loop (via with_) + vars: + ansible_hashi_vault_auth_method: userpass + ansible_hashi_vault_username: '{{ user }}' + ansible_hashi_vault_password: '{{ pwd }}' + ansible.builtin.debug: + msg: '{{ item }}' + with_community.hashi_vault.vault_list: + - secret/metadata + - sys/policies/acl + +- name: Create fact consisting of list of dictionaries each with secret name (e.g. username) and value of a key (e.g. 'password') within that secret + ansible.builtin.set_fact: + credentials: >- + {{ + credentials + | default([]) + [ + { + 'username': item, + 'password': lookup('community.hashi_vault.vault_kv2_get', item, engine_mount_point='vpn-users').secret.password + } + ] + }} + loop: "{{ query('community.hashi_vault.vault_list', 'vpn-users/metadata')[0].data['keys'] }}" + no_log: true + +- ansible.builtin.debug: + msg: "{{ credentials }}" + +- name: Create the same as above without looping, and only 2 logins + vars: + secret_names: >- + {{ + query('community.hashi_vault.vault_list', 'vpn-users/metadata') + | map(attribute='data') + | map(attribute='keys') + | flatten + }} + secret_values: >- + {{ + lookup('community.hashi_vault.vault_kv2_get', *secret_names, engine_mount_point='vpn-users') + | map(attribute='secret') + | map(attribute='password') + | flatten + }} + credentials_dict: "{{ dict(secret_names | zip(secret_values)) }}" + ansible.builtin.set_fact: + credentials_dict: "{{ credentials_dict }}" + credentials_list: "{{ credentials_dict | dict2items(key_name='username', value_name='password') }}" + no_log: true + +- ansible.builtin.debug: + msg: + - "Dictionary: {{ credentials_dict }}" + - "List: {{ credentials_list }}" + +- name: List all userpass users and output the token policies for each user + ansible.builtin.debug: + msg: "{{ lookup('community.hashi_vault.vault_read', 'auth/userpass/users/' + item).data.token_policies }}" + loop: "{{ query('community.hashi_vault.vault_list', 'auth/userpass/users')[0].data['keys'] }}" +""" + +RETURN = """ +_raw: + description: + - The raw result of the read against the given path. + type: list + elements: dict +""" + +from ansible.errors import AnsibleError +from ansible.utils.display import Display + +from ansible.module_utils.six import raise_from + +from ansible_collections.community.hashi_vault.plugins.plugin_utils._hashi_vault_lookup_base import HashiVaultLookupBase +from ansible_collections.community.hashi_vault.plugins.module_utils._hashi_vault_common import HashiVaultValueError + +display = Display() + +try: + import hvac +except ImportError as imp_exc: + HVAC_IMPORT_ERROR = imp_exc +else: + HVAC_IMPORT_ERROR = None + + +class LookupModule(HashiVaultLookupBase): + def run(self, terms, variables=None, **kwargs): + if HVAC_IMPORT_ERROR: + raise_from( + AnsibleError("This plugin requires the 'hvac' Python library"), + HVAC_IMPORT_ERROR + ) + + ret = [] + + self.set_options(direct=kwargs, var_options=variables) + # TODO: remove process_deprecations() if backported fix is available (see method definition) + self.process_deprecations() + + self.connection_options.process_connection_options() + client_args = self.connection_options.get_hvac_connection_options() + client = self.helper.get_vault_client(**client_args) + + try: + self.authenticator.validate() + self.authenticator.authenticate(client) + except (NotImplementedError, HashiVaultValueError) as e: + raise AnsibleError(e) + + for term in terms: + try: + data = client.list(term) + except hvac.exceptions.Forbidden: + raise AnsibleError("Forbidden: Permission Denied to path '%s'." % term) + + if data is None: + raise AnsibleError("The path '%s' doesn't seem to exist." % term) + + ret.append(data) + + return ret diff --git a/plugins/lookup/vault_login.py b/plugins/lookup/vault_login.py index 5248fbd01..27d497965 100644 --- a/plugins/lookup/vault_login.py +++ b/plugins/lookup/vault_login.py @@ -18,7 +18,7 @@ - Performs a login operation against a given path in HashiCorp Vault, returning the login response, including the token. seealso: - module: community.hashi_vault.vault_login - - ref: community.hashi_vault.vault_login_token filter + - ref: community.hashi_vault.vault_login_token filter description: The official documentation for the C(community.hashi_vault.vault_login_token) filter plugin. notes: - This lookup does not use the term string and will not work correctly in loops. Only a single response will be returned. @@ -42,12 +42,8 @@ type: str required: false token_validate: - description: - - For token auth, will perform a C(lookup-self) operation to determine the token's validity before using it. - - Disable if your token does not have the C(lookup-self) capability. default: true """ -# TODO: remove token_validate description in 4.0.0 when it will match the doc frag description. EXAMPLES = """ - name: Set a fact with a lookup result @@ -98,13 +94,13 @@ from ansible.module_utils.six import raise_from -from ansible_collections.community.hashi_vault.plugins.plugin_utils._hashi_vault_lookup_base import HashiVaultLookupBase -from ansible_collections.community.hashi_vault.plugins.module_utils._hashi_vault_common import HashiVaultValueError +from ...plugins.plugin_utils._hashi_vault_lookup_base import HashiVaultLookupBase +from ...plugins.module_utils._hashi_vault_common import HashiVaultValueError display = Display() try: - import hvac + import hvac # pylint: disable=unused-import except ImportError as imp_exc: HVAC_IMPORT_ERROR = imp_exc else: diff --git a/plugins/lookup/vault_token_create.py b/plugins/lookup/vault_token_create.py index 06757f657..520288897 100644 --- a/plugins/lookup/vault_token_create.py +++ b/plugins/lookup/vault_token_create.py @@ -21,7 +21,7 @@ - ref: community.hashi_vault.vault_login lookup description: The official documentation for the C(community.hashi_vault.vault_login) lookup plugin. - module: community.hashi_vault.vault_login - - ref: community.hashi_vault.vault_login_token filter + - ref: community.hashi_vault.vault_login_token filter description: The official documentation for the C(community.hashi_vault.vault_login_token) filter plugin. notes: - Token creation is a write operation (creating a token persisted to storage), so this module always reports C(changed=True). @@ -102,13 +102,13 @@ from ansible.module_utils.six import raise_from -from ansible_collections.community.hashi_vault.plugins.plugin_utils._hashi_vault_lookup_base import HashiVaultLookupBase -from ansible_collections.community.hashi_vault.plugins.module_utils._hashi_vault_common import HashiVaultValueError +from ...plugins.plugin_utils._hashi_vault_lookup_base import HashiVaultLookupBase +from ...plugins.module_utils._hashi_vault_common import HashiVaultValueError display = Display() try: - import hvac + import hvac # pylint: disable=unused-import except ImportError as imp_exc: HVAC_IMPORT_ERROR = imp_exc else: @@ -134,7 +134,7 @@ class LookupModule(HashiVaultLookupBase): 'wrap_ttl', ] - LEGACY_OPTION_TRANSLATION = { + ORPHAN_OPTION_TRANSLATION = { 'id': 'token_id', 'role_name': 'role', 'type': 'token_type', @@ -166,29 +166,27 @@ def run(self, terms, variables=None, **kwargs): pass_thru_options = self._options_adapter.get_filled_options(*self.PASS_THRU_OPTION_NAMES) - if self.get_option('orphan'): - pass_thru_options['no_parent'] = True - - legacy_options = pass_thru_options.copy() + orphan_options = pass_thru_options.copy() for key in pass_thru_options.keys(): - if key in self.LEGACY_OPTION_TRANSLATION: - legacy_options[self.LEGACY_OPTION_TRANSLATION[key]] = legacy_options.pop(key) + if key in self.ORPHAN_OPTION_TRANSLATION: + orphan_options[self.ORPHAN_OPTION_TRANSLATION[key]] = orphan_options.pop(key) response = None if self.get_option('orphan'): - # this method is deprecated, but it's the only way through hvac to get - # at the /create-orphan endpoint at this time. - # See: https://github.com/hvac/hvac/issues/758 try: - response = client.create_token(orphan=True, **legacy_options) - except AttributeError: - display.warning("'create_token' method was not found. Attempting method that requires root privileges.") + try: + # this method was added in hvac 1.0.0 + # See: https://github.com/hvac/hvac/pull/869 + response = client.auth.token.create_orphan(**orphan_options) + except AttributeError: + # this method was removed in hvac 1.0.0 + # See: https://github.com/hvac/hvac/issues/758 + response = client.create_token(orphan=True, **orphan_options) except Exception as e: raise AnsibleError(e) - - if response is None: + else: try: response = client.auth.token.create(**pass_thru_options) except Exception as e: diff --git a/plugins/module_utils/_auth_method_azure.py b/plugins/module_utils/_auth_method_azure.py new file mode 100644 index 000000000..36f44e07c --- /dev/null +++ b/plugins/module_utils/_auth_method_azure.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2022 Junrui Chen (@jchenship) +# Simplified BSD License (see LICENSES/BSD-2-Clause.txt or https://opensource.org/licenses/BSD-2-Clause) +# SPDX-License-Identifier: BSD-2-Clause + +'''Python versions supported: >=3.6''' + +# FOR INTERNAL COLLECTION USE ONLY +# The interfaces in this file are meant for use within the community.hashi_vault 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. + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +from ansible_collections.community.hashi_vault.plugins.module_utils._hashi_vault_common import ( + HashiVaultAuthMethodBase, + HashiVaultValueError, +) + + +class HashiVaultAuthMethodAzure(HashiVaultAuthMethodBase): + '''HashiVault auth method for Azure''' + + NAME = 'azure' + OPTIONS = [ + 'role_id', + 'jwt', + 'mount_point', + 'azure_tenant_id', + 'azure_client_id', + 'azure_client_secret', + 'azure_resource', + ] + + def __init__(self, option_adapter, warning_callback, deprecate_callback): + super(HashiVaultAuthMethodAzure, self).__init__( + option_adapter, warning_callback, deprecate_callback + ) + + def validate(self): + params = { + 'role': self._options.get_option_default('role_id'), + 'jwt': self._options.get_option_default('jwt'), + } + if not params['role']: + raise HashiVaultValueError( + 'role_id is required for azure authentication.' + ) + + # if mount_point is not provided, it will use the default value defined + # in hvac library (e.g. `azure`) + mount_point = self._options.get_option_default('mount_point') + if mount_point: + params['mount_point'] = mount_point + + # if jwt exists, use provided jwt directly, otherwise trying to get jwt + # from azure service principal or managed identity + if not params['jwt']: + azure_tenant_id = self._options.get_option_default('azure_tenant_id') + azure_client_id = self._options.get_option_default('azure_client_id') + azure_client_secret = self._options.get_option_default('azure_client_secret') + + # the logic of getting azure scope is from this function + # https://github.com/Azure/azure-cli/blob/azure-cli-2.39.0/src/azure-cli-core/azure/cli/core/auth/util.py#L72 + # the reason we expose resource instead of scope is resource is + # more aligned with the vault azure auth config here + # https://www.vaultproject.io/api-docs/auth/azure#resource + azure_resource = self._options.get_option('azure_resource') + azure_scope = azure_resource + "/.default" + + try: + import azure.identity + except ImportError: + raise HashiVaultValueError( + "azure-identity is required for getting access token from azure service principal or managed identity." + ) + + if azure_client_id and azure_client_secret: + # service principal + if not azure_tenant_id: + raise HashiVaultValueError( + 'azure_tenant_id is required when using azure service principal.' + ) + azure_credentials = azure.identity.ClientSecretCredential( + azure_tenant_id, azure_client_id, azure_client_secret + ) + elif azure_client_id: + # user assigned managed identity + azure_credentials = azure.identity.ManagedIdentityCredential( + client_id=azure_client_id + ) + else: + # system assigned managed identity + azure_credentials = azure.identity.ManagedIdentityCredential() + + params['jwt'] = azure_credentials.get_token(azure_scope).token + + self._auth_azure_login_params = params + + def authenticate(self, client, use_token=True): + params = self._auth_azure_login_params + response = client.auth.azure.login(use_token=use_token, **params) + return response diff --git a/plugins/module_utils/_auth_method_k8s.py b/plugins/module_utils/_auth_method_k8s.py new file mode 100644 index 000000000..faed8d078 --- /dev/null +++ b/plugins/module_utils/_auth_method_k8s.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021 FERREIRA Christophe (@chris93111) +# Simplified BSD License (see licenses/simplified_bsd.txt or https://opensource.org/licenses/BSD-2-Clause) + +'''Python versions supported: >=3.6''' + +# FOR INTERNAL COLLECTION USE ONLY +# The interfaces in this file are meant for use within the community.hashi_vault 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. + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +from ..module_utils._hashi_vault_common import HashiVaultAuthMethodBase, HashiVaultValueError +import os + + +class HashiVaultAuthMethodKubernetes(HashiVaultAuthMethodBase): + '''HashiVault option group class for auth: kubernetes''' + + NAME = 'kubernetes' + OPTIONS = ['kubernetes_token', 'kubernetes_token_path', 'role_id', 'mount_point'] + + def __init__(self, option_adapter, warning_callback, deprecate_callback): + super(HashiVaultAuthMethodKubernetes, self).__init__(option_adapter, warning_callback, deprecate_callback) + + def validate(self): + self.validate_by_required_fields('role_id') + + if self._options.get_option('kubernetes_token') is None and self._options.get_option('kubernetes_token_path') is not None: + token_filename = self._options.get_option('kubernetes_token_path') + if os.path.exists(token_filename): + if not os.path.isfile(token_filename): + raise HashiVaultValueError("The Kubernetes token file '%s' was found but is not a file." % token_filename) + with open(token_filename) as token_file: + self._options.set_option('kubernetes_token', token_file.read().strip()) + + if self._options.get_option('kubernetes_token') is None: + raise HashiVaultValueError( + self._options.get_option_default('kubernetes_token_path') + " No Kubernetes Token specified or discovered." + ) + + def authenticate(self, client, use_token=True): + origin_params = self._options.get_filled_options(*self.OPTIONS) + params = { + "role": origin_params.get('role_id'), + "jwt": origin_params.get('kubernetes_token'), + "mount_point": origin_params.get('mount_point'), + "use_token": use_token, + } + + try: + response = client.auth.kubernetes.login(**params) + except (NotImplementedError, AttributeError): + self.warn("Kubernetes authentication requires HVAC version 1.0.0 or higher. Deprecated method 'auth_kubernetes' will be used.") + response = client.auth_kubernetes(**params) + + return response diff --git a/plugins/module_utils/_auth_method_token.py b/plugins/module_utils/_auth_method_token.py index aa385be76..3b66b1937 100644 --- a/plugins/module_utils/_auth_method_token.py +++ b/plugins/module_utils/_auth_method_token.py @@ -77,14 +77,6 @@ def validate(self): with open(token_filename) as token_file: self._options.set_option('token', token_file.read().strip()) - if self._options.get_option_default('token_validate') is None: - self._options.set_option('token_validate', True) - self.deprecate( - "The default value for 'token_validate' will change from True to False.", - version='4.0.0', - collection_name='community.hashi_vault' - ) - if self._options.get_option_default('token') is None: raise HashiVaultValueError("No Vault Token specified or discovered.") diff --git a/plugins/module_utils/_authenticator.py b/plugins/module_utils/_authenticator.py index 85d03d12d..3816145f1 100644 --- a/plugins/module_utils/_authenticator.py +++ b/plugins/module_utils/_authenticator.py @@ -17,12 +17,15 @@ # please keep this list in alphabetical order of auth method name from ansible_collections.community.hashi_vault.plugins.module_utils._auth_method_approle import HashiVaultAuthMethodApprole from ansible_collections.community.hashi_vault.plugins.module_utils._auth_method_aws_iam import HashiVaultAuthMethodAwsIam +from ansible_collections.community.hashi_vault.plugins.module_utils._auth_method_azure import HashiVaultAuthMethodAzure from ansible_collections.community.hashi_vault.plugins.module_utils._auth_method_cert import HashiVaultAuthMethodCert from ansible_collections.community.hashi_vault.plugins.module_utils._auth_method_jwt import HashiVaultAuthMethodJwt +from ansible_collections.community.hashi_vault.plugins.module_utils._auth_method_k8s import HashiVaultAuthMethodKubernetes from ansible_collections.community.hashi_vault.plugins.module_utils._auth_method_ldap import HashiVaultAuthMethodLdap from ansible_collections.community.hashi_vault.plugins.module_utils._auth_method_none import HashiVaultAuthMethodNone from ansible_collections.community.hashi_vault.plugins.module_utils._auth_method_token import HashiVaultAuthMethodToken from ansible_collections.community.hashi_vault.plugins.module_utils._auth_method_userpass import HashiVaultAuthMethodUserpass +from ansible_collections.community.hashi_vault.plugins.module_utils._auth_method_k8s import HashiVaultAuthMethodKubernetes class HashiVaultAuthenticator(): @@ -33,27 +36,34 @@ class HashiVaultAuthenticator(): 'ldap', 'approle', 'aws_iam', + 'azure', 'jwt', 'cert', + 'kubernetes', 'none', ]), mount_point=dict(type='str'), token=dict(type='str', no_log=True, default=None), token_path=dict(type='str', default=None, no_log=False), token_file=dict(type='str', default='.vault-token'), - # TODO: token_validate default becomes False in 4.0.0 - token_validate=dict(type='bool'), + token_validate=dict(type='bool', default=False), username=dict(type='str'), password=dict(type='str', no_log=True), role_id=dict(type='str'), secret_id=dict(type='str', no_log=True), jwt=dict(type='str', no_log=True), + kubernetes_token=dict(type='str', no_log=True), + kubernetes_token_path=dict(type='str', default='/var/run/secrets/kubernetes.io/serviceaccount/token', no_log=False), aws_profile=dict(type='str', aliases=['boto_profile']), aws_access_key=dict(type='str', aliases=['aws_access_key_id'], no_log=False), aws_secret_key=dict(type='str', aliases=['aws_secret_access_key'], no_log=True), aws_security_token=dict(type='str', no_log=False), region=dict(type='str'), aws_iam_server_id=dict(type='str'), + azure_tenant_id=dict(type='str'), + azure_client_id=dict(type='str'), + azure_client_secret=dict(type='str', no_log=True), + azure_resource=dict(type='str', default='https://management.azure.com/'), cert_auth_private_key=dict(type='path', no_log=False), cert_auth_public_key=dict(type='path'), ) @@ -65,8 +75,10 @@ def __init__(self, option_adapter, warning_callback, deprecate_callback): # so that it's easier to scan and see at a glance that a given auth method is present or absent 'approle': HashiVaultAuthMethodApprole(option_adapter, warning_callback, deprecate_callback), 'aws_iam': HashiVaultAuthMethodAwsIam(option_adapter, warning_callback, deprecate_callback), + 'azure': HashiVaultAuthMethodAzure(option_adapter, warning_callback, deprecate_callback), 'cert': HashiVaultAuthMethodCert(option_adapter, warning_callback, deprecate_callback), 'jwt': HashiVaultAuthMethodJwt(option_adapter, warning_callback, deprecate_callback), + 'kubernetes': HashiVaultAuthMethodKubernetes(option_adapter, warning_callback, deprecate_callback), 'ldap': HashiVaultAuthMethodLdap(option_adapter, warning_callback, deprecate_callback), 'none': HashiVaultAuthMethodNone(option_adapter, warning_callback, deprecate_callback), 'token': HashiVaultAuthMethodToken(option_adapter, warning_callback, deprecate_callback), diff --git a/plugins/module_utils/_hashi_vault_common.py b/plugins/module_utils/_hashi_vault_common.py index 11aa9c46e..b39431c05 100644 --- a/plugins/module_utils/_hashi_vault_common.py +++ b/plugins/module_utils/_hashi_vault_common.py @@ -53,13 +53,14 @@ class HashiVaultValueError(ValueError): class HashiVaultHelper(): - STRINGIFY_CANDIDATES = set( + STRINGIFY_CANDIDATES = set([ 'token', # Token will end up in a header, requests requires headers to be str or bytes, # and newer versions of requests stopped converting automatically. Because our # token could have been passed in from a previous lookup call, it could be one # of the AnsibleUnsafe types instead, causing a failure. Tokens should always # be strings, so we will convert them. - ) + 'namespace', # namespace is also set in a header + ]) def __init__(self): # TODO move hvac checking here? diff --git a/plugins/modules/vault_kv1_get.py b/plugins/modules/vault_kv1_get.py index 348447411..e21f4a813 100644 --- a/plugins/modules/vault_kv1_get.py +++ b/plugins/modules/vault_kv1_get.py @@ -26,6 +26,9 @@ description: Documentation for the Vault KV secrets engine, version 1. link: https://www.vaultproject.io/docs/secrets/kv/kv-v1 extends_documentation_fragment: + - community.hashi_vault.attributes + - community.hashi_vault.attributes.action_group + - community.hashi_vault.attributes.check_mode_read_only - community.hashi_vault.connection - community.hashi_vault.auth - community.hashi_vault.engine_mount @@ -135,6 +138,7 @@ HAS_HVAC = False HVAC_IMPORT_ERROR = traceback.format_exc() else: + HVAC_IMPORT_ERROR = None HAS_HVAC = True diff --git a/plugins/modules/vault_kv2_delete.py b/plugins/modules/vault_kv2_delete.py new file mode 100644 index 000000000..ac4d59257 --- /dev/null +++ b/plugins/modules/vault_kv2_delete.py @@ -0,0 +1,179 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# (c) 2022, Isaac Wagner (@idwagner) +# 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 + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +DOCUMENTATION = ''' +module: vault_kv2_delete +version_added: 3.4.0 +author: + - Isaac Wagner (@idwagner) +short_description: Delete one or more versions of a secret from HashiCorp Vault's KV version 2 secret store +requirements: + - C(hvac) (L(Python library,https://hvac.readthedocs.io/en/stable/overview.html)) + - For detailed requirements, see R(the collection requirements page,ansible_collections.community.hashi_vault.docsite.user_guide.requirements). +description: + - Delete one or more versions of a secret from HashiCorp Vault's KV version 2 secret store. +notes: + - This module always reports C(changed) status because it cannot guarantee idempotence. + - Use C(changed_when) to control that in cases where the operation is known to not change state. +attributes: + check_mode: + support: partial + details: + - In check mode, the module returns C(changed) status without contacting Vault. + - Consider using M(community.hashi_vault.vault_kv2_get) to verify the existence of the secret first. +seealso: + - module: community.hashi_vault.vault_kv2_get + - name: KV2 Secrets Engine + description: Documentation for the Vault KV secrets engine, version 2. + link: https://www.vaultproject.io/docs/secrets/kv/kv-v2 +extends_documentation_fragment: + - community.hashi_vault.attributes + - community.hashi_vault.attributes.action_group + - community.hashi_vault.connection + - community.hashi_vault.auth + - community.hashi_vault.engine_mount +options: + engine_mount_point: + default: secret + path: + description: + - Vault KV path to be deleted. + - This is relative to the I(engine_mount_point), so the mount path should not be included. + - For kv2, do not include C(/data/) or C(/metadata/). + type: str + required: True + versions: + description: + - One or more versions of the secret to delete. + - When omitted, the latest version of the secret is deleted. + type: list + elements: int + required: False +''' + +EXAMPLES = """ +- name: Delete the latest version of the secret/mysecret secret. + community.hashi_vault.vault_kv2_delete: + url: https://vault:8201 + path: secret/mysecret + auth_method: userpass + username: user + password: '{{ passwd }}' + register: result + +- name: Delete versions 1 and 3 of the secret/mysecret secret. + community.hashi_vault.vault_kv2_delete: + url: https://vault:8201 + path: secret/mysecret + versions: [1, 3] + auth_method: userpass + username: user + password: '{{ passwd }}' +""" + +RETURN = """ +data: + description: + - The raw result of the delete against the given path. + - This is usually empty, but may contain warnings or other information. + returned: success + type: dict +""" + +import traceback + +from ansible.module_utils._text import to_native +from ansible.module_utils.basic import missing_required_lib + +from ansible_collections.community.hashi_vault.plugins.module_utils._hashi_vault_module import HashiVaultModule +from ansible_collections.community.hashi_vault.plugins.module_utils._hashi_vault_common import HashiVaultValueError + +try: + import hvac +except ImportError: + HAS_HVAC = False + HVAC_IMPORT_ERROR = traceback.format_exc() +else: + HVAC_IMPORT_ERROR = None + HAS_HVAC = True + + +def run_module(): + + argspec = HashiVaultModule.generate_argspec( + engine_mount_point=dict(type='str', default='secret'), + path=dict(type='str', required=True), + versions=dict(type='list', elements='int', required=False) + ) + + module = HashiVaultModule( + argument_spec=argspec, + supports_check_mode=True + ) + + if not HAS_HVAC: + module.fail_json( + msg=missing_required_lib('hvac'), + exception=HVAC_IMPORT_ERROR + ) + + engine_mount_point = module.params.get('engine_mount_point') + path = module.params.get('path') + versions = module.params.get('versions') + + module.connection_options.process_connection_options() + client_args = module.connection_options.get_hvac_connection_options() + client = module.helper.get_vault_client(**client_args) + + try: + module.authenticator.validate() + module.authenticator.authenticate(client) + except (NotImplementedError, HashiVaultValueError) as e: + module.fail_json(msg=to_native(e), exception=traceback.format_exc()) + + try: + # Vault has two separate methods, one for delete latest version, + # and delete specific versions. + if module.check_mode: + response = {} + elif not versions: + response = client.secrets.kv.v2.delete_latest_version_of_secret( + path=path, mount_point=engine_mount_point) + else: + response = client.secrets.kv.v2.delete_secret_versions( + path=path, versions=versions, mount_point=engine_mount_point) + + except hvac.exceptions.Forbidden as e: + module.fail_json(msg="Forbidden: Permission Denied to path ['%s']." % path, exception=traceback.format_exc()) + + # https://github.com/hvac/hvac/issues/797 + # HVAC returns a raw response object when the body is not JSON. + # That includes 204 responses, which are successful with no body. + # So we will try to detect that and a act accordingly. + # A better way may be to implement our own adapter for this + # collection, but it's a little premature to do that. + if hasattr(response, 'json') and callable(response.json): + if response.status_code == 204: + output = {} + else: + module.warn( + 'Vault returned status code %i and an unparsable body.' % response.status_code) + output = response.content + else: + output = response + + module.exit_json(changed=True, data=output) + + +def main(): + run_module() + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/vault_kv2_get.py b/plugins/modules/vault_kv2_get.py index d1f03787d..5ff5903fb 100644 --- a/plugins/modules/vault_kv2_get.py +++ b/plugins/modules/vault_kv2_get.py @@ -26,6 +26,9 @@ description: Documentation for the Vault KV secrets engine, version 2. link: https://www.vaultproject.io/docs/secrets/kv/kv-v2 extends_documentation_fragment: + - community.hashi_vault.attributes + - community.hashi_vault.attributes.action_group + - community.hashi_vault.attributes.check_mode_read_only - community.hashi_vault.connection - community.hashi_vault.auth - community.hashi_vault.engine_mount @@ -149,6 +152,7 @@ HAS_HVAC = False HVAC_IMPORT_ERROR = traceback.format_exc() else: + HVAC_IMPORT_ERROR = None HAS_HVAC = True diff --git a/plugins/modules/vault_list.py b/plugins/modules/vault_list.py new file mode 100644 index 000000000..a0823dc2d --- /dev/null +++ b/plugins/modules/vault_list.py @@ -0,0 +1,134 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# (c) 2023, Tom Kivlin (@tomkivlin) +# 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 + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +DOCUMENTATION = """ + module: vault_list + version_added: 4.1.0 + author: + - Tom Kivlin (@tomkivlin) + short_description: Perform a list operation against HashiCorp Vault + requirements: + - C(hvac) (L(Python library,https://hvac.readthedocs.io/en/stable/overview.html)) + - For detailed requirements, see R(the collection requirements page,ansible_collections.community.hashi_vault.docsite.user_guide.requirements). + description: + - Performs a generic list operation against a given path in HashiCorp Vault. + seealso: + - ref: community.hashi_vault.vault_list lookup + description: The official documentation for the C(community.hashi_vault.vault_list) lookup plugin. + extends_documentation_fragment: + - community.hashi_vault.attributes + - community.hashi_vault.attributes.action_group + - community.hashi_vault.attributes.check_mode_read_only + - community.hashi_vault.connection + - community.hashi_vault.auth + options: + path: + description: Vault path to be listed. + type: str + required: true +""" + +EXAMPLES = """ +- name: List kv2 secrets from Vault via the remote host with userpass auth + community.hashi_vault.vault_list: + url: https://vault:8201 + path: secret/metadata + # For kv2, the path needs to follow the pattern 'mount_point/metadata' or 'mount_point/metadata/path' to list all secrets in that path + auth_method: userpass + username: user + password: '{{ passwd }}' + register: secret + +- name: Display the secrets found at the path provided above + ansible.builtin.debug: + msg: "{{ secret.data.data['keys'] }}" + # Note that secret.data.data.keys won't work as 'keys' is a built-in method + +- name: List access policies from Vault via the remote host + community.hashi_vault.vault_list: + url: https://vault:8201 + path: sys/policies/acl + register: policies + +- name: Display the policy names + ansible.builtin.debug: + msg: "{{ policies.data.data['keys'] }}" + # Note that secret.data.data.keys won't work as 'keys' is a built-in method +""" + +RETURN = """ +data: + description: The raw result of the list against the given path. + returned: success + type: dict +""" + +import traceback + +from ansible.module_utils._text import to_native +from ansible.module_utils.basic import missing_required_lib + +from ansible_collections.community.hashi_vault.plugins.module_utils._hashi_vault_module import HashiVaultModule +from ansible_collections.community.hashi_vault.plugins.module_utils._hashi_vault_common import HashiVaultValueError + +try: + import hvac +except ImportError: + HAS_HVAC = False + HVAC_IMPORT_ERROR = traceback.format_exc() +else: + HVAC_IMPORT_ERROR = None + HAS_HVAC = True + + +def run_module(): + argspec = HashiVaultModule.generate_argspec( + path=dict(type='str', required=True), + ) + + module = HashiVaultModule( + argument_spec=argspec, + supports_check_mode=True + ) + + if not HAS_HVAC: + module.fail_json( + msg=missing_required_lib('hvac'), + exception=HVAC_IMPORT_ERROR + ) + + path = module.params.get('path') + + module.connection_options.process_connection_options() + client_args = module.connection_options.get_hvac_connection_options() + client = module.helper.get_vault_client(**client_args) + + try: + module.authenticator.validate() + module.authenticator.authenticate(client) + except (NotImplementedError, HashiVaultValueError) as e: + module.fail_json(msg=to_native(e), exception=traceback.format_exc()) + + try: + data = client.list(path) + except hvac.exceptions.Forbidden as e: + module.fail_json(msg="Forbidden: Permission Denied to path '%s'." % path, exception=traceback.format_exc()) + + if data is None: + module.fail_json(msg="The path '%s' doesn't seem to exist." % path) + + module.exit_json(data=data) + + +def main(): + run_module() + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/vault_login.py b/plugins/modules/vault_login.py index 34a284168..fe0408da2 100644 --- a/plugins/modules/vault_login.py +++ b/plugins/modules/vault_login.py @@ -21,9 +21,11 @@ seealso: - ref: community.hashi_vault.vault_login lookup description: The official documentation for the C(community.hashi_vault.vault_login) lookup plugin. - - ref: community.hashi_vault.vault_login_token filter + - ref: community.hashi_vault.vault_login_token filter description: The official documentation for the C(community.hashi_vault.vault_login_token) filter plugin. extends_documentation_fragment: + - community.hashi_vault.attributes + - community.hashi_vault.attributes.action_group - community.hashi_vault.connection - community.hashi_vault.auth notes: @@ -36,17 +38,17 @@ - "The C(token) auth method will only return full information if I(token_validate=True). If the token does not have the C(lookup-self) capability, this will fail. If I(token_validate=False), only the token value itself will be returned in the structure." - - "In check mode, this module will not perform a login, and will instead return a basic structure with an empty token. - However this may not be useful if the token is required for follow on tasks. - It may be better to use this module with C(check_mode=no) in order to have a valid token that can be used." + attributes: + check_mode: + support: partial + details: + - In check mode, this module will not perform a login, and will instead return a basic structure with an empty token. + However this may not be useful if the token is required for follow on tasks. + - It may be better to use this module with C(check_mode=false) in order to have a valid token that can be used. options: token_validate: - description: - - For token auth, will perform a C(lookup-self) operation to determine the token's validity before using it. - - Disable if your token does not have the C(lookup-self) capability. default: true """ -# TODO: remove token_validate description in 4.0.0 when it will match the doc frag description. EXAMPLES = """ - name: Login and use the resulting token @@ -100,18 +102,19 @@ from ansible.module_utils._text import to_native from ansible.module_utils.basic import missing_required_lib -from ansible_collections.community.hashi_vault.plugins.module_utils._hashi_vault_module import HashiVaultModule -from ansible_collections.community.hashi_vault.plugins.module_utils._hashi_vault_common import HashiVaultValueError +from ...plugins.module_utils._hashi_vault_module import HashiVaultModule +from ...plugins.module_utils._hashi_vault_common import HashiVaultValueError # we don't actually need to import hvac directly in this module # because all of the hvac calls happen in module utils, but # we would like to control the error message here for consistency. try: - import hvac + import hvac # pylint: disable=unused-import except ImportError: HAS_HVAC = False HVAC_IMPORT_ERROR = traceback.format_exc() else: + HVAC_IMPORT_ERROR = None HAS_HVAC = True @@ -122,8 +125,8 @@ def run_module(): token=dict(type='str', no_log=False, default=None), # we override this from the shared argspec because the default for - # this module should be True, which will differ from the rest of the - # collection after 4.0.0. + # this module should be True, which differs from the rest of the + # collection since 4.0.0. token_validate=dict(type='bool', default=True) ) diff --git a/plugins/modules/vault_pki_generate_certificate.py b/plugins/modules/vault_pki_generate_certificate.py index 53e36ad03..66b9190b4 100644 --- a/plugins/modules/vault_pki_generate_certificate.py +++ b/plugins/modules/vault_pki_generate_certificate.py @@ -26,9 +26,16 @@ description: HVAC library reference about the PKI engine. link: https://hvac.readthedocs.io/en/stable/usage/secrets_engines/pki.html#generate-certificate extends_documentation_fragment: + - community.hashi_vault.attributes + - community.hashi_vault.attributes.action_group - community.hashi_vault.connection - community.hashi_vault.auth - community.hashi_vault.engine_mount + attributes: + check_mode: + support: partial + details: + - In check mode, this module will not contact Vault and will return an empty C(data) field and C(changed) status. options: alt_names: description: @@ -37,6 +44,7 @@ - If any requested names do not match role policy, the entire request will be denied. type: list elements: str + default: [] common_name: description: - Specifies the requested CN for the certificate. @@ -66,6 +74,7 @@ - Only valid if the role allows IP SANs (which is the default). type: list elements: str + default: [] role_name: description: - Specifies the name of the role to create the certificate against. @@ -78,6 +87,7 @@ - "The format is the same as OpenSSL: C(;:) where the only current valid type is C(UTF8)." type: list elements: str + default: [] engine_mount_point: description: - Specify the mount point used by the PKI engine. @@ -102,6 +112,7 @@ - Specifies the requested URI Subject Alternative Names. type: list elements: str + default: [] """ EXAMPLES = """ @@ -206,10 +217,12 @@ try: import hvac from hvac.api.secrets_engines.pki import DEFAULT_MOUNT_POINT - HAS_HVAC = True except ImportError: HVAC_IMPORT_ERROR = traceback.format_exc() HAS_HVAC = False +else: + HVAC_IMPORT_ERROR = None + HAS_HVAC = True def run_module(): diff --git a/plugins/modules/vault_read.py b/plugins/modules/vault_read.py index 0e0e90e38..6b6b209d5 100644 --- a/plugins/modules/vault_read.py +++ b/plugins/modules/vault_read.py @@ -24,6 +24,9 @@ - ref: community.hashi_vault.hashi_vault lookup description: The official documentation for the C(community.hashi_vault.hashi_vault) lookup plugin. extends_documentation_fragment: + - community.hashi_vault.attributes + - community.hashi_vault.attributes.action_group + - community.hashi_vault.attributes.check_mode_read_only - community.hashi_vault.connection - community.hashi_vault.auth options: @@ -79,6 +82,7 @@ HAS_HVAC = False HVAC_IMPORT_ERROR = traceback.format_exc() else: + HVAC_IMPORT_ERROR = None HAS_HVAC = True diff --git a/plugins/modules/vault_token_create.py b/plugins/modules/vault_token_create.py index ffdaffb53..c2d19422f 100644 --- a/plugins/modules/vault_token_create.py +++ b/plugins/modules/vault_token_create.py @@ -24,9 +24,11 @@ - module: community.hashi_vault.vault_login - ref: community.hashi_vault.vault_login lookup description: The official documentation for the C(community.hashi_vault.vault_login) lookup plugin. - - ref: community.hashi_vault.vault_login_token filter + - ref: community.hashi_vault.vault_login_token filter description: The official documentation for the C(community.hashi_vault.vault_login_token) filter plugin. extends_documentation_fragment: + - community.hashi_vault.attributes + - community.hashi_vault.attributes.action_group - community.hashi_vault.connection - community.hashi_vault.auth - community.hashi_vault.token_create @@ -35,9 +37,13 @@ - Token creation is a write operation (creating a token persisted to storage), so this module always reports C(changed=True). - For the purposes of Ansible playbooks however, it may be more useful to set I(changed_when=false) if you are doing idempotency checks against the target system. - - In check mode, this module will not create a token, and will instead return a basic structure with an empty token. - However, this may not be useful if the token is required for follow on tasks. - It may be better to use this module with I(check_mode=no) in order to have a valid token that can be used. + attributes: + check_mode: + support: partial + details: + - In check mode, this module will not create a token, and will instead return a basic structure with an empty token. + However, this may not be useful if the token is required for follow on tasks. + - It may be better to use this module with I(check_mode=false) in order to have a valid token that can be used. options: {} """ @@ -131,7 +137,7 @@ ] -LEGACY_OPTION_TRANSLATION = { +ORPHAN_OPTION_TRANSLATION = { 'id': 'token_id', 'role_name': 'role', 'type': 'token_type', @@ -175,14 +181,11 @@ def run_module(): pass_thru_options = module.adapter.get_filled_options(*PASS_THRU_OPTION_NAMES) - if module.adapter.get_option('orphan'): - pass_thru_options['no_parent'] = True - - legacy_options = pass_thru_options.copy() + orphan_options = pass_thru_options.copy() for key in pass_thru_options.keys(): - if key in LEGACY_OPTION_TRANSLATION: - legacy_options[LEGACY_OPTION_TRANSLATION[key]] = legacy_options.pop(key) + if key in ORPHAN_OPTION_TRANSLATION: + orphan_options[ORPHAN_OPTION_TRANSLATION[key]] = orphan_options.pop(key) # token creation is a write operation, using storage and resources changed = True @@ -192,17 +195,18 @@ def run_module(): module.exit_json(changed=changed, login={'auth': {'client_token': None}}) if module.adapter.get_option('orphan'): - # this method is deprecated, but it's the only way through hvac to get - # at the /create-orphan endpoint at this time. - # See: https://github.com/hvac/hvac/issues/758 try: - response = client.create_token(orphan=True, **legacy_options) - except AttributeError: - module.warn("'create_token' method was not found. Attempting method that requires root privileges.") + try: + # this method was added in hvac 1.0.0 + # See: https://github.com/hvac/hvac/pull/869 + response = client.auth.token.create_orphan(**orphan_options) + except AttributeError: + # this method was removed in hvac 1.0.0 + # See: https://github.com/hvac/hvac/issues/758 + response = client.create_token(orphan=True, **orphan_options) except Exception as e: module.fail_json(msg=to_native(e), exception=traceback.format_exc()) - - if response is None: + else: try: response = client.auth.token.create(**pass_thru_options) except Exception as e: diff --git a/plugins/modules/vault_write.py b/plugins/modules/vault_write.py index 54c56cdd0..35c7fcb60 100644 --- a/plugins/modules/vault_write.py +++ b/plugins/modules/vault_write.py @@ -23,6 +23,11 @@ - The I(data) option is not treated as secret and may be logged. Use the C(no_log) keyword if I(data) contains sensitive values. - This module always reports C(changed) status because it cannot guarantee idempotence. - Use C(changed_when) to control that in cases where the operation is known to not change state. + attributes: + check_mode: + support: partial + details: + - In check mode, an empty response will be returned and the write will not be performed. seealso: - ref: community.hashi_vault.vault_write lookup description: The official documentation for the C(community.hashi_vault.vault_write) lookup plugin. @@ -30,6 +35,8 @@ - ref: community.hashi_vault.vault_read lookup description: The official documentation for the C(community.hashi_vault.vault_read) lookup plugin. extends_documentation_fragment: + - community.hashi_vault.attributes + - community.hashi_vault.attributes.action_group - community.hashi_vault.connection - community.hashi_vault.auth - community.hashi_vault.wrapping @@ -110,6 +117,7 @@ HAS_HVAC = False HVAC_IMPORT_ERROR = traceback.format_exc() else: + HVAC_IMPORT_ERROR = None HAS_HVAC = True diff --git a/tests/integration/requirements.txt b/tests/integration/requirements.txt index 7a9aaae0b..033733f7c 100644 --- a/tests/integration/requirements.txt +++ b/tests/integration/requirements.txt @@ -10,3 +10,7 @@ hvac >= 0.10.6 ; python_version >= '3.6' # these should be satisfied naturally by the requests versions required by hvac anyway urllib3 >= 1.15 ; python_version >= '3.6' # we need raise_on_status for retry support to raise the correct exceptions https://github.com/urllib3/urllib3/blob/main/CHANGES.rst#115-2016-04-06 urllib3 >= 1.15, <2.0.0 ; python_version < '3.6' # https://urllib3.readthedocs.io/en/latest/v2-roadmap.html#optimized-for-python-3-6 + +# azure-identity 1.7.0 depends on cryptography 2.5 which drops python 2.6 support +azure-identity < 1.7.0; python_version < '2.7' +azure-identity; python_version >= '2.7' diff --git a/tests/integration/targets/auth_azure/aliases b/tests/integration/targets/auth_azure/aliases new file mode 100644 index 000000000..32ccc0d36 --- /dev/null +++ b/tests/integration/targets/auth_azure/aliases @@ -0,0 +1,2 @@ +vault/auth/azure +context/target diff --git a/tests/integration/targets/auth_azure/defaults/main.yml b/tests/integration/targets/auth_azure/defaults/main.yml new file mode 100644 index 000000000..16540fbcd --- /dev/null +++ b/tests/integration/targets/auth_azure/defaults/main.yml @@ -0,0 +1,10 @@ +# Copyright (c) 2022 Junrui Chen (@jchenship) +# 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 +--- +ansible_hashi_vault_url: '{{ vault_mmock_server_http }}' +ansible_hashi_vault_auth_method: azure + +auth_paths: + - azure + - azure-alt diff --git a/tests/integration/targets/auth_azure/meta/main.yml b/tests/integration/targets/auth_azure/meta/main.yml new file mode 100644 index 000000000..fa4fc1c74 --- /dev/null +++ b/tests/integration/targets/auth_azure/meta/main.yml @@ -0,0 +1,7 @@ +# Copyright (c) 2022 Junrui Chen (@jchenship) +# 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 +--- +dependencies: + - setup_vault_test_plugins + - setup_vault_configure diff --git a/tests/integration/targets/auth_azure/tasks/azure_test_controller.yml b/tests/integration/targets/auth_azure/tasks/azure_test_controller.yml new file mode 100644 index 000000000..8b38c0d73 --- /dev/null +++ b/tests/integration/targets/auth_azure/tasks/azure_test_controller.yml @@ -0,0 +1,48 @@ +# Copyright (c) 2022 Junrui Chen (@jchenship) +# 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 +--- +- name: "Test block" + vars: + is_default_path: "{{ this_path == default_path }}" + kwargs_mount: "{{ {} if is_default_path else {'mount_point': this_path} }}" + kwargs_common: + jwt: azure-jwt + kwargs: "{{ kwargs_common | combine(kwargs_mount) }}" + block: + # the purpose of this test is to catch when the plugin accepts mount_point but does not pass it into hvac + # we set the policy of the default mount to deny access to this secret and so we expect failure when the mount + # is default, and success when the mount is alternate + - name: Check auth mount differing result + set_fact: + response: "{{ lookup('vault_test_auth', role_id='not-important', **kwargs) }}" + + - assert: + fail_msg: "A token from mount path '{{ this_path }}' had the wrong policy: {{ response.login.auth.policies }}" + that: + - ('azure-sample-policy' in response.login.auth.policies) | bool == is_default_path + - ('azure-sample-policy' not in response.login.auth.policies) | bool != is_default_path + - ('azure-alt-sample-policy' in response.login.auth.policies) | bool != is_default_path + - ('azure-alt-sample-policy' not in response.login.auth.policies) | bool == is_default_path + + - name: Failure expected when something goes wrong (simulated) + set_fact: + response: "{{ lookup('vault_test_auth', role_id='fail-me-role', want_exception=true, **kwargs) }}" + + - assert: + fail_msg: "An invalid request somehow did not cause a failure." + that: + - response is failed + - response.msg is search('expected audience .+ got .+') + + - name: Failure expected when role_id is not given + set_fact: + response: "{{ lookup('vault_test_auth', want_exception=true, **kwargs) }}" + + - assert: + fail_msg: | + Missing role_id did not cause an expected failure. + {{ response }} + that: + - response is failed + - response.msg is search('^role_id is required for azure authentication\.$') diff --git a/tests/integration/targets/auth_azure/tasks/azure_test_target.yml b/tests/integration/targets/auth_azure/tasks/azure_test_target.yml new file mode 100644 index 000000000..0c637c706 --- /dev/null +++ b/tests/integration/targets/auth_azure/tasks/azure_test_target.yml @@ -0,0 +1,54 @@ +# Copyright (c) 2022 Junrui Chen (@jchenship) +# 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 +--- +- name: "Test block" + vars: + is_default_path: "{{ this_path == default_path }}" + module_defaults: + vault_test_auth: + url: '{{ ansible_hashi_vault_url }}' + auth_method: '{{ ansible_hashi_vault_auth_method }}' + mount_point: '{{ omit if is_default_path else this_path }}' + jwt: azure-jwt + block: + # the purpose of this test is to catch when the plugin accepts mount_point but does not pass it into hvac + # we set the policy of the default mount to deny access to this secret and so we expect failure when the mount + # is default, and success when the mount is alternate + - name: Check auth mount differing result + register: response + vault_test_auth: + role_id: not-important + + - assert: + fail_msg: "A token from mount path '{{ this_path }}' had the wrong policy: {{ response.login.auth.policies }}" + that: + - ('azure-sample-policy' in response.login.auth.policies) | bool == is_default_path + - ('azure-sample-policy' not in response.login.auth.policies) | bool != is_default_path + - ('azure-alt-sample-policy' in response.login.auth.policies) | bool != is_default_path + - ('azure-alt-sample-policy' not in response.login.auth.policies) | bool == is_default_path + + - name: Failure expected when something goes wrong (simulated) + register: response + vault_test_auth: + role_id: fail-me-role + want_exception: yes + + - assert: + fail_msg: "An invalid request somehow did not cause a failure." + that: + - response.inner is failed + - response.msg is search('expected audience .+ got .+') + + - name: Failure expected when role_id is not given + register: response + vault_test_auth: + want_exception: yes + + - assert: + fail_msg: | + Missing role_id did not cause an expected failure. + {{ response }} + that: + - response.inner is failed + - response.msg is search('^role_id is required for azure authentication\.$') diff --git a/tests/integration/targets/auth_azure/tasks/main.yml b/tests/integration/targets/auth_azure/tasks/main.yml new file mode 100644 index 000000000..95bde76e5 --- /dev/null +++ b/tests/integration/targets/auth_azure/tasks/main.yml @@ -0,0 +1,26 @@ +# Copyright (c) 2022 Junrui Chen (@jchenship) +# 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 +--- +# task vars are not templated when used as vars, so we'll need to set_fact this evaluate the template +# see: https://github.com/ansible/ansible/issues/73268 +- name: Persist defaults + set_fact: + '{{ item.key }}': "{{ lookup('vars', item.key) }}" + loop: "{{ lookup('file', role_path ~ '/defaults/main.yml') | from_yaml | dict2items }}" + loop_control: + label: '{{ item.key }}' + +# there's no setup for this auth method because its API is mocked + +- name: Run azure tests + loop: '{{ auth_paths | product(["target", "controller"]) | list }}' + include_tasks: + file: azure_test_{{ item[1] }}.yml + apply: + vars: + default_path: azure + this_path: '{{ item[0] }}' + module_defaults: + assert: + quiet: yes diff --git a/tests/integration/targets/auth_kubernetes/defaults/main.yml b/tests/integration/targets/auth_kubernetes/defaults/main.yml new file mode 100644 index 000000000..920c029ad --- /dev/null +++ b/tests/integration/targets/auth_kubernetes/defaults/main.yml @@ -0,0 +1,6 @@ +--- +ansible_hashi_vault_url: "{{ vault_test_server_http }}" +ansible_hashi_vault_auth_method: kubernetes + +auth_paths: + - kubernetes diff --git a/tests/integration/targets/auth_kubernetes/files/service_account_token.jwt b/tests/integration/targets/auth_kubernetes/files/service_account_token.jwt new file mode 100644 index 000000000..e38d1040b --- /dev/null +++ b/tests/integration/targets/auth_kubernetes/files/service_account_token.jwt @@ -0,0 +1 @@ +eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJ0ZXN0Iiwic3ViIjoiaGFzaGlfdmF1bHRAdGVzdC5hbnNpYmxlLmNvbSIsIm5iZiI6MTYwNDgzNTEwMCwiZXhwIjozMjQ5OTA1MTM1OX0.NEWQR_Eicw8Fa9gU9HPY2M9Rp1czNTUKrICwKe7l1edaZNtgxhMGdyqnBsPrHL_dw1ZIwdvwVAioi8bEyIDEWICls0lzHwM169rrea3WEFrB5CP17A6DkvYL0cnOnGutbwUrXInPCRUfvRogIKEI-w8X-ris9LX2FBPKhXX1K3U0D8uYi5_9t8YWywTe0NkYvY-nTzMugK1MXMoBJ3fCksweJiDp6BOo3v9OU03MLgwgri2UdsqVb7WSk4XvWG-lmbiiSAWVf9BI3mecVDUHpYxbEqjv1HDG_wdX8zy1ZlAFbjp3kIpMlDVK1Q5nu_VPDzQrEvPdTnOzU36LE4UF-w diff --git a/tests/integration/targets/auth_kubernetes/files/service_account_token_invalid.jwt b/tests/integration/targets/auth_kubernetes/files/service_account_token_invalid.jwt new file mode 100644 index 000000000..aa608e6c4 --- /dev/null +++ b/tests/integration/targets/auth_kubernetes/files/service_account_token_invalid.jwt @@ -0,0 +1 @@ +eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiIxMjM0IiwidXNlcl9jbGFpbSI6InVzZXJfY2xhaW0iLCJuYmYiOjE2MDQ4MzUxMDAsImV4cCI6MzI0OTkwNTEzNTl9.etc2WSH7kR3fHFlVt4wlBYFKNn7Z4DQcRVXUK4gGF-Q diff --git a/tests/integration/targets/auth_kubernetes/meta/main.yml b/tests/integration/targets/auth_kubernetes/meta/main.yml new file mode 100644 index 000000000..d3acb69e9 --- /dev/null +++ b/tests/integration/targets/auth_kubernetes/meta/main.yml @@ -0,0 +1,4 @@ +--- +dependencies: + - setup_vault_test_plugins + - setup_vault_configure diff --git a/tests/integration/targets/auth_kubernetes/tasks/kubernetes_setup.yml b/tests/integration/targets/auth_kubernetes/tasks/kubernetes_setup.yml new file mode 100644 index 000000000..a6ad26e08 --- /dev/null +++ b/tests/integration/targets/auth_kubernetes/tasks/kubernetes_setup.yml @@ -0,0 +1,32 @@ +- name: "Setup block" + vars: + is_default_path: "{{ this_path == default_path }}" + block: + - name: "Enable the Kubernetes auth method" + vault_ci_enable_auth: + method_type: kubernetes + path: "{{ omit if is_default_path else this_path }}" + config: + default_lease_ttl: 60m + + - name: "Configure the Kubernetes auth method" + vars: + # jwt_public_key: '{{ lookup("file", "jwt_public.pem") }}' + # kubernetes_host: 'https://$KUBERNETES_SERVICE_HOST:$KUBERNETES_SERVICE_PORT' + vault_ci_write: + path: "auth/{{ this_path }}/config" + data: + # in docs, this is token_policies (changed in Vault 1.2) + # use 'policies' to support older versions + policies: "{{ 'test-policy' if is_default_path else 'alt-policy' }},approle-policy" + + - name: "Create a named role" + vault_ci_write: + path: "auth/{{ this_path }}/role/test-role" + data: + # in docs, this is token_policies (changed in Vault 1.2) + # use 'policies' to support older versions + policies: "{{ 'test-policy' if is_default_path else 'alt-policy' }},approle-policy" + role_type: kubernetes + user_claim: sub + bound_audiences: test diff --git a/tests/integration/targets/auth_kubernetes/tasks/kubernetes_test_controller.yml b/tests/integration/targets/auth_kubernetes/tasks/kubernetes_test_controller.yml new file mode 100644 index 000000000..cf8f97bf3 --- /dev/null +++ b/tests/integration/targets/auth_kubernetes/tasks/kubernetes_test_controller.yml @@ -0,0 +1,39 @@ +- name: "Test block" + vars: + service_account_token: '{{ lookup("file", "service_account_token.jwt") }}' + service_account_token_invalid: '{{ lookup("file", "service_account_token_invalid.jwt") }}' + is_default_path: "{{ this_path == default_path }}" + kwargs_common: + role_id: test-role + kwargs_mount: "{{ {} if is_default_path else {'mount_point': this_path} }}" + kwargs_service_account_token: + service_account_token: "{{ service_account_token }}" + kwargs: "{{ kwargs_common | combine(kwargs_mount) | combine(kwargs_service_account_token) }}" + block: + # the purpose of this test is to catch when the plugin accepts mount_point but does not pass it into hvac + # we set the policy of the default mount to deny access to this secret and so we expect failure when the mount + # is default, and success when the mount is alternate + - name: Check auth mount differing result + set_fact: + response: "{{ lookup('vault_test_auth', **kwargs) }}" + + - assert: + fail_msg: "A token from mount path '{{ this_path }}' had the wrong policy: {{ response.login.auth.policies }}" + that: + - ('test-policy' in response.login.auth.policies) | bool == is_default_path + - ('test-policy' not in response.login.auth.policies) | bool != is_default_path + - ('alt-policy' in response.login.auth.policies) | bool != is_default_path + - ('alt-policy' not in response.login.auth.policies) | bool == is_default_path + + - name: Failure expected when erroneous credentials are used + vars: + kwargs_: + service_account_token: "{{ service_account_token_invalid }}" + set_fact: + response: "{{ lookup('vault_test_auth', want_exception=true, **kwargs) }}" + + - assert: + fail_msg: "An invalid JWT somehow did not cause a failure." + that: + - response is failed + - response.msg is search('no known key successfully validated the token signature') diff --git a/tests/integration/targets/auth_kubernetes/tasks/kubernetes_test_target.yml b/tests/integration/targets/auth_kubernetes/tasks/kubernetes_test_target.yml new file mode 100644 index 000000000..ae1f72d43 --- /dev/null +++ b/tests/integration/targets/auth_kubernetes/tasks/kubernetes_test_target.yml @@ -0,0 +1,39 @@ +- name: "Test block" + vars: + is_default_path: "{{ this_path == default_path }}" + service_account_token: '{{ lookup("file", "service_account_token.jwt") }}' + service_account_token_invalid: '{{ lookup("file", "service_account_token_invalid.jwt") }}' + module_defaults: + vault_test_auth: + url: '{{ ansible_hashi_vault_url }}' + auth_method: '{{ ansible_hashi_vault_auth_method }}' + role_id: test-role + mount_point: '{{ omit if is_default_path else this_path }}' + service_account_token: '{{ service_account_token }}' + block: + # the purpose of this test is to catch when the plugin accepts mount_point but does not pass it into hvac + # we set the policy of the default mount to deny access to this secret and so we expect failure when the mount + # is default, and success when the mount is alternate + - name: Check auth mount differing result + register: response + vault_test_auth: + + - assert: + fail_msg: "A token from mount path '{{ this_path }}' had the wrong policy: {{ response.login.auth.policies }}" + that: + - ('test-policy' in response.login.auth.policies) | bool == is_default_path + - ('test-policy' not in response.login.auth.policies) | bool != is_default_path + - ('alt-policy' in response.login.auth.policies) | bool != is_default_path + - ('alt-policy' not in response.login.auth.policies) | bool == is_default_path + + - name: Failure expected when erroneous credentials are used + register: response + vault_test_auth: + service_account_token: '{{ service_account_token_invalid }}' + want_exception: yes + + - assert: + fail_msg: "An invalid Kubernetes Service Account Token somehow did not cause a failure." + that: + - response.inner is failed + - response.msg is search('no known key successfully validated the token signature') diff --git a/tests/integration/targets/auth_kubernetes/tasks/main.yml b/tests/integration/targets/auth_kubernetes/tasks/main.yml new file mode 100644 index 000000000..4e918ec60 --- /dev/null +++ b/tests/integration/targets/auth_kubernetes/tasks/main.yml @@ -0,0 +1,55 @@ +--- +# task vars are not templated when used as vars, so we'll need to set_fact this evaluate the template +# see: https://github.com/ansible/ansible/issues/73268 +- name: Persist defaults + set_fact: + "{{ item.key }}": "{{ lookup('vars', item.key) }}" + loop: "{{ lookup('file', role_path ~ '/defaults/main.yml') | from_yaml | dict2items }}" + loop_control: + label: "{{ item.key }}" + +- name: Configuration tasks + module_defaults: + vault_ci_enable_auth: "{{ vault_plugins_module_defaults_common }}" + vault_ci_write: "{{ vault_plugins_module_defaults_common }}" + vault_ci_read: "{{ vault_plugins_module_defaults_common }}" + block: + - name: Canary for Kubernetes auth + vault_ci_read: + path: "{{ vault_kubernetes_canary.path }}" + register: canary + + - name: Configure Kubernetes + when: canary.result is none + loop: "{{ auth_paths }}" + include_tasks: + file: kubernetes_setup.yml + apply: + vars: + default_path: "{{ ansible_hashi_vault_auth_method }}" + this_path: "{{ item }}" + + - name: Write Canary + when: canary.result is none + vault_ci_write: + path: "{{ vault_kubernetes_canary.path }}" + data: + value: "{{ vault_kubernetes_canary.value }}" + +- name: Run Kubernetes tests (controller) + loop: "{{ auth_paths }}" + include_tasks: + file: kubernetes_test_controller.yml + apply: &test_apply + vars: + default_path: "{{ ansible_hashi_vault_auth_method }}" + this_path: "{{ item }}" + module_defaults: + assert: + quiet: yes + +- name: Run Kubernetes tests (target) + loop: "{{ auth_paths }}" + include_tasks: + file: kubernetes_test_target.yml + apply: *test_apply diff --git a/tests/integration/targets/auth_token/tasks/token_test_controller.yml b/tests/integration/targets/auth_token/tasks/token_test_controller.yml index d88114eed..137c747ce 100644 --- a/tests/integration/targets/auth_token/tasks/token_test_controller.yml +++ b/tests/integration/targets/auth_token/tasks/token_test_controller.yml @@ -11,10 +11,8 @@ ansible_hashi_vault_token: '{{ user_token }}' - name: Authenticate with a 'no default policy' token (failure expected) - # vars: - # ansible_hashi_vault_token_validate: true - # This will fail when the default changes in 4.0.0. Uncomment above. - # https://github.com/ansible-collections/community.hashi_vault/issues/248 + vars: + ansible_hashi_vault_token_validate: true set_fact: response: "{{ lookup('vault_test_auth', want_exception=true) }}" @@ -24,10 +22,6 @@ - response.msg is search('Invalid Vault Token') - name: Authenticate with 'no default policy' token - with no validation - vars: - ansible_hashi_vault_token_validate: false - # After 4.0.0, let's let this one use the default value and unset the above. - # https://github.com/ansible-collections/community.hashi_vault/issues/248 set_fact: response: "{{ lookup('vault_test_auth') }}" @@ -73,8 +67,9 @@ vars: user_token: '{{ user_token_cmd.result.auth.client_token }}' expected_policy: test-policy + ansible_hashi_vault_token_validate: true block: - - name: Authenticate with a token + - name: Authenticate with a token (with validation) set_fact: response: "{{ lookup('vault_test_auth', token=user_token) }}" @@ -84,7 +79,7 @@ - expected_policy in response.login.data.policies - expected_policy in response.login.auth.policies - - name: Authenticate with an invalid token + - name: Authenticate with an invalid token (wuth validation) set_fact: response: "{{ lookup('vault_test_auth', token='fake', want_exception=true) }}" diff --git a/tests/integration/targets/auth_token/tasks/token_test_target.yml b/tests/integration/targets/auth_token/tasks/token_test_target.yml index a5b41305e..cbfa30f70 100644 --- a/tests/integration/targets/auth_token/tasks/token_test_target.yml +++ b/tests/integration/targets/auth_token/tasks/token_test_target.yml @@ -11,9 +11,7 @@ - name: Authenticate with a 'no default policy' token (failure expected) register: response vault_test_auth: - # token_validate: true - # This will fail when the default changes in 4.0.0. Uncomment above. - # https://github.com/ansible-collections/community.hashi_vault/issues/248 + token_validate: true want_exception: true - assert: @@ -24,9 +22,6 @@ - name: Authenticate with 'no default policy' token - with no validation register: response vault_test_auth: - token_validate: false - # After 4.0.0, let's let this one use the default value and unset the above. - # https://github.com/ansible-collections/community.hashi_vault/issues/248 - assert: that: response.login.auth.client_token == user_token @@ -74,10 +69,11 @@ vault_test_auth: url: '{{ ansible_hashi_vault_url }}' block: - - name: Authenticate with a token + - name: Authenticate with a token (with validation) register: response vault_test_auth: token: '{{ user_token }}' + token_validate: true - assert: that: @@ -85,10 +81,11 @@ - expected_policy in response.login.data.policies - expected_policy in response.login.auth.policies - - name: Authenticate with an invalid token + - name: Authenticate with an invalid token (with validation) register: response vault_test_auth: token: fake + token_validate: true want_exception: true - assert: diff --git a/tests/integration/targets/lookup_hashi_vault/tasks/lookup_test.yml b/tests/integration/targets/lookup_hashi_vault/tasks/lookup_test.yml index e0c7ba240..adca2d0db 100644 --- a/tests/integration/targets/lookup_hashi_vault/tasks/lookup_test.yml +++ b/tests/integration/targets/lookup_hashi_vault/tasks/lookup_test.yml @@ -1,6 +1,7 @@ --- - name: Var block vars: + ansible_hashi_vault_token_validate: true user_token: '{{ user_token_cmd.result.auth.client_token }}' kwargs: url: '{{ vault_test_server_http }}' diff --git a/tests/integration/targets/lookup_vault_kv1_get/tasks/lookup_vault_kv1_get_test.yml b/tests/integration/targets/lookup_vault_kv1_get/tasks/lookup_vault_kv1_get_test.yml index 95ce7777b..ed317023d 100644 --- a/tests/integration/targets/lookup_vault_kv1_get/tasks/lookup_vault_kv1_get_test.yml +++ b/tests/integration/targets/lookup_vault_kv1_get/tasks/lookup_vault_kv1_get_test.yml @@ -1,6 +1,7 @@ --- - name: Var block vars: + ansible_hashi_vault_token_validate: true user_token: '{{ user_token_cmd.result.auth.client_token }}' kwargs: url: '{{ vault_test_server_http }}' diff --git a/tests/integration/targets/lookup_vault_kv2_get/tasks/lookup_vault_kv2_get_test.yml b/tests/integration/targets/lookup_vault_kv2_get/tasks/lookup_vault_kv2_get_test.yml index 41ed54a42..b335290ec 100644 --- a/tests/integration/targets/lookup_vault_kv2_get/tasks/lookup_vault_kv2_get_test.yml +++ b/tests/integration/targets/lookup_vault_kv2_get/tasks/lookup_vault_kv2_get_test.yml @@ -1,6 +1,7 @@ --- - name: Var block vars: + ansible_hashi_vault_token_validate: true user_token: '{{ user_token_cmd.result.auth.client_token }}' kwargs: url: '{{ vault_test_server_http }}' diff --git a/tests/integration/targets/lookup_vault_list/aliases b/tests/integration/targets/lookup_vault_list/aliases new file mode 100644 index 000000000..1bb8bf6d7 --- /dev/null +++ b/tests/integration/targets/lookup_vault_list/aliases @@ -0,0 +1 @@ +# empty diff --git a/tests/integration/targets/lookup_vault_list/meta/main.yml b/tests/integration/targets/lookup_vault_list/meta/main.yml new file mode 100644 index 000000000..d3acb69e9 --- /dev/null +++ b/tests/integration/targets/lookup_vault_list/meta/main.yml @@ -0,0 +1,4 @@ +--- +dependencies: + - setup_vault_test_plugins + - setup_vault_configure diff --git a/tests/integration/targets/lookup_vault_list/tasks/lookup_vault_list_setup.yml b/tests/integration/targets/lookup_vault_list/tasks/lookup_vault_list_setup.yml new file mode 100644 index 000000000..193d6fa5e --- /dev/null +++ b/tests/integration/targets/lookup_vault_list/tasks/lookup_vault_list_setup.yml @@ -0,0 +1,9 @@ +--- +- name: Configuration tasks + module_defaults: + vault_ci_token_create: '{{ vault_plugins_module_defaults_common }}' + block: + - name: 'Create a test non-root token' + vault_ci_token_create: + policies: test-policy + register: user_token_cmd diff --git a/tests/integration/targets/lookup_vault_list/tasks/lookup_vault_list_test.yml b/tests/integration/targets/lookup_vault_list/tasks/lookup_vault_list_test.yml new file mode 100644 index 000000000..9c2190208 --- /dev/null +++ b/tests/integration/targets/lookup_vault_list/tasks/lookup_vault_list_test.yml @@ -0,0 +1,137 @@ +--- +- name: Var block + vars: + ansible_hashi_vault_token_validate: true + user_token: '{{ user_token_cmd.result.auth.client_token }}' + kwargs: + url: '{{ vault_test_server_http }}' + auth_method: token + token: '{{ user_token }}' + block: + - name: 'Check kv2 secret list' + vars: + kv2_secret2: "{{ lookup('community.hashi_vault.vault_list', vault_kv2_api_list_path, **kwargs) }}" + assert: + that: + - "'data' in kv2_secret2" + - "'keys' in kv2_secret2['data']" + fail_msg: 'Return value did not contain expected fields.' + + - name: 'Check kv2 mount point list' + vars: + kv2_mount_point: "{{ lookup('community.hashi_vault.vault_list', vault_kv2_api_list_mount_point, **kwargs) }}" + assert: + that: + - "'data' in kv2_mount_point" + - "'keys' in kv2_mount_point['data']" + fail_msg: 'Return value did not contain expected fields.' + + - name: "Check multiple path list as array" + vars: + paths: + - '{{ vault_kv2_api_list_path }}' + - '{{ vault_policy_api_list_path }}' + list_results: "{{ lookup('community.hashi_vault.vault_list', *paths, **kwargs) }}" + assert: + that: + - list_results | type_debug == 'list' + - item | type_debug == 'dict' + - "'data' in item" + - "'keys' in item['data']" + - item['data']['keys'] | type_debug == 'list' + fail_msg: 'Return value was not correct type or items do not match.' + loop: '{{ list_results }}' + + + ### failure tests + + - name: 'Failure expected when erroneous credentials are used' + vars: + secret_wrong_cred: "{{ lookup('community.hashi_vault.vault_list', vault_kv2_api_list_path, token='wrong_token', url=kwargs.url) }}" + debug: + msg: 'Failure is expected ({{ secret_wrong_cred }})' + register: test_wrong_cred + ignore_errors: true + + - assert: + that: + - test_wrong_cred is failed + - test_wrong_cred.msg is search('Invalid Vault Token') + fail_msg: "Expected failure but got success or wrong failure message." + + - name: 'Failure expected when unauthorized path is provided' + vars: + secret_unauthorized: "{{ lookup('community.hashi_vault.vault_list', unauthorized_vault_kv2_mount_point, **kwargs) }}" + debug: + msg: 'Failure is expected ({{ secret_unauthorized }})' + register: test_unauthorized + ignore_errors: true + + - assert: + that: + - test_unauthorized is failed + - test_unauthorized.msg is search('Permission Denied') + fail_msg: "Expected failure but got success or wrong failure message." + + # When an inexistent mount point is listed, the API returns a 403 error, not 404. + - name: 'Failure expected when inexistent mount point is listed' + vars: + mount_point_inexistent: "{{ lookup('community.hashi_vault.vault_list', vault_kv2_api_list_inexistent_mount_point, **kwargs) }}" + debug: + msg: 'Failure is expected ({{ mount_point_inexistent }})' + register: test_inexistent_mount_point + ignore_errors: true + + - assert: + that: + - test_inexistent_mount_point is failed + - test_inexistent_mount_point.msg is search("Permission Denied") + fail_msg: "Expected failure but got success or wrong failure message." + + - name: 'Failure expected when inexistent path is listed' + vars: + path_inexistent: "{{ lookup('community.hashi_vault.vault_list', vault_kv2_api_list_inexistent_path, **kwargs) }}" + debug: + msg: 'Failure is expected ({{ path_inexistent }})' + register: test_inexistent + ignore_errors: true + + - assert: + that: + - test_inexistent is failed + - test_inexistent.msg is search("doesn't seem to exist") + fail_msg: "Expected failure but got success or wrong failure message." + + # If an inexistent path is included in a policy statement that denies access, the list API returns a 403 error. + - name: 'Failure expected when inexistent path is listed but is explicitly mentioned in a policy statement' + vars: + path_inexistent_unauthorized: "{{ lookup('community.hashi_vault.vault_list', vault_kv2_api_list_inexistent_unauthorized_path, **kwargs) }}" + debug: + msg: 'Failure is expected ({{ path_inexistent_unauthorized }})' + register: test_inexistent_unauthorized + ignore_errors: true + + - assert: + that: + - test_inexistent_unauthorized is failed + - test_inexistent_unauthorized.msg is search("Permission Denied") + fail_msg: "Expected failure but got success or wrong failure message." + + # do this last so our set_fact doesn't affect any other tests + - name: Set the vars that will configure the lookup settings we can't set via with_ + set_fact: + ansible_hashi_vault_url: '{{ kwargs.url }}' + ansible_hashi_vault_token: '{{ kwargs.token }}' + ansible_hashi_vault_auth_method: '{{ kwargs.auth_method }}' + + - name: Check multiple path list via with_ + assert: + that: + - item | type_debug == 'dict' + - "'data' in item" + - "'keys' in item['data']" + - item['data']['keys'] | type_debug == 'list' + fail_msg: 'Return value was not correct type or items do not match.' + with_community.hashi_vault.vault_list: + - '{{ vault_kv2_api_list_path }}' + - '{{ vault_policy_api_list_path }}' diff --git a/tests/integration/targets/lookup_vault_list/tasks/main.yml b/tests/integration/targets/lookup_vault_list/tasks/main.yml new file mode 100644 index 000000000..e0caae6bd --- /dev/null +++ b/tests/integration/targets/lookup_vault_list/tasks/main.yml @@ -0,0 +1,3 @@ +--- +- import_tasks: lookup_vault_list_setup.yml +- import_tasks: lookup_vault_list_test.yml diff --git a/tests/integration/targets/lookup_vault_read/tasks/lookup_vault_read_test.yml b/tests/integration/targets/lookup_vault_read/tasks/lookup_vault_read_test.yml index b2a63637f..111ccdcf8 100644 --- a/tests/integration/targets/lookup_vault_read/tasks/lookup_vault_read_test.yml +++ b/tests/integration/targets/lookup_vault_read/tasks/lookup_vault_read_test.yml @@ -1,6 +1,7 @@ --- - name: Var block vars: + ansible_hashi_vault_token_validate: true user_token: '{{ user_token_cmd.result.auth.client_token }}' kwargs: url: '{{ vault_test_server_http }}' diff --git a/tests/integration/targets/lookup_vault_token_create/tasks/lookup_vault_token_create_test.yml b/tests/integration/targets/lookup_vault_token_create/tasks/lookup_vault_token_create_test.yml index f32de9996..de2c9cd7a 100644 --- a/tests/integration/targets/lookup_vault_token_create/tasks/lookup_vault_token_create_test.yml +++ b/tests/integration/targets/lookup_vault_token_create/tasks/lookup_vault_token_create_test.yml @@ -35,7 +35,7 @@ - assert: that: - orphan_result is failed - - orphan_result.msg is search('root or sudo privileges required to create orphan token') + - orphan_result.msg is search('permission denied') - name: (xfail) Create an orphan token with no_parent=true vars: diff --git a/tests/integration/targets/lookup_vault_write/tasks/lookup_vault_write_test.yml b/tests/integration/targets/lookup_vault_write/tasks/lookup_vault_write_test.yml index 984ae5595..33dc245f9 100644 --- a/tests/integration/targets/lookup_vault_write/tasks/lookup_vault_write_test.yml +++ b/tests/integration/targets/lookup_vault_write/tasks/lookup_vault_write_test.yml @@ -1,6 +1,7 @@ --- - name: Var block vars: + ansible_hashi_vault_token_validate: true user_token: '{{ user_token_cmd.result.auth.client_token }}' kwargs: url: '{{ vault_test_server_http }}' diff --git a/tests/integration/targets/module_vault_kv1_get/tasks/module_vault_kv1_get_test.yml b/tests/integration/targets/module_vault_kv1_get/tasks/module_vault_kv1_get_test.yml index 5f474bc11..e6bfb1c1f 100644 --- a/tests/integration/targets/module_vault_kv1_get/tasks/module_vault_kv1_get_test.yml +++ b/tests/integration/targets/module_vault_kv1_get/tasks/module_vault_kv1_get_test.yml @@ -7,6 +7,7 @@ url: '{{ vault_test_server_http }}' auth_method: token token: '{{ user_token }}' + token_validate: true timeout: 5 block: - name: Test defualt path value diff --git a/tests/integration/targets/module_vault_kv2_delete/aliases b/tests/integration/targets/module_vault_kv2_delete/aliases new file mode 100644 index 000000000..7636a9a65 --- /dev/null +++ b/tests/integration/targets/module_vault_kv2_delete/aliases @@ -0,0 +1 @@ +context/target diff --git a/tests/integration/targets/module_vault_kv2_delete/meta/main.yml b/tests/integration/targets/module_vault_kv2_delete/meta/main.yml new file mode 100644 index 000000000..d3acb69e9 --- /dev/null +++ b/tests/integration/targets/module_vault_kv2_delete/meta/main.yml @@ -0,0 +1,4 @@ +--- +dependencies: + - setup_vault_test_plugins + - setup_vault_configure diff --git a/tests/integration/targets/module_vault_kv2_delete/tasks/main.yml b/tests/integration/targets/module_vault_kv2_delete/tasks/main.yml new file mode 100644 index 000000000..e222b14e6 --- /dev/null +++ b/tests/integration/targets/module_vault_kv2_delete/tasks/main.yml @@ -0,0 +1,3 @@ +--- +- import_tasks: module_vault_kv2_delete_setup.yml +- import_tasks: module_vault_kv2_delete_test.yml diff --git a/tests/integration/targets/module_vault_kv2_delete/tasks/module_vault_kv2_delete_setup.yml b/tests/integration/targets/module_vault_kv2_delete/tasks/module_vault_kv2_delete_setup.yml new file mode 100644 index 000000000..4b058c0ce --- /dev/null +++ b/tests/integration/targets/module_vault_kv2_delete/tasks/module_vault_kv2_delete_setup.yml @@ -0,0 +1,28 @@ +--- +- name: Configuration tasks + module_defaults: + vault_ci_token_create: '{{ vault_plugins_module_defaults_common }}' + block: + - name: Create a test non-root token + vault_ci_token_create: + policies: [test-policy] + register: user_token_cmd + +- name: Configuration tasks + module_defaults: + vault_ci_kv2_destroy_all: '{{ vault_plugins_module_defaults_common }}' + vault_ci_kv_put: '{{ vault_plugins_module_defaults_common }}' + block: + - name: Remove existing multi-version secret + vault_ci_kv2_destroy_all: + mount_point: '{{ vault_kv2_mount_point }}' + path: '{{ vault_kv2_versioned_path }}/secret6' + + - name: Set up a multi versioned secret for delete (v2) + vault_ci_kv_put: + version: 2 + mount_point: '{{ vault_kv2_mount_point }}' + path: '{{ vault_kv2_versioned_path }}/secret6' + secret: + v: value{{ item }} + loop: ["1", "2", "3", "4", "5"] diff --git a/tests/integration/targets/module_vault_kv2_delete/tasks/module_vault_kv2_delete_test.yml b/tests/integration/targets/module_vault_kv2_delete/tasks/module_vault_kv2_delete_test.yml new file mode 100644 index 000000000..57e770526 --- /dev/null +++ b/tests/integration/targets/module_vault_kv2_delete/tasks/module_vault_kv2_delete_test.yml @@ -0,0 +1,232 @@ +--- +- name: Var block + vars: + user_token: '{{ user_token_cmd.result.auth.client_token }}' + regex_secret_version_is_deleted: "^[0-9]{4}-[0-9]{2}-[0-9]{2}T.*" + regex_secret_version_not_deleted: "^$" + + module_defaults: + community.hashi_vault.vault_kv2_delete: &defaults + url: '{{ vault_test_server_http }}' + auth_method: token + token: '{{ user_token }}' + token_validate: true + timeout: 5 + vault_ci_kv2_metadata_read: '{{ vault_plugins_module_defaults_common }}' + + block: + - name: Test default path value + register: default_path + community.hashi_vault.vault_kv2_delete: + path: '{{ vault_kv2_path }}/secret2' + ignore_errors: true + + - assert: + that: + - default_path is failed + - default_path.msg is search('Permission Denied to path') + + - module_defaults: + community.hashi_vault.vault_kv2_delete: + <<: *defaults + engine_mount_point: '{{ vault_kv2_mount_point }}' + block: + + - name: Check kv2 existing versions + register: kv2_result + vault_ci_kv2_metadata_read: + path: "{{ vault_kv2_versioned_path }}/secret6" + mount_point: '{{ vault_kv2_mount_point }}' + + - assert: + that: + - "'result' in kv2_result" + - "'data' in kv2_result['result']" + - "'versions' in kv2_result['result']['data']" + - "kv2_result['result']['data']['versions']['1']['deletion_time'] is search(regex_secret_version_not_deleted)" + - "kv2_result['result']['data']['versions']['2']['deletion_time'] is search(regex_secret_version_not_deleted)" + - "kv2_result['result']['data']['versions']['3']['deletion_time'] is search(regex_secret_version_not_deleted)" + - "kv2_result['result']['data']['versions']['4']['deletion_time'] is search(regex_secret_version_not_deleted)" + - "kv2_result['result']['data']['versions']['5']['deletion_time'] is search(regex_secret_version_not_deleted)" + fail_msg: 'Test Seed value did not contain expected data.' + + + - name: Try kv2 delete latest version in check mode + register: kv2_result + community.hashi_vault.vault_kv2_delete: + path: "{{ vault_kv2_versioned_path }}/secret6" + check_mode: true + + - assert: + that: + - kv2_result is changed + - kv2_result.data == {} + + - name: Read resultant secret versions + register: kv2_result + vault_ci_kv2_metadata_read: + path: "{{ vault_kv2_versioned_path }}/secret6" + mount_point: '{{ vault_kv2_mount_point }}' + + - assert: + that: + - "'result' in kv2_result" + - "'data' in kv2_result['result']" + - "'versions' in kv2_result['result']['data']" + - "kv2_result['result']['data']['versions']['1']['deletion_time'] is search(regex_secret_version_not_deleted)" + - "kv2_result['result']['data']['versions']['2']['deletion_time'] is search(regex_secret_version_not_deleted)" + - "kv2_result['result']['data']['versions']['3']['deletion_time'] is search(regex_secret_version_not_deleted)" + - "kv2_result['result']['data']['versions']['4']['deletion_time'] is search(regex_secret_version_not_deleted)" + - "kv2_result['result']['data']['versions']['5']['deletion_time'] is search(regex_secret_version_not_deleted)" + fail_msg: 'Secret version was deleted while in check mode.' + + + - name: Try kv2 delete specific version in check mode + register: kv2_result + community.hashi_vault.vault_kv2_delete: + path: "{{ vault_kv2_versioned_path }}/secret6" + versions: [1, 3] + check_mode: true + + - name: Read resultant secret versions + register: kv2_result + vault_ci_kv2_metadata_read: + path: "{{ vault_kv2_versioned_path }}/secret6" + mount_point: '{{ vault_kv2_mount_point }}' + + - assert: + that: + - "'result' in kv2_result" + - "'data' in kv2_result['result']" + - "'versions' in kv2_result['result']['data']" + - "kv2_result['result']['data']['versions']['1']['deletion_time'] is search(regex_secret_version_not_deleted)" + - "kv2_result['result']['data']['versions']['2']['deletion_time'] is search(regex_secret_version_not_deleted)" + - "kv2_result['result']['data']['versions']['3']['deletion_time'] is search(regex_secret_version_not_deleted)" + - "kv2_result['result']['data']['versions']['4']['deletion_time'] is search(regex_secret_version_not_deleted)" + - "kv2_result['result']['data']['versions']['5']['deletion_time'] is search(regex_secret_version_not_deleted)" + fail_msg: 'Secret version was deleted while in check mode.' + + + - name: Try kv2 delete version 1 and 3 + register: kv2_result + community.hashi_vault.vault_kv2_delete: + path: "{{ vault_kv2_versioned_path }}/secret6" + versions: + - 1 + - 3 + + - name: Read resultant secret versions + register: kv2_result + vault_ci_kv2_metadata_read: + path: "{{ vault_kv2_versioned_path }}/secret6" + mount_point: '{{ vault_kv2_mount_point }}' + + - assert: + that: + - "'result' in kv2_result" + - "'data' in kv2_result['result']" + - "'versions' in kv2_result['result']['data']" + - "kv2_result['result']['data']['versions']['1']['deletion_time'] is search(regex_secret_version_is_deleted)" + - "kv2_result['result']['data']['versions']['2']['deletion_time'] is search(regex_secret_version_not_deleted)" + - "kv2_result['result']['data']['versions']['3']['deletion_time'] is search(regex_secret_version_is_deleted)" + - "kv2_result['result']['data']['versions']['4']['deletion_time'] is search(regex_secret_version_not_deleted)" + - "kv2_result['result']['data']['versions']['5']['deletion_time'] is search(regex_secret_version_not_deleted)" + fail_msg: 'Result value did not contain expected data.' + + + - name: Try kv2 delete latest version + register: kv2_result + community.hashi_vault.vault_kv2_delete: + path: "{{ vault_kv2_versioned_path }}/secret6" + + - name: Read resultant secret versions + register: kv2_result + vault_ci_kv2_metadata_read: + path: "{{ vault_kv2_versioned_path }}/secret6" + mount_point: '{{ vault_kv2_mount_point }}' + + - assert: + that: + - "'result' in kv2_result" + - "'data' in kv2_result['result']" + - "'versions' in kv2_result['result']['data']" + - "kv2_result['result']['data']['versions']['1']['deletion_time'] is search(regex_secret_version_is_deleted)" + - "kv2_result['result']['data']['versions']['2']['deletion_time'] is search(regex_secret_version_not_deleted)" + - "kv2_result['result']['data']['versions']['3']['deletion_time'] is search(regex_secret_version_is_deleted)" + - "kv2_result['result']['data']['versions']['4']['deletion_time'] is search(regex_secret_version_not_deleted)" + - "kv2_result['result']['data']['versions']['5']['deletion_time'] is search(regex_secret_version_is_deleted)" + fail_msg: 'Result value did not contain expected data.' + + + - name: Success expected when authorized delete on non-existent path (latest version) + register: test_nonexistant + community.hashi_vault.vault_kv2_delete: + path: "{{ vault_kv2_versioned_path }}/non_existent_secret" + + + - name: Success expected when authorized delete on non-existent path (specific version) + register: test_nonexistant + community.hashi_vault.vault_kv2_delete: + path: "{{ vault_kv2_versioned_path }}/non_existent_secret" + versions: + - 1 + + + ### failure tests + + - name: Failure expected when erroneous credentials are used (latest version) + register: test_wrong_cred + community.hashi_vault.vault_kv2_delete: + path: "{{ vault_kv2_versioned_path }}/secret6" + token: wrong_token + ignore_errors: true + + - assert: + that: + - test_wrong_cred is failed + - test_wrong_cred.msg is search('Invalid Vault Token') + fail_msg: "Expected failure but got success or wrong failure message." + + + - name: Failure expected when erroneous credentials are used (specific version) + register: test_wrong_cred + community.hashi_vault.vault_kv2_delete: + path: "{{ vault_kv2_versioned_path }}/secret6" + token: wrong_token + versions: + - 1 + ignore_errors: true + + - assert: + that: + - test_wrong_cred is failed + - test_wrong_cred.msg is search('Invalid Vault Token') + fail_msg: "Expected failure but got success or wrong failure message." + + + - name: Failure expected when unauthorized secret is deleted (latest version) + register: test_unauthorized + community.hashi_vault.vault_kv2_delete: + path: "{{ vault_kv2_path }}/secret3" + ignore_errors: true + + - assert: + that: + - test_unauthorized is failed + - test_unauthorized.msg is search('Permission Denied') + fail_msg: "Expected failure but got success or wrong failure message." + + + - name: Failure expected when unauthorized secret is deleted (specific version) + register: test_unauthorized + community.hashi_vault.vault_kv2_delete: + path: "{{ vault_kv2_path }}/secret3" + versions: + - 1 + ignore_errors: true + + - assert: + that: + - test_unauthorized is failed + - test_unauthorized.msg is search('Permission Denied') + fail_msg: "Expected failure but got success or wrong failure message." diff --git a/tests/integration/targets/module_vault_kv2_get/tasks/module_vault_kv2_get_test.yml b/tests/integration/targets/module_vault_kv2_get/tasks/module_vault_kv2_get_test.yml index cdd330847..8cb4edf0f 100644 --- a/tests/integration/targets/module_vault_kv2_get/tasks/module_vault_kv2_get_test.yml +++ b/tests/integration/targets/module_vault_kv2_get/tasks/module_vault_kv2_get_test.yml @@ -7,6 +7,7 @@ url: '{{ vault_test_server_http }}' auth_method: token token: '{{ user_token }}' + token_validate: true timeout: 5 block: - name: Test defualt path value diff --git a/tests/integration/targets/module_vault_list/aliases b/tests/integration/targets/module_vault_list/aliases new file mode 100644 index 000000000..7636a9a65 --- /dev/null +++ b/tests/integration/targets/module_vault_list/aliases @@ -0,0 +1 @@ +context/target diff --git a/tests/integration/targets/module_vault_list/meta/main.yml b/tests/integration/targets/module_vault_list/meta/main.yml new file mode 100644 index 000000000..d3acb69e9 --- /dev/null +++ b/tests/integration/targets/module_vault_list/meta/main.yml @@ -0,0 +1,4 @@ +--- +dependencies: + - setup_vault_test_plugins + - setup_vault_configure diff --git a/tests/integration/targets/module_vault_list/tasks/main.yml b/tests/integration/targets/module_vault_list/tasks/main.yml new file mode 100644 index 000000000..cd7bd5d5d --- /dev/null +++ b/tests/integration/targets/module_vault_list/tasks/main.yml @@ -0,0 +1,3 @@ +--- +- import_tasks: module_vault_list_setup.yml +- import_tasks: module_vault_list_test.yml diff --git a/tests/integration/targets/module_vault_list/tasks/module_vault_list_setup.yml b/tests/integration/targets/module_vault_list/tasks/module_vault_list_setup.yml new file mode 100644 index 000000000..193d6fa5e --- /dev/null +++ b/tests/integration/targets/module_vault_list/tasks/module_vault_list_setup.yml @@ -0,0 +1,9 @@ +--- +- name: Configuration tasks + module_defaults: + vault_ci_token_create: '{{ vault_plugins_module_defaults_common }}' + block: + - name: 'Create a test non-root token' + vault_ci_token_create: + policies: test-policy + register: user_token_cmd diff --git a/tests/integration/targets/module_vault_list/tasks/module_vault_list_test.yml b/tests/integration/targets/module_vault_list/tasks/module_vault_list_test.yml new file mode 100644 index 000000000..64f40d845 --- /dev/null +++ b/tests/integration/targets/module_vault_list/tasks/module_vault_list_test.yml @@ -0,0 +1,100 @@ +--- +- name: Var block + vars: + user_token: '{{ user_token_cmd.result.auth.client_token }}' + module_defaults: + community.hashi_vault.vault_list: + url: '{{ vault_test_server_http }}' + auth_method: token + token: '{{ user_token }}' + token_validate: true + timeout: 5 + block: + - name: 'Check kv2 secret list' + register: kv2_path + community.hashi_vault.vault_list: + path: "{{ vault_kv2_api_list_path }}" + + - assert: + that: + - "'data' in kv2_path" + - "'data' in kv2_path['data']" + - "'keys' in kv2_path['data']['data']" + fail_msg: 'Return value did not contain expected fields.' + + - name: 'Check kv2 mount point list' + register: kv2_mount_point + community.hashi_vault.vault_list: + path: "{{ vault_kv2_api_list_mount_point }}" + + - assert: + that: + - "'data' in kv2_mount_point" + - "'data' in kv2_mount_point['data']" + - "'keys' in kv2_mount_point['data']['data']" + fail_msg: 'Return value did not contain expected fields.' + + ### failure tests + + - name: 'Failure expected when erroneous credentials are used' + register: test_wrong_cred + community.hashi_vault.vault_list: + path: "{{ vault_kv2_api_list_path }}" + token: wrong_token + ignore_errors: true + + - assert: + that: + - test_wrong_cred is failed + - test_wrong_cred.msg is search('Invalid Vault Token') + fail_msg: "Expected failure but got success or wrong failure message." + + - name: 'Failure expected when unauthorized path is listed' + register: test_unauthorized + community.hashi_vault.vault_list: + path: "{{ unauthorized_vault_kv2_mount_point }}" + ignore_errors: true + + - assert: + that: + - test_unauthorized is failed + - test_unauthorized.msg is search('Permission Denied') + fail_msg: "Expected failure but got success or wrong failure message." + + # When an inexistent mount point is listed, the API returns a 403 error, not 404. + - name: 'Failure expected when inexistent mount point is listed' + register: test_inexistent_mount_point + community.hashi_vault.vault_list: + path: "{{ vault_kv2_api_list_inexistent_mount_point }}" + ignore_errors: true + + - assert: + that: + - test_inexistent_mount_point is failed + - test_inexistent_mount_point.msg is search("Permission Denied") + fail_msg: "Expected failure but got success or wrong failure message." + + - name: 'Failure expected when inexistent path is listed' + register: test_inexistent + community.hashi_vault.vault_list: + path: "{{ vault_kv2_api_list_inexistent_path }}" + ignore_errors: true + + - assert: + that: + - test_inexistent is failed + - test_inexistent.msg is search("doesn't seem to exist") + fail_msg: "Expected failure but got success or wrong failure message." + + # If an inexistent path is included in a policy statement that denies access, the list API returns a 403 error. + - name: 'Failure expected when inexistent path is listed but is explicitly mentioned in a policy statement' + register: test_inexistent_unauthorized + community.hashi_vault.vault_list: + path: "{{ vault_kv2_api_list_inexistent_unauthorized_path }}" + ignore_errors: true + + - assert: + that: + - test_inexistent_unauthorized is failed + - test_inexistent_unauthorized.msg is search("Permission Denied") + fail_msg: "Expected failure but got success or wrong failure message." diff --git a/tests/integration/targets/module_vault_read/tasks/module_vault_read_test.yml b/tests/integration/targets/module_vault_read/tasks/module_vault_read_test.yml index 6d6ea30b7..55c27d435 100644 --- a/tests/integration/targets/module_vault_read/tasks/module_vault_read_test.yml +++ b/tests/integration/targets/module_vault_read/tasks/module_vault_read_test.yml @@ -7,6 +7,7 @@ url: '{{ vault_test_server_http }}' auth_method: token token: '{{ user_token }}' + token_validate: true timeout: 5 block: - name: 'Check kv2 secret read' diff --git a/tests/integration/targets/module_vault_token_create/tasks/module_vault_token_create_test.yml b/tests/integration/targets/module_vault_token_create/tasks/module_vault_token_create_test.yml index 2294aa331..67e9981ea 100644 --- a/tests/integration/targets/module_vault_token_create/tasks/module_vault_token_create_test.yml +++ b/tests/integration/targets/module_vault_token_create/tasks/module_vault_token_create_test.yml @@ -37,7 +37,7 @@ - assert: that: - orphan_result is failed - - orphan_result.msg is search('root or sudo privileges required to create orphan token') + - orphan_result.msg is search('permission denied') - name: (xfail) Create an orphan token with no_parent=true register: no_parent_result @@ -169,4 +169,10 @@ - "'client_token' in result.login.auth" - result.login.auth.client_token != 'static_token' - result.login.auth.client_token == None - - lookup('vault_test_auth', token='static_token', url=vault_test_server_http, want_exception=True) is failed + - >- + lookup('vault_test_auth', + token='static_token', + token_validate=true, + url=vault_test_server_http, + want_exception=True + ) is failed diff --git a/tests/integration/targets/module_vault_write/tasks/module_vault_write_test.yml b/tests/integration/targets/module_vault_write/tasks/module_vault_write_test.yml index 7332a412c..244b8e29a 100644 --- a/tests/integration/targets/module_vault_write/tasks/module_vault_write_test.yml +++ b/tests/integration/targets/module_vault_write/tasks/module_vault_write_test.yml @@ -9,6 +9,7 @@ community.hashi_vault.vault_write: <<: *defaults auth_method: token + token_validate: true timeout: 5 block: - name: Write data to the cubbyhole (check mode) diff --git a/tests/integration/targets/setup_localenv_docker/templates/mmock/azure_login_alt_mount.yml.j2 b/tests/integration/targets/setup_localenv_docker/templates/mmock/azure_login_alt_mount.yml.j2 new file mode 100644 index 000000000..b1588fd6e --- /dev/null +++ b/tests/integration/targets/setup_localenv_docker/templates/mmock/azure_login_alt_mount.yml.j2 @@ -0,0 +1,46 @@ +#jinja2:variable_start_string:'[%', variable_end_string:'%]' +# Copyright (c) 2022 Junrui Chen (@jchenship) +# 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 +--- +request: + method: POST|PUT + path: "/v1/auth/azure-alt/login" +control: + priority: 10 +response: + statusCode: 200 + headers: + Content-Type: + - application/json + body: >- + { + "request_id": "{{fake.UUID}}", + "lease_id": "", + "lease_duration": 0, + "renewable": false, + "data": null, + "warnings": null, + "auth": { + "client_token": "s.{{fake.CharactersN(24)}}", + "accessor": "{{fake.CharactersN(24)}}", + "policies": [ + "default", + "azure-alt-sample-policy" + ], + "token_policies": [ + "default", + "azure-alt-sample-policy" + ], + "identity_policies": null, + "metadata": { + "role": "vault-role", + "resource_group_name": "", + "subscription_id": "" + }, + "orphan": true, + "entity_id": "{{fake.UUID}}", + "lease_duration": 1800, + "renewable": true + } + } diff --git a/tests/integration/targets/setup_localenv_docker/templates/mmock/azure_login_bad_request.yml.j2 b/tests/integration/targets/setup_localenv_docker/templates/mmock/azure_login_bad_request.yml.j2 new file mode 100644 index 000000000..d447dd015 --- /dev/null +++ b/tests/integration/targets/setup_localenv_docker/templates/mmock/azure_login_bad_request.yml.j2 @@ -0,0 +1,22 @@ +#jinja2:variable_start_string:'[%', variable_end_string:'%]' +# Copyright (c) 2022 Junrui Chen (@jchenship) +# 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 +--- +request: + method: POST|PUT + path: "/v1/auth/azure*/login" + body: '*fail-me-role*' +control: + priority: 11 +response: + statusCode: 400 + headers: + Content-Type: + - application/json + body: >- + { + "errors": [ + "oidc: expected audience \"https://management.azure.com/\" got [\"https://management.azure.com\"]" + ] + } diff --git a/tests/integration/targets/setup_localenv_docker/templates/mmock/azure_login_default_mount.yml.j2 b/tests/integration/targets/setup_localenv_docker/templates/mmock/azure_login_default_mount.yml.j2 new file mode 100644 index 000000000..af26ada83 --- /dev/null +++ b/tests/integration/targets/setup_localenv_docker/templates/mmock/azure_login_default_mount.yml.j2 @@ -0,0 +1,46 @@ +#jinja2:variable_start_string:'[%', variable_end_string:'%]' +# Copyright (c) 2022 Junrui Chen (@jchenship) +# 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 +--- +request: + method: POST|PUT + path: "/v1/auth/azure/login" +control: + priority: 10 +response: + statusCode: 200 + headers: + Content-Type: + - application/json + body: >- + { + "request_id": "{{fake.UUID}}", + "lease_id": "", + "lease_duration": 0, + "renewable": false, + "data": null, + "warnings": null, + "auth": { + "client_token": "s.{{fake.CharactersN(24)}}", + "accessor": "{{fake.CharactersN(24)}}", + "policies": [ + "default", + "azure-sample-policy" + ], + "token_policies": [ + "default", + "azure-sample-policy" + ], + "identity_policies": null, + "metadata": { + "role": "vault-role", + "resource_group_name": "", + "subscription_id": "" + }, + "orphan": true, + "entity_id": "{{fake.UUID}}", + "lease_duration": 1800, + "renewable": true + } + } diff --git a/tests/integration/targets/setup_vault_configure/tasks/configure.yml b/tests/integration/targets/setup_vault_configure/tasks/configure.yml index 3d7bc5f61..5fdafe937 100644 --- a/tests/integration/targets/setup_vault_configure/tasks/configure.yml +++ b/tests/integration/targets/setup_vault_configure/tasks/configure.yml @@ -13,6 +13,13 @@ options: version: 2 +- name: 'Create KV v2 secrets engine to test unauthorized access' + vault_ci_enable_engine: + backend_type: kv + path: '{{ unauthorized_vault_kv2_mount_point }}' + options: + version: 2 + - name: Create a test policy vault_ci_policy_put: name: test-policy @@ -56,6 +63,15 @@ secret: value: 'foo{{ item }}' +- name: 'Create KV v2 secrets in unauthorized path' + loop: [1, 2, 3, 4, 5] + vault_ci_kv_put: + path: "{{ vault_kv2_path }}/secret{{ item }}" + version: 2 + mount_point: '{{ unauthorized_vault_kv2_mount_point }}' + secret: + value: 'foo{{ item }}' + - name: 'Update KV v2 secret4 with new value to create version' vault_ci_kv_put: path: "{{ vault_kv2_path }}/secret4" diff --git a/tests/integration/targets/setup_vault_configure/vars/main.yml b/tests/integration/targets/setup_vault_configure/vars/main.yml index b6d369379..900185ed8 100644 --- a/tests/integration/targets/setup_vault_configure/vars/main.yml +++ b/tests/integration/targets/setup_vault_configure/vars/main.yml @@ -8,6 +8,7 @@ vault_kv1_path: testproject vault_kv1_api_path: '{{ vault_kv1_mount_point }}/{{ vault_kv1_path }}' vault_kv2_mount_point: kv2 +unauthorized_vault_kv2_mount_point: kv2_noauth vault_kv2_path: testproject vault_kv2_multi_path: testmulti @@ -16,6 +17,17 @@ vault_kv2_versioned_path: versioned vault_kv2_api_path: '{{ vault_kv2_mount_point }}/data/{{ vault_kv2_path }}' vault_kv2_multi_api_path: '{{ vault_kv2_mount_point }}/data/{{ vault_kv2_multi_path }}' vault_kv2_versioned_api_path: '{{ vault_kv2_mount_point }}/data/{{ vault_kv2_versioned_path }}' +vault_kv2_delete_api_path: '{{ vault_kv2_mount_point }}/delete/{{ vault_kv2_versioned_path }}' +vault_kv2_metadata_api_path: '{{ vault_kv2_mount_point }}/metadata/{{ vault_kv2_versioned_path }}' +vault_kv2_api_list_mount_point: '{{ vault_kv2_mount_point }}/metadata' +vault_kv2_api_list_path: '{{ vault_kv2_mount_point }}/metadata/{{ vault_kv2_path }}' + +vault_policy_api_list_path: 'sys/policies/acl' + +vault_kv2_api_list_inexistent_path: '{{ vault_kv2_mount_point }}/metadata/__inexistent' +vault_kv2_api_list_inexistent_mount_point: '{{ vault_kv2_mount_point }}__inexistent/metadata' +vault_kv2_api_list_inexistent_unauthorized_path: '{{ vault_kv2_mount_point }}/metadata/__inexistent_no_auth' +vault_kv2_api_list_unauthorized_path: '{{ unauthorized_vault_kv2_mount_point }}/metadata' vault_base_policy: | path "{{ vault_kv1_api_path }}/secret1" { @@ -52,6 +64,36 @@ vault_base_policy: | path "{{ vault_kv2_versioned_api_path }}/*" { capabilities = ["read"] } + path "{{ vault_kv2_versioned_api_path }}/secret6" { + capabilities = ["delete"] + } + path "{{ vault_kv2_versioned_api_path }}/non_existent_secret" { + capabilities = ["delete"] + } + path "{{ vault_kv2_delete_api_path }}/secret6" { + capabilities = ["create", "update"] + } + path "{{ vault_kv2_delete_api_path }}/non_existent_secret" { + capabilities = ["create", "update"] + } + path "{{ vault_kv2_metadata_api_path }}/secret6" { + capabilities = ["read"] + } + path "{{ vault_kv2_api_list_mount_point }}/*" { + capabilities = ["list"] + } + path "{{ vault_kv2_api_list_path }}" { + capabilities = ["list"] + } + path "{{ vault_policy_api_list_path }}" { + capabilities = ["list"] + } + path "{{ vault_kv2_api_list_inexistent_unauthorized_path }}" { + capabilities = ["deny"] + } + path "{{ vault_kv2_api_list_unauthorized_path }}" { + capabilities = ["deny"] + } vault_token_creator_policy: | path "auth/token/create" { @@ -60,9 +102,6 @@ vault_token_creator_policy: | path "auth/token/create/*" { capabilities = ["create", "update"] } - path "auth/token/create-orphan" { - capabilities = ["create", "update"] - } vault_orphan_creator_policy: | path "auth/token/create" { diff --git a/tests/integration/targets/setup_vault_test_plugins/library/vault_ci_kv2_metadata_read.py b/tests/integration/targets/setup_vault_test_plugins/library/vault_ci_kv2_metadata_read.py new file mode 100644 index 000000000..0532124ed --- /dev/null +++ b/tests/integration/targets/setup_vault_test_plugins/library/vault_ci_kv2_metadata_read.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2022 Isaac Wagner (@idwagner) +# Simplified BSD License (see LICENSES/BSD-2-Clause.txt or https://opensource.org/licenses/BSD-2-Clause) +# SPDX-License-Identifier: BSD-2-Clause + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +import traceback + +from ansible.module_utils.basic import AnsibleModule +import hvac + + +def main(): + module = AnsibleModule( + argument_spec=dict( + url=dict(type='str', required=True), + token=dict(type='str', required=True), + path=dict(type='str'), + mount_point=dict(type='str'), + ), + ) + + p = module.params + + client = hvac.Client(url=p['url'], token=p['token']) + + extra = {} + if p['mount_point'] is not None: + extra['mount_point'] = p['mount_point'] + + try: + result = client.secrets.kv.v2.read_secret_metadata(path=p['path'], **extra) + except Exception as e: + module.fail_json(msg=str(e), exception=traceback.format_exc()) + + module.exit_json(changed=True, result=result) + + +if __name__ == '__main__': + main() diff --git a/tests/integration/targets/setup_vault_test_plugins/vars/main.yml b/tests/integration/targets/setup_vault_test_plugins/vars/main.yml index 010e89505..3f891566e 100644 --- a/tests/integration/targets/setup_vault_test_plugins/vars/main.yml +++ b/tests/integration/targets/setup_vault_test_plugins/vars/main.yml @@ -11,6 +11,7 @@ vault_plugins_module_defaults: vault_ci_enable_engine: '{{ vault_plugins_module_defaults_common }}' vault_ci_kv_put: '{{ vault_plugins_module_defaults_common }}' vault_ci_kv2_destroy_all: '{{ vault_plugins_module_defaults_common }}' + vault_ci_kv2_metadata_read: '{{ vault_plugins_module_defaults_common }}' vault_ci_policy_put: '{{ vault_plugins_module_defaults_common }}' vault_ci_read: '{{ vault_plugins_module_defaults_common }}' vault_ci_token_create: '{{ vault_plugins_module_defaults_common }}' diff --git a/tests/unit/compat/builtins.py b/tests/unit/compat/builtins.py index f60ee6782..349d310e8 100644 --- a/tests/unit/compat/builtins.py +++ b/tests/unit/compat/builtins.py @@ -26,7 +26,7 @@ # One unittest needs to import builtins via __import__() so we need to have # the string that represents it try: - import __builtin__ + import __builtin__ # pylint: disable=unused-import except ImportError: BUILTINS = 'builtins' else: diff --git a/tests/unit/compat/mock.py b/tests/unit/compat/mock.py index 0972cd2e8..c98b26312 100644 --- a/tests/unit/compat/mock.py +++ b/tests/unit/compat/mock.py @@ -1,19 +1,6 @@ -# (c) 2014, Toshio Kuratomi -# -# 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 . +# Copyright (c) 2014, Toshio Kuratomi +# 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 # Make coding more python3-ish from __future__ import (absolute_import, division, print_function) @@ -22,8 +9,6 @@ ''' Compat module for Python3.x's unittest.mock module ''' -import sys - # Python 2.7 # Note: Could use the pypi mock library on python3.x as well as python2.x. It @@ -41,82 +26,3 @@ from mock import * except ImportError: print('You need the mock library installed on python2.x to run tests') - - -# Prior to 3.4.4, mock_open cannot handle binary read_data -if sys.version_info >= (3,) and sys.version_info < (3, 4, 4): - file_spec = None - - def _iterate_read_data(read_data): - # Helper for mock_open: - # Retrieve lines from read_data via a generator so that separate calls to - # readline, read, and readlines are properly interleaved - sep = b'\n' if isinstance(read_data, bytes) else '\n' - data_as_list = [l + sep for l in read_data.split(sep)] - - if data_as_list[-1] == sep: - # If the last line ended in a newline, the list comprehension will have an - # extra entry that's just a newline. Remove this. - data_as_list = data_as_list[:-1] - else: - # If there wasn't an extra newline by itself, then the file being - # emulated doesn't have a newline to end the last line remove the - # newline that our naive format() added - data_as_list[-1] = data_as_list[-1][:-1] - - for line in data_as_list: - yield line - - def mock_open(mock=None, read_data=''): - """ - A helper function to create a mock to replace the use of `open`. It works - for `open` called directly or used as a context manager. - - The `mock` argument is the mock object to configure. If `None` (the - default) then a `MagicMock` will be created for you, with the API limited - to methods or attributes available on standard file handles. - - `read_data` is a string for the `read` methoddline`, and `readlines` of the - file handle to return. This is an empty string by default. - """ - def _readlines_side_effect(*args, **kwargs): - if handle.readlines.return_value is not None: - return handle.readlines.return_value - return list(_data) - - def _read_side_effect(*args, **kwargs): - if handle.read.return_value is not None: - return handle.read.return_value - return type(read_data)().join(_data) - - def _readline_side_effect(): - if handle.readline.return_value is not None: - while True: - yield handle.readline.return_value - for line in _data: - yield line - - global file_spec - if file_spec is None: - import _io - file_spec = list(set(dir(_io.TextIOWrapper)).union(set(dir(_io.BytesIO)))) - - if mock is None: - mock = MagicMock(name='open', spec=open) - - handle = MagicMock(spec=file_spec) - handle.__enter__.return_value = handle - - _data = _iterate_read_data(read_data) - - handle.write.return_value = None - handle.read.return_value = None - handle.readline.return_value = None - handle.readlines.return_value = None - - handle.read.side_effect = _read_side_effect - handle.readline.side_effect = _readline_side_effect() - handle.readlines.side_effect = _readlines_side_effect - - mock.return_value = handle - return mock diff --git a/tests/unit/fixtures/azure_login_response.json b/tests/unit/fixtures/azure_login_response.json new file mode 100644 index 000000000..f1d1302e5 --- /dev/null +++ b/tests/unit/fixtures/azure_login_response.json @@ -0,0 +1,33 @@ +{ + "request_id": "cbfb16b9-4cf6-917d-182b-170801fc5a4e", + "lease_id": "", + "renewable": false, + "lease_duration": 0, + "data": null, + "wrap_info": null, + "warnings": null, + "auth": { + "client_token": "hvs.CAESIH6iy4yyvKMpk-vcaaVvU8nGfZFRCcH92hVa24lGNxHNGh4KHGh2cy5qU29Ua1FscTJIQ3BBY1AwTDM4dzNpR0E", + "accessor": "60U0DvUOIMOIGI7kzAneeD2x", + "policies": [ + "default", + "azure-sample-policy" + ], + "token_policies": [ + "default", + "azure-sample-policy" + ], + "metadata": { + "resource_group_name": "", + "role": "msi-vault", + "subscription_id": "" + }, + "lease_duration": 2764800, + "renewable": true, + "entity_id": "ff6a9d66-c2eb-6b78-e463-b3192243b5c1", + "token_type": "service", + "orphan": true, + "mfa_requirement": null, + "num_uses": 0 + } +} diff --git a/tests/unit/fixtures/kubernetes_login_response.json b/tests/unit/fixtures/kubernetes_login_response.json new file mode 100644 index 000000000..1d8198ab8 --- /dev/null +++ b/tests/unit/fixtures/kubernetes_login_response.json @@ -0,0 +1,16 @@ +{ + "auth": { + "client_token": "38fe9691-e623-7238-f618-c94d4e7bc674", + "accessor": "78e87a38-84ed-2692-538f-ca8b9f400ab3", + "policies": ["default"], + "metadata": { + "role": "demo", + "service_account_name": "myapp", + "service_account_namespace": "default", + "service_account_secret_name": "myapp-token-pd21c", + "service_account_uid": "aa9aa8ff-98d0-11e7-9bb7-0800276d99bf" + }, + "lease_duration": 2764800, + "renewable": true + } +} diff --git a/tests/unit/fixtures/kv2_list_response.json b/tests/unit/fixtures/kv2_list_response.json new file mode 100644 index 000000000..2fe833b51 --- /dev/null +++ b/tests/unit/fixtures/kv2_list_response.json @@ -0,0 +1,15 @@ +{ + "auth": null, + "data": { + "keys": [ + "Secret1", + "Secret2" + ] + }, + "lease_duration": 0, + "lease_id": "", + "renewable": false, + "request_id": "02e4b52a-23b1-9a1c-cf2b-3799edb17fed", + "warnings": null, + "wrap_info": null +} diff --git a/tests/unit/fixtures/policy_list_response.json b/tests/unit/fixtures/policy_list_response.json new file mode 100644 index 000000000..5a7dfdb8d --- /dev/null +++ b/tests/unit/fixtures/policy_list_response.json @@ -0,0 +1,15 @@ +{ + "auth": null, + "data": { + "keys": [ + "Policy1", + "Policy2" + ] + }, + "lease_duration": 0, + "lease_id": "", + "renewable": false, + "request_id": "96f2857e-5e33-1957-ea7e-be58f483faa3", + "warnings": null, + "wrap_info": null +} diff --git a/tests/unit/fixtures/userpass_list_response.json b/tests/unit/fixtures/userpass_list_response.json new file mode 100644 index 000000000..84cabf3bb --- /dev/null +++ b/tests/unit/fixtures/userpass_list_response.json @@ -0,0 +1,15 @@ +{ + "auth": null, + "data": { + "keys": [ + "User1", + "User2" + ] + }, + "lease_duration": 0, + "lease_id": "", + "renewable": false, + "request_id": "8b18a5ca-9baf-eb7c-18a6-11be81ed95a6", + "warnings": null, + "wrap_info": null +} diff --git a/tests/unit/plugins/lookup/conftest.py b/tests/unit/plugins/lookup/conftest.py index 244cb4c5e..ba4da9291 100644 --- a/tests/unit/plugins/lookup/conftest.py +++ b/tests/unit/plugins/lookup/conftest.py @@ -7,8 +7,6 @@ import pytest -from ...compat import mock - @pytest.fixture def minimal_vars(): diff --git a/tests/unit/plugins/lookup/test_vault_list.py b/tests/unit/plugins/lookup/test_vault_list.py new file mode 100644 index 000000000..dddb4a381 --- /dev/null +++ b/tests/unit/plugins/lookup/test_vault_list.py @@ -0,0 +1,94 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2023 Tom Kivlin (@tomkivlin) +# 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 + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import pytest + +from ansible.plugins.loader import lookup_loader +from ansible.errors import AnsibleError + +from ...compat import mock + +from .....plugins.plugin_utils._hashi_vault_lookup_base import HashiVaultLookupBase +from .....plugins.module_utils._hashi_vault_common import HashiVaultValueError + +from .....plugins.lookup import vault_list + + +hvac = pytest.importorskip('hvac') + + +pytestmark = pytest.mark.usefixtures( + 'patch_authenticator', + 'patch_get_vault_client', +) + + +@pytest.fixture +def vault_list_lookup(): + return lookup_loader.get('community.hashi_vault.vault_list') + + +LIST_FIXTURES = [ + 'kv2_list_response.json', + 'policy_list_response.json', + 'userpass_list_response.json', +] + + +@pytest.fixture(params=LIST_FIXTURES) +def list_response(request, fixture_loader): + return fixture_loader(request.param) + + +class TestVaultListLookup(object): + + def test_vault_list_is_lookup_base(self, vault_list_lookup): + assert issubclass(type(vault_list_lookup), HashiVaultLookupBase) + + def test_vault_list_no_hvac(self, vault_list_lookup, minimal_vars): + with mock.patch.object(vault_list, 'HVAC_IMPORT_ERROR', new=ImportError()): + with pytest.raises(AnsibleError, match=r"This plugin requires the 'hvac' Python library"): + vault_list_lookup.run(terms='fake', variables=minimal_vars) + + @pytest.mark.parametrize('exc', [HashiVaultValueError('throwaway msg'), NotImplementedError('throwaway msg')]) + def test_vault_list_authentication_error(self, vault_list_lookup, minimal_vars, authenticator, exc): + authenticator.authenticate.side_effect = exc + + with pytest.raises(AnsibleError, match=r'throwaway msg'): + vault_list_lookup.run(terms='fake', variables=minimal_vars) + + @pytest.mark.parametrize('exc', [HashiVaultValueError('throwaway msg'), NotImplementedError('throwaway msg')]) + def test_vault_list_auth_validation_error(self, vault_list_lookup, minimal_vars, authenticator, exc): + authenticator.validate.side_effect = exc + + with pytest.raises(AnsibleError, match=r'throwaway msg'): + vault_list_lookup.run(terms='fake', variables=minimal_vars) + + @pytest.mark.parametrize('paths', [['fake1'], ['fake2', 'fake3']]) + def test_vault_list_return_data(self, vault_list_lookup, minimal_vars, list_response, vault_client, paths): + client = vault_client + + expected_calls = [mock.call(p) for p in paths] + + def _fake_list_operation(path): + r = list_response.copy() + r.update({'_path': path}) + return r + + client.list = mock.Mock(wraps=_fake_list_operation) + + response = vault_list_lookup.run(terms=paths, variables=minimal_vars) + + client.list.assert_has_calls(expected_calls) + + assert len(response) == len(paths), "%i paths processed but got %i responses" % (len(paths), len(response)) + + for p in paths: + r = response.pop(0) + ins_p = r.pop('_path') + assert p == ins_p, "expected '_path=%s' field was not found in response, got %r" % (p, ins_p) diff --git a/tests/unit/plugins/lookup/test_vault_token_create.py b/tests/unit/plugins/lookup/test_vault_token_create.py index 0407a475b..05a74f8b4 100644 --- a/tests/unit/plugins/lookup/test_vault_token_create.py +++ b/tests/unit/plugins/lookup/test_vault_token_create.py @@ -17,6 +17,7 @@ from ansible_collections.community.hashi_vault.plugins.plugin_utils._hashi_vault_lookup_base import HashiVaultLookupBase from .....plugins.lookup import vault_token_create +from .....plugins.module_utils._hashi_vault_common import HashiVaultValueError pytest.importorskip('hvac') @@ -55,7 +56,7 @@ def pass_thru_options(): @pytest.fixture -def legacy_option_translation(): +def orphan_option_translation(): return { 'id': 'token_id', 'role_name': 'role', @@ -78,6 +79,20 @@ def test_vault_token_create_no_hvac(self, vault_token_create_lookup, minimal_var with pytest.raises(AnsibleError, match=r"This plugin requires the 'hvac' Python library"): vault_token_create_lookup.run(terms='fake', variables=minimal_vars) + @pytest.mark.parametrize('exc', [HashiVaultValueError('throwaway msg'), NotImplementedError('throwaway msg')]) + def test_vault_token_create_authentication_error(self, vault_token_create_lookup, minimal_vars, authenticator, exc): + authenticator.authenticate.side_effect = exc + + with pytest.raises(AnsibleError, match=r'throwaway msg'): + vault_token_create_lookup.run(terms='fake', variables=minimal_vars) + + @pytest.mark.parametrize('exc', [HashiVaultValueError('throwaway msg'), NotImplementedError('throwaway msg')]) + def test_vault_token_create_auth_validation_error(self, vault_token_create_lookup, minimal_vars, authenticator, exc): + authenticator.validate.side_effect = exc + + with pytest.raises(AnsibleError, match=r'throwaway msg'): + vault_token_create_lookup.run(terms='fake', variables=minimal_vars) + def test_vault_token_create_extra_terms(self, vault_token_create_lookup, authenticator, minimal_vars): with mock.patch('ansible_collections.community.hashi_vault.plugins.lookup.vault_token_create.display.warning') as warning: with mock.patch.object(vault_token_create_lookup, 'authenticator', new=authenticator): @@ -97,24 +112,24 @@ def test_vault_token_create_passthru_options_expected(self, vault_token_create_l ) ) - def test_vault_token_create_legacy_options_expected(self, vault_token_create_lookup, legacy_option_translation, pass_thru_options): - # designed to catch the case where new legacy translations differ between tests and lookup + def test_vault_token_create_orphan_options_expected(self, vault_token_create_lookup, orphan_option_translation, pass_thru_options): + # designed to catch the case where new orphan translations differ between tests and lookup # and that all listed translations are present in passthru options - lookup_set = set(vault_token_create_lookup.LEGACY_OPTION_TRANSLATION.items()) - test_set = set(legacy_option_translation.items()) + lookup_set = set(vault_token_create_lookup.ORPHAN_OPTION_TRANSLATION.items()) + test_set = set(orphan_option_translation.items()) - lookup_key_set = set(vault_token_create_lookup.LEGACY_OPTION_TRANSLATION.keys()) + lookup_key_set = set(vault_token_create_lookup.ORPHAN_OPTION_TRANSLATION.keys()) pass_thru_key_set = set(pass_thru_options.keys()) assert lookup_set == test_set, ( - "Legacy options in lookup do not match legacy options in test:\nlookup: %r\ntest: %r" % ( + "Orphan options in lookup do not match orphan options in test:\nlookup: %r\ntest: %r" % ( dict(lookup_set - test_set), dict(test_set - lookup_set), ) ) - assert vault_token_create_lookup.LEGACY_OPTION_TRANSLATION.keys() <= pass_thru_options.keys(), ( - "Legacy option translation keys must exist in passthru options: %r" % ( + assert vault_token_create_lookup.ORPHAN_OPTION_TRANSLATION.keys() <= pass_thru_options.keys(), ( + "Orphan option translation keys must exist in passthru options: %r" % ( list(lookup_key_set - pass_thru_key_set), ) ) @@ -141,19 +156,20 @@ def test_vault_token_create_passthru_options(self, vault_token_create_lookup, au else: assert pass_thru_options.items() <= client.auth.token.create.call_args.kwargs.items() - def test_vault_token_create_legacy_options( - self, vault_token_create_lookup, authenticator, minimal_vars, pass_thru_options, legacy_option_translation, token_create_response + def test_vault_token_create_orphan_options( + self, vault_token_create_lookup, authenticator, minimal_vars, pass_thru_options, orphan_option_translation, token_create_response ): client = mock.MagicMock() - client.create_token.return_value = token_create_response + client.auth.token.create_orphan.return_value = token_create_response with mock.patch.object(vault_token_create_lookup, 'authenticator', new=authenticator): with mock.patch.object(vault_token_create_lookup.helper, 'get_vault_client', return_value=client): result = vault_token_create_lookup.run(terms=[], variables=minimal_vars, orphan=True, **pass_thru_options) client.auth.token.create.assert_not_called() - client.create_token.assert_called_once() + client.auth.token.create_orphan.assert_called_once() + client.create_token.assert_not_called() assert result[0] == token_create_response, ( "lookup result did not match expected result:\nlookup: %r\nexpected: %r" % (result, token_create_response) @@ -161,46 +177,65 @@ def test_vault_token_create_legacy_options( if sys.version_info < (3, 8): # TODO: remove when python < 3.8 is dropped - call_kwargs = client.create_token.call_args[1] + call_kwargs = client.auth.token.create_orphan.call_args[1] else: - call_kwargs = client.create_token.call_args.kwargs + call_kwargs = client.auth.token.create_orphan.call_args.kwargs - for name, legacy in legacy_option_translation.items(): + for name, orphan in orphan_option_translation.items(): assert name not in call_kwargs, ( - "'%s' was found in call to legacy method, should be '%s'" % (name, legacy) + "'%s' was found in call to orphan method, should be '%s'" % (name, orphan) ) - assert legacy in call_kwargs, ( - "'%s' (from '%s') was not found in call to legacy method" % (legacy, name) + assert orphan in call_kwargs, ( + "'%s' (from '%s') was not found in call to orphan method" % (orphan, name) ) - assert call_kwargs[legacy] == pass_thru_options.get(name), ( - "Expected legacy param '%s' not found or value did not match:\nvalue: %r\nexpected: %r" % ( - legacy, - call_kwargs.get(legacy), + assert call_kwargs[orphan] == pass_thru_options.get(name), ( + "Expected orphan param '%s' not found or value did not match:\nvalue: %r\nexpected: %r" % ( + orphan, + call_kwargs.get(orphan), pass_thru_options.get(name), ) ) - def test_vault_token_create_legacy_fallback(self, vault_token_create_lookup, authenticator, minimal_vars, pass_thru_options, token_create_response): + def test_vault_token_create_orphan_fallback(self, vault_token_create_lookup, authenticator, minimal_vars, pass_thru_options, token_create_response): client = mock.MagicMock() - client.create_token.side_effect = AttributeError - client.auth.token.create.return_value = token_create_response + client.create_token.return_value = token_create_response + client.auth.token.create_orphan.side_effect = AttributeError - with mock.patch('ansible_collections.community.hashi_vault.plugins.lookup.vault_token_create.display.warning') as warning: - with mock.patch.object(vault_token_create_lookup, 'authenticator', new=authenticator): - with mock.patch.object(vault_token_create_lookup.helper, 'get_vault_client', return_value=client): - result = vault_token_create_lookup.run(terms=[], variables=minimal_vars, orphan=True, **pass_thru_options) + with mock.patch.object(vault_token_create_lookup, 'authenticator', new=authenticator): + with mock.patch.object(vault_token_create_lookup.helper, 'get_vault_client', return_value=client): + result = vault_token_create_lookup.run(terms=[], variables=minimal_vars, orphan=True, **pass_thru_options) - warning.assert_called_once_with("'create_token' method was not found. Attempting method that requires root privileges.") - client.auth.token.create.assert_called_once() + client.auth.token.create_orphan.assert_called_once() + client.create_token.assert_called_once() - assert result[0] == token_create_response, ( - "lookup result did not match expected result:\nlookup: %r\nexpected: %r" % (result, token_create_response) - ) + assert result[0] == token_create_response, ( + "lookup result did not match expected result:\nlookup: %r\nexpected: %r" % (result, token_create_response) + ) + + def test_vault_token_create_exception_handling_standard(self, vault_token_create_lookup, authenticator, minimal_vars, pass_thru_options): + client = mock.MagicMock() + client.auth.token.create.side_effect = Exception('side_effect') + + with mock.patch.object(vault_token_create_lookup, 'authenticator', new=authenticator): + with mock.patch.object(vault_token_create_lookup.helper, 'get_vault_client', return_value=client): + with pytest.raises(AnsibleError, match=r'^side_effect$'): + vault_token_create_lookup.run(terms=[], variables=minimal_vars, **pass_thru_options) + + def test_vault_token_create_exception_handling_orphan(self, vault_token_create_lookup, authenticator, minimal_vars, pass_thru_options): + client = mock.MagicMock() + client.auth.token.create_orphan.side_effect = Exception('side_effect') + + with mock.patch.object(vault_token_create_lookup, 'authenticator', new=authenticator): + with mock.patch.object(vault_token_create_lookup.helper, 'get_vault_client', return_value=client): + with pytest.raises(AnsibleError, match=r'^side_effect$'): + vault_token_create_lookup.run(terms=[], variables=minimal_vars, orphan=True, **pass_thru_options) - # we're retesting that expected options were passed, even though there's a separate test for that, - # to ensure that nothing in the original legacy attempt mutates the non-legacy options during fallback - if sys.version_info < (3, 8): - # TODO: remove when python < 3.8 is dropped - assert pass_thru_options.items() <= client.auth.token.create.call_args[1].items() - else: - assert pass_thru_options.items() <= client.auth.token.create.call_args.kwargs.items() + def test_vault_token_create_exception_handling_orphan_fallback(self, vault_token_create_lookup, authenticator, minimal_vars, pass_thru_options): + client = mock.MagicMock() + client.create_token.side_effect = Exception('side_effect') + client.auth.token.create_orphan.side_effect = AttributeError + + with mock.patch.object(vault_token_create_lookup, 'authenticator', new=authenticator): + with mock.patch.object(vault_token_create_lookup.helper, 'get_vault_client', return_value=client): + with pytest.raises(AnsibleError, match=r'^side_effect$'): + vault_token_create_lookup.run(terms=[], variables=minimal_vars, orphan=True, **pass_thru_options) diff --git a/tests/unit/plugins/module_utils/authentication/test_auth_azure.py b/tests/unit/plugins/module_utils/authentication/test_auth_azure.py new file mode 100644 index 000000000..747a432df --- /dev/null +++ b/tests/unit/plugins/module_utils/authentication/test_auth_azure.py @@ -0,0 +1,224 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2022 Junrui Chen (@jchenship) +# 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 + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +import pytest + +from ansible_collections.community.hashi_vault.tests.unit.compat import mock + +from ansible_collections.community.hashi_vault.plugins.module_utils._auth_method_azure import ( + HashiVaultAuthMethodAzure, +) + +from ansible_collections.community.hashi_vault.plugins.module_utils._hashi_vault_common import ( + HashiVaultAuthMethodBase, + HashiVaultValueError, +) + + +@pytest.fixture +def option_dict(): + return { + 'auth_method': 'azure', + 'role_id': 'vault-role', + 'mount_point': None, + 'jwt': None, + 'azure_tenant_id': None, + 'azure_client_id': None, + 'azure_client_secret': None, + 'azure_resource': 'https://management.azure.com/', + } + + +@pytest.fixture +def azure_client_id(): + return 'client-id' + + +@pytest.fixture +def azure_client_secret(): + return 'client-secret' + + +@pytest.fixture +def jwt(): + return 'jwt-token' + + +@pytest.fixture +def auth_azure(adapter, warner, deprecator): + return HashiVaultAuthMethodAzure(adapter, warner, deprecator) + + +@pytest.fixture +def azure_login_response(fixture_loader): + return fixture_loader('azure_login_response.json') + + +class TestAuthAzure(object): + def test_auth_azure_is_auth_method_base(self, auth_azure): + assert isinstance(auth_azure, HashiVaultAuthMethodAzure) + assert issubclass(HashiVaultAuthMethodAzure, HashiVaultAuthMethodBase) + + def test_auth_azure_validate_role_id(self, auth_azure, adapter): + adapter.set_options(role_id=None) + with pytest.raises(HashiVaultValueError, match=r'^role_id is required for azure authentication\.$'): + auth_azure.validate() + + @pytest.mark.parametrize('mount_point', [None, 'other'], ids=lambda x: 'mount_point=%s' % x) + @pytest.mark.parametrize('role_id', ['role1', 'role2'], ids=lambda x: 'role_id=%s' % x) + @pytest.mark.parametrize('jwt', ['jwt1', 'jwt2'], ids=lambda x: 'jwt=%s' % x) + def test_auth_azure_validate_use_jwt( + self, auth_azure, adapter, role_id, mount_point, jwt + ): + adapter.set_options( + role_id=role_id, + mount_point=mount_point, + jwt=jwt, + ) + + auth_azure.validate() + + params = auth_azure._auth_azure_login_params + + assert (mount_point is None and 'mount_point' not in params) or params['mount_point'] == mount_point + assert params['role'] == role_id + assert params['jwt'] == jwt + + @pytest.mark.parametrize('mount_point', [None, 'other'], ids=lambda x: 'mount_point=%s' % x) + @pytest.mark.parametrize('use_token', [True, False], ids=lambda x: 'use_token=%s' % x) + def test_auth_azure_authenticate_use_jwt( + self, + auth_azure, + client, + adapter, + mount_point, + jwt, + use_token, + azure_login_response, + ): + adapter.set_options( + mount_point=mount_point, + jwt=jwt, + ) + + auth_azure.validate() + + params = auth_azure._auth_azure_login_params.copy() + + with mock.patch.object( + client.auth.azure, 'login', return_value=azure_login_response + ) as azure_login: + response = auth_azure.authenticate(client, use_token=use_token) + azure_login.assert_called_once_with(use_token=use_token, **params) + + assert ( + response['auth']['client_token'] + == azure_login_response['auth']['client_token'] + ) + + def test_auth_azure_validate_use_identity_no_azure_identity_lib( + self, auth_azure, mock_import_error, adapter + ): + adapter.set_options() + with mock_import_error('azure.identity'): + with pytest.raises( + HashiVaultValueError, match=r'azure-identity is required' + ): + auth_azure.validate() + + @pytest.mark.parametrize('azure_tenant_id', ['tenant1', 'tenant2'], ids=lambda x: 'azure_tenant_id=%s' % x) + @pytest.mark.parametrize('azure_client_id', ['client1', 'client2'], ids=lambda x: 'azure_client_id=%s' % x) + @pytest.mark.parametrize('azure_client_secret', ['secret1', 'secret2'], ids=lambda x: 'azure_client_secret=%s' % x) + @pytest.mark.parametrize('jwt', ['jwt1', 'jwt2'], ids=lambda x: 'jwt=%s' % x) + def test_auth_azure_validate_use_service_principal( + self, + auth_azure, + adapter, + jwt, + azure_tenant_id, + azure_client_id, + azure_client_secret, + ): + adapter.set_options( + azure_tenant_id=azure_tenant_id, + azure_client_id=azure_client_id, + azure_client_secret=azure_client_secret, + ) + + with mock.patch( + 'azure.identity.ClientSecretCredential' + ) as mocked_credential_class: + credential = mocked_credential_class.return_value + credential.get_token.return_value.token = jwt + auth_azure.validate() + + assert mocked_credential_class.called_once_with( + azure_tenant_id, azure_client_id, azure_client_secret + ) + assert credential.get_token.called_once_with( + 'https://management.azure.com//.default' + ) + + params = auth_azure._auth_azure_login_params + assert params['jwt'] == jwt + + def test_auth_azure_validate_use_service_principal_no_tenant_id( + self, auth_azure, adapter, azure_client_id, azure_client_secret + ): + adapter.set_options( + azure_client_id=azure_client_id, + azure_client_secret=azure_client_secret, + ) + + with pytest.raises(HashiVaultValueError, match='azure_tenant_id is required'): + auth_azure.validate() + + @pytest.mark.parametrize('azure_client_id', ['client1', 'client2'], ids=lambda x: 'azure_client_id=%s' % x) + @pytest.mark.parametrize('jwt', ['jwt1', 'jwt2'], ids=lambda x: 'jwt=%s' % x) + def test_auth_azure_validate_use_user_managed_identity( + self, auth_azure, adapter, jwt, azure_client_id + ): + adapter.set_options( + azure_client_id=azure_client_id, + ) + + with mock.patch( + 'azure.identity.ManagedIdentityCredential' + ) as mocked_credential_class: + credential = mocked_credential_class.return_value + credential.get_token.return_value.token = jwt + auth_azure.validate() + + assert mocked_credential_class.called_once_with(azure_client_id) + assert credential.get_token.called_once_with( + 'https://management.azure.com//.default' + ) + + params = auth_azure._auth_azure_login_params + assert params['jwt'] == jwt + + @pytest.mark.parametrize('jwt', ['jwt1', 'jwt2'], ids=lambda x: 'jwt=%s' % x) + def test_auth_azure_validate_use_system_managed_identity( + self, auth_azure, adapter, jwt + ): + adapter.set_options() + + with mock.patch( + 'azure.identity.ManagedIdentityCredential' + ) as mocked_credential_class: + credential = mocked_credential_class.return_value + credential.get_token.return_value.token = jwt + auth_azure.validate() + + assert mocked_credential_class.called_once_with() + assert credential.get_token.called_once_with( + 'https://management.azure.com//.default' + ) + + params = auth_azure._auth_azure_login_params + assert params['jwt'] == jwt diff --git a/tests/unit/plugins/module_utils/authentication/test_auth_none.py b/tests/unit/plugins/module_utils/authentication/test_auth_none.py index 87cfe91fb..1e3024f11 100644 --- a/tests/unit/plugins/module_utils/authentication/test_auth_none.py +++ b/tests/unit/plugins/module_utils/authentication/test_auth_none.py @@ -8,11 +8,8 @@ import pytest -from ansible_collections.community.hashi_vault.tests.unit.compat import mock - -from ansible_collections.community.hashi_vault.plugins.module_utils._auth_method_none import HashiVaultAuthMethodNone - -from ansible_collections.community.hashi_vault.plugins.module_utils._hashi_vault_common import HashiVaultAuthMethodBase +from ......plugins.module_utils._auth_method_none import HashiVaultAuthMethodNone +from ......plugins.module_utils._hashi_vault_common import HashiVaultAuthMethodBase @pytest.fixture diff --git a/tests/unit/plugins/module_utils/authentication/test_auth_token.py b/tests/unit/plugins/module_utils/authentication/test_auth_token.py index 1d8d3a708..d8a13435b 100644 --- a/tests/unit/plugins/module_utils/authentication/test_auth_token.py +++ b/tests/unit/plugins/module_utils/authentication/test_auth_token.py @@ -4,14 +4,12 @@ # SPDX-License-Identifier: GPL-3.0-or-later from __future__ import (absolute_import, division, print_function) -from _pytest.fixtures import fixture -from _pytest.python_api import raises __metaclass__ = type import os import pytest -from ansible_collections.community.hashi_vault.tests.unit.compat import mock +from ......tests.unit.compat import mock try: import hvac @@ -19,11 +17,11 @@ # python 2.6, which isn't supported anyway hvac = mock.MagicMock() -from ansible_collections.community.hashi_vault.plugins.module_utils._auth_method_token import ( +from ......plugins.module_utils._auth_method_token import ( HashiVaultAuthMethodToken, ) -from ansible_collections.community.hashi_vault.plugins.module_utils._hashi_vault_common import ( +from ......plugins.module_utils._hashi_vault_common import ( HashiVaultAuthMethodBase, HashiVaultValueError, ) @@ -175,3 +173,14 @@ def test_auth_token_authenticate_failed_validation(self, auth_token, adapter, cl with pytest.raises(HashiVaultValueError, match=r'Invalid Vault Token Specified'): with mock.patch.object(client.auth.token, 'lookup_self', raiser): auth_token.authenticate(client, use_token=True, lookup_self=False) + + @pytest.mark.parametrize('exc', [AttributeError, NotImplementedError]) + def test_auth_token_authenticate_old_lookup_self(self, auth_token, adapter, client, token, exc): + adapter.set_option('token', token) + + with mock.patch.object(client, 'lookup_token') as legacy_lookup: + with mock.patch.object(client.auth.token, 'lookup_self', side_effect=exc) as lookup: + auth_token.authenticate(client, use_token=True, lookup_self=True) + + legacy_lookup.assert_called_once_with() + lookup.assert_called_once_with() diff --git a/tests/unit/plugins/module_utils/authentication/test_hashi_vault_authenticator.py b/tests/unit/plugins/module_utils/authentication/test_hashi_vault_authenticator.py index 439dd7f15..4853d2c76 100644 --- a/tests/unit/plugins/module_utils/authentication/test_hashi_vault_authenticator.py +++ b/tests/unit/plugins/module_utils/authentication/test_hashi_vault_authenticator.py @@ -8,9 +8,7 @@ import pytest -from ansible_collections.community.hashi_vault.tests.unit.compat import mock - -from ansible_collections.community.hashi_vault.plugins.module_utils._authenticator import HashiVaultAuthenticator +from ......plugins.module_utils._authenticator import HashiVaultAuthenticator @pytest.fixture diff --git a/tests/unit/plugins/module_utils/test_hashi_vault_helper.py b/tests/unit/plugins/module_utils/test_hashi_vault_helper.py index 1993a8598..6a5c6002b 100644 --- a/tests/unit/plugins/module_utils/test_hashi_vault_helper.py +++ b/tests/unit/plugins/module_utils/test_hashi_vault_helper.py @@ -6,12 +6,11 @@ from __future__ import (absolute_import, division, print_function) __metaclass__ = type -import sys import os import pytest -from ansible_collections.community.hashi_vault.tests.unit.compat import mock -from ansible_collections.community.hashi_vault.plugins.module_utils._hashi_vault_common import ( +from .....tests.unit.compat import mock +from .....plugins.module_utils._hashi_vault_common import ( HashiVaultHelper, _stringify, ) diff --git a/tests/unit/plugins/modules/test_vault_kv2_delete.py b/tests/unit/plugins/modules/test_vault_kv2_delete.py new file mode 100644 index 000000000..faca37d61 --- /dev/null +++ b/tests/unit/plugins/modules/test_vault_kv2_delete.py @@ -0,0 +1,221 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2022 Isaac Wagner (@idwagner) +# 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 + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import pytest +import re +import json + +from ansible.module_utils.basic import missing_required_lib + +from ...compat import mock +from .....plugins.modules import vault_kv2_delete +from .....plugins.module_utils._hashi_vault_common import HashiVaultValueError + + +hvac = pytest.importorskip('hvac') + + +pytestmark = pytest.mark.usefixtures( + 'patch_ansible_module', + 'patch_authenticator', + 'patch_get_vault_client', +) + + +def _connection_options(): + return { + 'auth_method': 'token', + 'url': 'http://myvault', + 'token': 'beep-boop', + } + + +def _sample_options(): + return { + 'engine_mount_point': 'secret', + 'path': 'endpoint', + } + + +def _combined_options(**kwargs): + opt = _connection_options() + opt.update(_sample_options()) + opt.update(kwargs) + return opt + + +class TestModuleVaultKv2Delete(): + + @pytest.mark.parametrize('patch_ansible_module', [_combined_options()], indirect=True) + @pytest.mark.parametrize('exc', [HashiVaultValueError('throwaway msg'), NotImplementedError('throwaway msg')]) + def test_vault_kv2_delete_authentication_error(self, authenticator, exc, capfd): + authenticator.authenticate.side_effect = exc + + with pytest.raises(SystemExit) as e: + vault_kv2_delete.main() + + out, err = capfd.readouterr() + result = json.loads(out) + + assert e.value.code != 0, "result: %r" % (result,) + assert result['msg'] == 'throwaway msg', "result: %r" % result + + @pytest.mark.parametrize('patch_ansible_module', [_combined_options()], indirect=True) + @pytest.mark.parametrize('exc', [HashiVaultValueError('throwaway msg'), NotImplementedError('throwaway msg')]) + def test_vault_kv2_delete_auth_validation_error(self, authenticator, exc, capfd): + authenticator.validate.side_effect = exc + + with pytest.raises(SystemExit) as e: + vault_kv2_delete.main() + + out, err = capfd.readouterr() + result = json.loads(out) + + assert e.value.code != 0, "result: %r" % (result,) + assert result['msg'] == 'throwaway msg' + + @pytest.mark.parametrize('opt_versions', [None, [1, 3]]) + @pytest.mark.parametrize('patch_ansible_module', [[_combined_options(), 'versions']], indirect=True) + def test_vault_kv2_delete_empty_response(self, patch_ansible_module, opt_versions, requests_unparseable_response, vault_client, capfd): + client = vault_client + + requests_unparseable_response.status_code = 204 + + if opt_versions: + client.secrets.kv.v2.delete_secret_versions.return_value = requests_unparseable_response + else: + client.secrets.kv.v2.delete_latest_version_of_secret.return_value = requests_unparseable_response + + with pytest.raises(SystemExit) as e: + vault_kv2_delete.main() + + out, err = capfd.readouterr() + result = json.loads(out) + + assert e.value.code == 0, "result: %r" % (result,) + + assert result['data'] == {} + + @pytest.mark.parametrize('opt_versions', [None, [1, 3]]) + @pytest.mark.parametrize('patch_ansible_module', [[_combined_options(), 'versions']], indirect=True) + def test_vault_kv2_delete_unparseable_response(self, vault_client, opt_versions, requests_unparseable_response, module_warn, capfd): + client = vault_client + + requests_unparseable_response.status_code = 200 + requests_unparseable_response.content = '(☞゚ヮ゚)☞ ┻━┻' + + if opt_versions: + client.secrets.kv.v2.delete_secret_versions.return_value = requests_unparseable_response + else: + client.secrets.kv.v2.delete_latest_version_of_secret.return_value = requests_unparseable_response + + with pytest.raises(SystemExit) as e: + vault_kv2_delete.main() + + out, err = capfd.readouterr() + result = json.loads(out) + + assert e.value.code == 0, "result: %r" % (result,) + assert result['data'] == '(☞゚ヮ゚)☞ ┻━┻' + + module_warn.assert_called_once_with( + 'Vault returned status code 200 and an unparsable body.') + + @pytest.mark.parametrize('patch_ansible_module', [_combined_options()], indirect=True) + def test_vault_kv2_delete_no_hvac(self, capfd): + with mock.patch.multiple(vault_kv2_delete, HAS_HVAC=False, HVAC_IMPORT_ERROR=None, create=True): + with pytest.raises(SystemExit) as e: + vault_kv2_delete.main() + + out, err = capfd.readouterr() + result = json.loads(out) + + assert e.value.code != 0, "result: %r" % (result,) + assert result['msg'] == missing_required_lib('hvac') + + @pytest.mark.parametrize( + 'exc', + [ + (hvac.exceptions.Forbidden, "", + r"^Forbidden: Permission Denied to path \['([^']+)'\]"), + ] + ) + @pytest.mark.parametrize('opt_versions', [None, [1, 3]]) + @pytest.mark.parametrize('opt_path', ['path/1', 'second/path']) + @pytest.mark.parametrize('patch_ansible_module', [[_combined_options(), 'path', 'versions']], indirect=True) + def test_vault_kv2_delete_vault_exception(self, vault_client, exc, opt_versions, opt_path, capfd): + + client = vault_client + + if opt_versions: + client.secrets.kv.v2.delete_secret_versions.side_effect = exc[0]( + exc[1]) + else: + client.secrets.kv.v2.delete_latest_version_of_secret.side_effect = exc[0]( + exc[1]) + + with pytest.raises(SystemExit) as e: + vault_kv2_delete.main() + + out, err = capfd.readouterr() + result = json.loads(out) + + assert e.value.code != 0, "result: %r" % (result,) + match = re.search(exc[2], result['msg']) + assert match is not None, "result: %r\ndid not match: %s" % ( + result, exc[2]) + + assert opt_path == match.group(1) + + @pytest.mark.parametrize('opt__ansible_check_mode', [False, True]) + @pytest.mark.parametrize('opt_versions', [None]) + @pytest.mark.parametrize('patch_ansible_module', [[ + _combined_options(), + '_ansible_check_mode', + 'versions' + ]], indirect=True) + def test_vault_kv2_delete_latest_version_call(self, vault_client, opt__ansible_check_mode, opt_versions, capfd): + + client = vault_client + client.secrets.kv.v2.delete_latest_version_of_secret.return_value = {} + + with pytest.raises(SystemExit) as e: + vault_kv2_delete.main() + + out, err = capfd.readouterr() + result = json.loads(out) + + if opt__ansible_check_mode: + client.secrets.kv.v2.delete_latest_version_of_secret.assert_not_called() + else: + client.secrets.kv.v2.delete_latest_version_of_secret.assert_called_once_with( + path='endpoint', mount_point='secret') + + @pytest.mark.parametrize('opt__ansible_check_mode', [False, True]) + @pytest.mark.parametrize('opt_versions', [[1, 3]]) + @pytest.mark.parametrize('patch_ansible_module', [[ + _combined_options(), + '_ansible_check_mode', + 'versions' + ]], indirect=True) + def test_vault_kv2_delete_specific_versions_call(self, vault_client, opt__ansible_check_mode, opt_versions, capfd): + + client = vault_client + client.secrets.kv.v2.delete_secret_versions.return_value = {} + + with pytest.raises(SystemExit) as e: + vault_kv2_delete.main() + + out, err = capfd.readouterr() + result = json.loads(out) + + if opt__ansible_check_mode: + client.secrets.kv.v2.delete_secret_versions.assert_not_called() + else: + client.secrets.kv.v2.delete_secret_versions.assert_called_once_with( + path='endpoint', mount_point='secret', versions=[1, 3]) diff --git a/tests/unit/plugins/modules/test_vault_list.py b/tests/unit/plugins/modules/test_vault_list.py new file mode 100644 index 000000000..6891547be --- /dev/null +++ b/tests/unit/plugins/modules/test_vault_list.py @@ -0,0 +1,164 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2023 Tom Kivlin (@tomkivlin) +# 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 + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import pytest +import re +import json + +from ansible.module_utils.basic import missing_required_lib + +from ...compat import mock +from .....plugins.modules import vault_list +from .....plugins.module_utils._hashi_vault_common import HashiVaultValueError + + +hvac = pytest.importorskip('hvac') + + +pytestmark = pytest.mark.usefixtures( + 'patch_ansible_module', + 'patch_authenticator', + 'patch_get_vault_client', +) + + +def _connection_options(): + return { + 'auth_method': 'token', + 'url': 'http://myvault', + 'token': 'beep-boop', + } + + +def _sample_options(): + return { + 'path': 'endpoint', + } + + +def _combined_options(**kwargs): + opt = _connection_options() + opt.update(_sample_options()) + opt.update(kwargs) + return opt + + +LIST_FIXTURES = [ + 'kv2_list_response.json', + 'policy_list_response.json', + 'userpass_list_response.json', +] + + +@pytest.fixture(params=LIST_FIXTURES) +def list_response(request, fixture_loader): + return fixture_loader(request.param) + + +class TestModuleVaultList(): + + @pytest.mark.parametrize('patch_ansible_module', [_combined_options()], indirect=True) + @pytest.mark.parametrize('exc', [HashiVaultValueError('throwaway msg'), NotImplementedError('throwaway msg')]) + def test_vault_list_authentication_error(self, authenticator, exc, capfd): + authenticator.authenticate.side_effect = exc + + with pytest.raises(SystemExit) as e: + vault_list.main() + + out, err = capfd.readouterr() + result = json.loads(out) + + assert e.value.code != 0, "result: %r" % (result,) + assert result['msg'] == 'throwaway msg', "result: %r" % result + + @pytest.mark.parametrize('patch_ansible_module', [_combined_options()], indirect=True) + @pytest.mark.parametrize('exc', [HashiVaultValueError('throwaway msg'), NotImplementedError('throwaway msg')]) + def test_vault_list_auth_validation_error(self, authenticator, exc, capfd): + authenticator.validate.side_effect = exc + + with pytest.raises(SystemExit) as e: + vault_list.main() + + out, err = capfd.readouterr() + result = json.loads(out) + + assert e.value.code != 0, "result: %r" % (result,) + assert result['msg'] == 'throwaway msg' + + @pytest.mark.parametrize('patch_ansible_module', [_combined_options()], indirect=True) + def test_vault_list_return_data(self, patch_ansible_module, list_response, vault_client, capfd): + client = vault_client + client.list.return_value = list_response.copy() + + with pytest.raises(SystemExit) as e: + vault_list.main() + + out, err = capfd.readouterr() + result = json.loads(out) + + assert e.value.code == 0, "result: %r" % (result,) + + client.list.assert_called_once_with(patch_ansible_module['path']) + + assert result['data'] == list_response, "module result did not match expected result:\nexpected: %r\ngot: %r" % (list_response, result) + + @pytest.mark.parametrize('patch_ansible_module', [_combined_options()], indirect=True) + def test_vault_list_no_data(self, patch_ansible_module, vault_client, capfd): + client = vault_client + client.list.return_value = None + + with pytest.raises(SystemExit) as e: + vault_list.main() + + out, err = capfd.readouterr() + result = json.loads(out) + + assert e.value.code != 0, "result: %r" % (result,) + + client.list.assert_called_once_with(patch_ansible_module['path']) + + match = re.search(r"The path '[^']+' doesn't seem to exist", result['msg']) + + assert match is not None, "Unexpected msg: %s" % result['msg'] + + @pytest.mark.parametrize('patch_ansible_module', [_combined_options()], indirect=True) + def test_vault_list_no_hvac(self, capfd): + with mock.patch.multiple(vault_list, HAS_HVAC=False, HVAC_IMPORT_ERROR=None, create=True): + with pytest.raises(SystemExit) as e: + vault_list.main() + + out, err = capfd.readouterr() + result = json.loads(out) + + assert e.value.code != 0, "result: %r" % (result,) + assert result['msg'] == missing_required_lib('hvac') + + @pytest.mark.parametrize( + 'exc', + [ + (hvac.exceptions.Forbidden, "", r"^Forbidden: Permission Denied to path '([^']+)'"), + ] + ) + @pytest.mark.parametrize('patch_ansible_module', [[_combined_options(), 'path']], indirect=True) + @pytest.mark.parametrize('opt_path', ['path/1', 'second/path']) + def test_vault_list_vault_exception(self, vault_client, exc, opt_path, capfd): + + client = vault_client + client.list.side_effect = exc[0](exc[1]) + + with pytest.raises(SystemExit) as e: + vault_list.main() + + out, err = capfd.readouterr() + result = json.loads(out) + + assert e.value.code != 0, "result: %r" % (result,) + match = re.search(exc[2], result['msg']) + assert match is not None, "result: %r\ndid not match: %s" % (result, exc[2]) + + assert opt_path == match.group(1) diff --git a/tests/unit/plugins/modules/test_vault_token_create.py b/tests/unit/plugins/modules/test_vault_token_create.py index f23cf9e4a..24e44b800 100644 --- a/tests/unit/plugins/modules/test_vault_token_create.py +++ b/tests/unit/plugins/modules/test_vault_token_create.py @@ -12,6 +12,7 @@ import json from .....plugins.modules import vault_token_create +from .....plugins.module_utils._hashi_vault_common import HashiVaultValueError pytestmark = pytest.mark.usefixtures( 'patch_ansible_module', @@ -24,7 +25,7 @@ def _connection_options(): return { 'auth_method': 'token', 'url': 'http://myvault', - 'token': 'throwaway', + 'token': 'rando', } @@ -61,7 +62,7 @@ def pass_thru_options(): @pytest.fixture -def legacy_option_translation(): +def orphan_option_translation(): return { 'id': 'token_id', 'role_name': 'role', @@ -76,6 +77,34 @@ def token_create_response(fixture_loader): class TestModuleVaultTokenCreate(): + @pytest.mark.parametrize('patch_ansible_module', [_combined_options()], indirect=True) + @pytest.mark.parametrize('exc', [HashiVaultValueError('throwaway msg'), NotImplementedError('throwaway msg')]) + def test_vault_token_create_authentication_error(self, authenticator, exc, capfd): + authenticator.authenticate.side_effect = exc + + with pytest.raises(SystemExit) as e: + vault_token_create.main() + + out, err = capfd.readouterr() + result = json.loads(out) + + assert e.value.code != 0, "result: %r" % (result,) + assert result['msg'] == 'throwaway msg', "result: %r" % result + + @pytest.mark.parametrize('patch_ansible_module', [_combined_options()], indirect=True) + @pytest.mark.parametrize('exc', [HashiVaultValueError('throwaway msg'), NotImplementedError('throwaway msg')]) + def test_vault_token_create_auth_validation_error(self, authenticator, exc, capfd): + authenticator.validate.side_effect = exc + + with pytest.raises(SystemExit) as e: + vault_token_create.main() + + out, err = capfd.readouterr() + result = json.loads(out) + + assert e.value.code != 0, "result: %r" % (result,) + assert result['msg'] == 'throwaway msg' + @pytest.mark.no_ansible_module_patch def test_vault_token_create_passthru_options_expected(self, pass_thru_options): # designed to catch the case where new passthru options differ between tests and module @@ -90,24 +119,24 @@ def test_vault_token_create_passthru_options_expected(self, pass_thru_options): ) @pytest.mark.no_ansible_module_patch - def test_vault_token_create_legacy_options_expected(self, legacy_option_translation, pass_thru_options): - # designed to catch the case where new legacy translations differ between tests and module + def test_vault_token_create_orphan_options_expected(self, orphan_option_translation, pass_thru_options): + # designed to catch the case where new orphan translations differ between tests and module # and that all listed translations are present in passthru options - module_set = set(vault_token_create.LEGACY_OPTION_TRANSLATION.items()) - test_set = set(legacy_option_translation.items()) + module_set = set(vault_token_create.ORPHAN_OPTION_TRANSLATION.items()) + test_set = set(orphan_option_translation.items()) - module_key_set = set(vault_token_create.LEGACY_OPTION_TRANSLATION.keys()) + module_key_set = set(vault_token_create.ORPHAN_OPTION_TRANSLATION.keys()) pass_thru_key_set = set(pass_thru_options.keys()) assert module_set == test_set, ( - "Legacy options in module do not match legacy options in test:\nmodule: %r\ntest: %r" % ( + "Orphan options in module do not match orphan options in test:\nmodule: %r\ntest: %r" % ( dict(module_set - test_set), dict(test_set - module_set), ) ) - assert vault_token_create.LEGACY_OPTION_TRANSLATION.keys() <= pass_thru_options.keys(), ( - "Legacy option translation keys must exist in passthru options: %r" % ( + assert vault_token_create.ORPHAN_OPTION_TRANSLATION.keys() <= pass_thru_options.keys(), ( + "Orphan option translation keys must exist in passthru options: %r" % ( list(module_key_set - pass_thru_key_set), ) ) @@ -124,6 +153,7 @@ def test_vault_token_create_passthru_options(self, pass_thru_options, token_crea result = json.loads(out) client.create_token.assert_not_called() + client.auth.token.create_orphan.assert_not_called() client.auth.token.create.assert_called_once() assert result['login'] == token_create_response, ( @@ -137,9 +167,9 @@ def test_vault_token_create_passthru_options(self, pass_thru_options, token_crea assert pass_thru_options.items() <= client.auth.token.create.call_args.kwargs.items() @pytest.mark.parametrize('patch_ansible_module', [_combined_options(orphan=True)], indirect=True) - def test_vault_token_create_legacy_options(self, pass_thru_options, legacy_option_translation, token_create_response, vault_client, capfd): + def test_vault_token_create_orphan_options(self, pass_thru_options, orphan_option_translation, token_create_response, vault_client, capfd): client = vault_client - client.create_token.return_value = token_create_response + client.auth.token.create_orphan.return_value = token_create_response with pytest.raises(SystemExit): vault_token_create.main() @@ -147,8 +177,9 @@ def test_vault_token_create_legacy_options(self, pass_thru_options, legacy_optio out, err = capfd.readouterr() result = json.loads(out) + client.create_token.assert_not_called() client.auth.token.create.assert_not_called() - client.create_token.assert_called_once() + client.auth.token.create_orphan.assert_called_once() assert result['login'] == token_create_response, ( "module result did not match expected result:\nmodule: %r\nexpected: %r" % (result['module'], token_create_response) @@ -156,30 +187,30 @@ def test_vault_token_create_legacy_options(self, pass_thru_options, legacy_optio if sys.version_info < (3, 8): # TODO: remove when python < 3.8 is dropped - call_kwargs = client.create_token.call_args[1] + call_kwargs = client.auth.token.create_orphan.call_args[1] else: - call_kwargs = client.create_token.call_args.kwargs + call_kwargs = client.auth.token.create_orphan.call_args.kwargs - for name, legacy in legacy_option_translation.items(): + for name, orphan in orphan_option_translation.items(): assert name not in call_kwargs, ( - "'%s' was found in call to legacy method, should be '%s'" % (name, legacy) + "'%s' was found in call to orphan method, should be '%s'" % (name, orphan) ) - assert legacy in call_kwargs, ( - "'%s' (from '%s') was not found in call to legacy method" % (legacy, name) + assert orphan in call_kwargs, ( + "'%s' (from '%s') was not found in call to orphan method" % (orphan, name) ) - assert call_kwargs[legacy] == pass_thru_options.get(name), ( - "Expected legacy param '%s' not found or value did not match:\nvalue: %r\nexpected: %r" % ( - legacy, - call_kwargs.get(legacy), + assert call_kwargs[orphan] == pass_thru_options.get(name), ( + "Expected orphan param '%s' not found or value did not match:\nvalue: %r\nexpected: %r" % ( + orphan, + call_kwargs.get(orphan), pass_thru_options.get(name), ) ) @pytest.mark.parametrize('patch_ansible_module', [_combined_options(orphan=True)], indirect=True) - def test_vault_token_create_legacy_fallback(self, pass_thru_options, token_create_response, vault_client, module_warn, capfd): + def test_vault_token_create_orphan_fallback(self, token_create_response, vault_client, capfd): client = vault_client - client.create_token.side_effect = AttributeError - client.auth.token.create.return_value = token_create_response + client.create_token.return_value = token_create_response + client.auth.token.create_orphan.side_effect = AttributeError with pytest.raises(SystemExit): vault_token_create.main() @@ -187,17 +218,52 @@ def test_vault_token_create_legacy_fallback(self, pass_thru_options, token_creat out, err = capfd.readouterr() result = json.loads(out) - module_warn.assert_called_once_with("'create_token' method was not found. Attempting method that requires root privileges.") - client.auth.token.create.assert_called_once() + client.auth.token.create_orphan.assert_called_once() + client.create_token.assert_called_once() assert result['login'] == token_create_response, ( "module result did not match expected result:\nmodule: %r\nexpected: %r" % (result['login'], token_create_response) ) - # we're retesting that expected options were passed, even though there's a separate test for that, - # to ensure that nothing in the original legacy attempt mutates the non-legacy options during fallback - if sys.version_info < (3, 8): - # TODO: remove when python < 3.8 is dropped - assert pass_thru_options.items() <= client.auth.token.create.call_args[1].items() - else: - assert pass_thru_options.items() <= client.auth.token.create.call_args.kwargs.items() + @pytest.mark.parametrize('patch_ansible_module', [_combined_options()], indirect=True) + def test_vault_token_create_exception_handling_standard(self, vault_client, capfd): + client = vault_client + client.auth.token.create.side_effect = Exception('side_effect') + + with pytest.raises(SystemExit) as e: + vault_token_create.main() + + out, err = capfd.readouterr() + result = json.loads(out) + + assert e.value.code != 0, "result: %r" % (result,) + assert result['msg'] == 'side_effect' + + @pytest.mark.parametrize('patch_ansible_module', [_combined_options(orphan=True)], indirect=True) + def test_vault_token_create_exception_handling_orphan(self, vault_client, capfd): + client = vault_client + client.auth.token.create_orphan.side_effect = Exception('side_effect') + + with pytest.raises(SystemExit) as e: + vault_token_create.main() + + out, err = capfd.readouterr() + result = json.loads(out) + + assert e.value.code != 0, "result: %r" % (result,) + assert result['msg'] == 'side_effect' + + @pytest.mark.parametrize('patch_ansible_module', [_combined_options(orphan=True)], indirect=True) + def test_vault_token_create_exception_handling_orphan_fallback(self, vault_client, capfd): + client = vault_client + client.create_token.side_effect = Exception('side_effect') + client.auth.token.create_orphan.side_effect = AttributeError + + with pytest.raises(SystemExit) as e: + vault_token_create.main() + + out, err = capfd.readouterr() + result = json.loads(out) + + assert e.value.code != 0, "result: %r" % (result,) + assert result['msg'] == 'side_effect' diff --git a/tests/unit/plugins/plugin_utils/authentication/test_auth_token.py b/tests/unit/plugins/plugin_utils/authentication/test_auth_token.py index f80dfd9fd..3dbc4c7fc 100644 --- a/tests/unit/plugins/plugin_utils/authentication/test_auth_token.py +++ b/tests/unit/plugins/plugin_utils/authentication/test_auth_token.py @@ -29,7 +29,7 @@ def option_dict(): @pytest.fixture(params=[AnsibleUnsafeBytes(b'ub_opaque'), AnsibleUnsafeText(u'ut_opaque'), b'b_opaque', u't_opaque']) -def token(request): +def stringy(request): return request.param @@ -39,9 +39,8 @@ def auth_token(adapter, warner, deprecator): class TestAuthToken(object): - - def test_auth_token_unsafes(self, auth_token, client, adapter, token): - adapter.set_option('token', token) + def test_auth_token_unsafes(self, auth_token, client, adapter, stringy): + adapter.set_option('token', stringy) adapter.set_option('token_validate', False) wrapper = mock.Mock(wraps=auth_token._stringify) diff --git a/tests/unit/plugins/plugin_utils/test_hashi_vault_helper.py b/tests/unit/plugins/plugin_utils/test_hashi_vault_helper.py index 1f4132ecb..e71e625c0 100644 --- a/tests/unit/plugins/plugin_utils/test_hashi_vault_helper.py +++ b/tests/unit/plugins/plugin_utils/test_hashi_vault_helper.py @@ -8,7 +8,7 @@ import pytest -from ansible.utils.unsafe_proxy import AnsibleUnsafe, AnsibleUnsafeBytes, AnsibleUnsafeText +from ansible.utils.unsafe_proxy import AnsibleUnsafeBytes, AnsibleUnsafeText from ansible_collections.community.hashi_vault.tests.unit.compat import mock from ansible_collections.community.hashi_vault.plugins.module_utils._hashi_vault_common import HashiVaultHelper @@ -21,9 +21,10 @@ def hashi_vault_helper(): @pytest.fixture def expected_stringify_candidates(): - return set( + return set([ 'token', - ) + 'namespace', + ]) class TestHashiVaultHelper(object): diff --git a/tests/unit/requirements.txt b/tests/unit/requirements.txt index 7a9aaae0b..d8844e35b 100644 --- a/tests/unit/requirements.txt +++ b/tests/unit/requirements.txt @@ -2,7 +2,6 @@ # earlier python versions are still needed for Ansible < 2.12 which doesn't # support tests/config.yml, so that unit tests (which will be skipped) won't # choke on installing requirements. - hvac >= 0.10.6, != 0.10.12, != 0.10.13, < 1.0.0 ; python_version == '2.7' # bugs in 0.10.12 and 0.10.13 prevent it from working in Python 2 hvac >= 0.10.6, < 1.0.0 ; python_version == '3.5' # py3.5 support will be dropped in 1.0.0 hvac >= 0.10.6 ; python_version >= '3.6' @@ -10,3 +9,7 @@ hvac >= 0.10.6 ; python_version >= '3.6' # these should be satisfied naturally by the requests versions required by hvac anyway urllib3 >= 1.15 ; python_version >= '3.6' # we need raise_on_status for retry support to raise the correct exceptions https://github.com/urllib3/urllib3/blob/main/CHANGES.rst#115-2016-04-06 urllib3 >= 1.15, <2.0.0 ; python_version < '3.6' # https://urllib3.readthedocs.io/en/latest/v2-roadmap.html#optimized-for-python-3-6 + +# azure-identity 1.7.0 depends on cryptography 2.5 which drops python 2.6 support +azure-identity < 1.7.0; python_version < '2.7' +azure-identity; python_version >= '2.7'