diff --git a/changelogs/fragments/293-support-azure-auth-method.yml b/changelogs/fragments/293-support-azure-auth-method.yml new file mode 100644 index 000000000..33fa4db94 --- /dev/null +++ b/changelogs/fragments/293-support-azure-auth-method.yml @@ -0,0 +1,3 @@ +--- +minor_changes: + - community.hashi_vault collection - add support for ``azure`` auth method, for Azure service pricinpal, managed identity, or plain JWT access token (https://github.com/ansible-collections/community.hashi_vault/issues/293). diff --git a/codecov.yml b/codecov.yml index bb14293b7..8dafd8ba1 100644 --- a/codecov.yml +++ b/codecov.yml @@ -68,6 +68,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/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 = 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/plugins/doc_fragments/auth.py b/plugins/doc_fragments/auth.py index b02436d35..a13dc71c4 100644 --- a/plugins/doc_fragments/auth.py +++ b/plugins/doc_fragments/auth.py @@ -18,12 +18,14 @@ 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 - none @@ -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. @@ -96,6 +99,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 +265,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 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/_authenticator.py b/plugins/module_utils/_authenticator.py index 85d03d12d..2292d5b6b 100644 --- a/plugins/module_utils/_authenticator.py +++ b/plugins/module_utils/_authenticator.py @@ -17,6 +17,7 @@ # 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_ldap import HashiVaultAuthMethodLdap @@ -33,6 +34,7 @@ class HashiVaultAuthenticator(): 'ldap', 'approle', 'aws_iam', + 'azure', 'jwt', 'cert', 'none', @@ -54,6 +56,10 @@ class HashiVaultAuthenticator(): 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,6 +71,7 @@ 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), 'ldap': HashiVaultAuthMethodLdap(option_adapter, warning_callback, deprecate_callback), 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/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/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/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/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'