diff --git a/codecov.yml b/codecov.yml index 8dafd8ba1..03b0eec8b 100644 --- a/codecov.yml +++ b/codecov.yml @@ -20,6 +20,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 +48,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 diff --git a/meta/runtime.yml b/meta/runtime.yml index fb5b0b8fb..4a4663dda 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -6,6 +6,7 @@ action_groups: - vault_kv1_get - vault_kv2_delete - vault_kv2_get + - vault_list - vault_login - vault_pki_generate_certificate - vault_read 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/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/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/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/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 2d76ea951..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 @@ -18,6 +19,15 @@ vault_kv2_multi_api_path: '{{ vault_kv2_mount_point }}/data/{{ vault_kv2_multi_p 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" { @@ -69,6 +79,21 @@ vault_base_policy: | 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" { 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/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/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)