diff --git a/awx_collection/plugins/module_utils/tower_api.py b/awx_collection/plugins/module_utils/tower_api.py index d836c457c0..a0b8b789ff 100644 --- a/awx_collection/plugins/module_utils/tower_api.py +++ b/awx_collection/plugins/module_utils/tower_api.py @@ -521,6 +521,9 @@ class TowerModule(AnsibleModule): elif item_type == 'o_auth2_access_token': # An oauth2 token has no name, instead we will use its id for any of the messages item_name = existing_item['id'] + elif item_type == 'credential_input_source': + # An credential_input_source has no name, instead we will use its id for any of the messages + item_name = existing_item['id'] else: self.fail_json(msg="Unable to process delete of {0} due to missing name".format(item_type)) @@ -691,6 +694,8 @@ class TowerModule(AnsibleModule): item_name = existing_item['username'] elif item_type == 'workflow_job_template_node': item_name = existing_item['identifier'] + elif item_type == 'credential_input_source': + item_name = existing_item['id'] else: item_name = existing_item['name'] item_id = existing_item['id'] diff --git a/awx_collection/plugins/modules/tower_credential_input_source.py b/awx_collection/plugins/modules/tower_credential_input_source.py new file mode 100644 index 0000000000..bc2cb85579 --- /dev/null +++ b/awx_collection/plugins/modules/tower_credential_input_source.py @@ -0,0 +1,129 @@ +#!/usr/bin/python +# coding: utf-8 -*- + +# Copyright: (c) 2020, Tom Page +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} + + +DOCUMENTATION = ''' +--- +module: tower_credential_input_source +author: "Tom Page (@Tompage1994)" +version_added: "2.3" +short_description: create, update, or destroy Ansible Tower credential input sources. +description: + - Create, update, or destroy Ansible Tower credential input sources. See + U(https://www.ansible.com/tower) for an overview. +options: + description: + description: + - The description to use for the credential input source. + type: str + input_field_name: + description: + - The input field the credential source will be used for + required: True + type: str + metadata: + description: + - A JSON or YAML string + required: False + type: dict + target_credential: + description: + - The credential which will have its input defined by this source + required: true + type: str + source_credential: + description: + - The credential which is the source of the credential lookup + type: str + state: + description: + - Desired state of the resource. + choices: ["present", "absent"] + default: "present" + type: str + +extends_documentation_fragment: awx.awx.auth +''' + + +EXAMPLES = ''' +- name: Use CyberArk Lookup credential as password source + tower_credential_input_source: + input_field_name: password + target_credential: new_cred + source_credential: cyberark_lookup + metadata: + object_query: "Safe=MY_SAFE;Object=awxuser" + object_query_format: "Exact" + state: present + +''' + +from ..module_utils.tower_api import TowerModule + + +def main(): + # Any additional arguments that are not fields of the item can be added here + argument_spec = dict( + description=dict(default=''), + input_field_name=dict(required=True), + target_credential=dict(required=True), + source_credential=dict(default=''), + metadata=dict(type="dict"), + state=dict(choices=['present', 'absent'], default='present'), + ) + + # Create a module for ourselves + module = TowerModule(argument_spec=argument_spec) + + # Extract our parameters + description = module.params.get('description') + input_field_name = module.params.get('input_field_name') + target_credential = module.params.get('target_credential') + source_credential = module.params.get('source_credential') + metadata = module.params.get('metadata') + state = module.params.get('state') + + target_credential_id = module.resolve_name_to_id('credentials', target_credential) + + # Attempt to look up the object based on the target credential and input field + lookup_data = { + 'target_credential': target_credential_id, + 'input_field_name': input_field_name, + } + credential_input_source = module.get_one('credential_input_sources', **{'data': lookup_data}) + + if state == 'absent': + module.delete_if_needed(credential_input_source) + + # Create the data that gets sent for create and update + credential_input_source_fields = { + 'target_credential': target_credential_id, + 'input_field_name': input_field_name, + } + if source_credential: + credential_input_source_fields['source_credential'] = module.resolve_name_to_id('credentials', source_credential) + if metadata: + credential_input_source_fields['metadata'] = metadata + if description: + credential_input_source_fields['description'] = description + + # If the state was present we can let the module build or update the existing group, this will return on its own + module.create_or_update_if_needed( + credential_input_source, credential_input_source_fields, endpoint='credential_input_sources', item_type='credential_input_source' + ) + + +if __name__ == '__main__': + main() diff --git a/awx_collection/test/awx/test_credential_input_source.py b/awx_collection/test/awx/test_credential_input_source.py new file mode 100644 index 0000000000..a676ab15cb --- /dev/null +++ b/awx_collection/test/awx/test_credential_input_source.py @@ -0,0 +1,333 @@ +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import pytest + +from awx.main.models import CredentialInputSource, Credential, CredentialType, Organization + + +@pytest.fixture +def aim_cred_type(): + ct = CredentialType.defaults['aim']() + ct.save() + return ct + + +# Test CyberArk AIM credential source +@pytest.fixture +def source_cred_aim(aim_cred_type): + return Credential.objects.create( + name='CyberArk AIM Cred', + credential_type=aim_cred_type, + inputs={ + "url": "https://cyberark.example.com", + "app_id": "myAppID", + "verify": "false" + } + ) + + +@pytest.mark.django_db +def test_aim_credential_source(run_module, admin_user, organization, source_cred_aim, silence_deprecation): + ct = CredentialType.defaults['ssh']() + ct.save() + tgt_cred = Credential.objects.create( + name='Test Machine Credential', + organization=organization, + credential_type=ct, + inputs={'username': 'bob'} + ) + + result = run_module('tower_credential_input_source', dict( + source_credential=source_cred_aim.name, + target_credential=tgt_cred.name, + input_field_name='password', + metadata={"object_query": "Safe=SUPERSAFE;Object=MyAccount"}, + state='present' + ), admin_user) + + assert not result.get('failed', False), result.get('msg', result) + assert result.get('changed'), result + + assert CredentialInputSource.objects.count() == 1 + cis = CredentialInputSource.objects.first() + + assert cis.metadata['object_query'] == "Safe=SUPERSAFE;Object=MyAccount" + assert cis.source_credential.name == source_cred_aim.name + assert cis.target_credential.name == tgt_cred.name + assert cis.input_field_name == 'password' + assert result['id'] == cis.pk + + +# Test CyberArk Conjur credential source +@pytest.fixture +def source_cred_conjur(organization): + # Make a credential type which will be used by the credential + ct = CredentialType.defaults['conjur']() + ct.save() + return Credential.objects.create( + name='CyberArk CONJUR Cred', + credential_type=ct, + inputs={ + "url": "https://cyberark.example.com", + "api_key": "myApiKey", + "account": "account", + "username": "username" + } + ) + + +@pytest.mark.django_db +def test_conjur_credential_source(run_module, admin_user, organization, source_cred_conjur, silence_deprecation): + ct = CredentialType.defaults['ssh']() + ct.save() + tgt_cred = Credential.objects.create( + name='Test Machine Credential', + organization=organization, + credential_type=ct, + inputs={'username': 'bob'} + ) + + result = run_module('tower_credential_input_source', dict( + source_credential=source_cred_conjur.name, + target_credential=tgt_cred.name, + input_field_name='password', + metadata={"secret_path": "/path/to/secret"}, + state='present' + ), admin_user) + + assert not result.get('failed', False), result.get('msg', result) + assert result.get('changed'), result + + assert CredentialInputSource.objects.count() == 1 + cis = CredentialInputSource.objects.first() + + assert cis.metadata['secret_path'] == "/path/to/secret" + assert cis.source_credential.name == source_cred_conjur.name + assert cis.target_credential.name == tgt_cred.name + assert cis.input_field_name == 'password' + assert result['id'] == cis.pk + + +# Test Hashicorp Vault secret credential source +@pytest.fixture +def source_cred_hashi_secret(organization): + # Make a credential type which will be used by the credential + ct = CredentialType.defaults['hashivault_kv']() + ct.save() + return Credential.objects.create( + name='HashiCorp secret Cred', + credential_type=ct, + inputs={ + "url": "https://secret.hash.example.com", + "token": "myApiKey", + "role_id": "role", + "secret_id": "secret" + } + ) + + +@pytest.mark.django_db +def test_hashi_secret_credential_source(run_module, admin_user, organization, source_cred_hashi_secret, silence_deprecation): + ct = CredentialType.defaults['ssh']() + ct.save() + tgt_cred = Credential.objects.create( + name='Test Machine Credential', + organization=organization, + credential_type=ct, + inputs={'username': 'bob'} + ) + + result = run_module('tower_credential_input_source', dict( + source_credential=source_cred_hashi_secret.name, + target_credential=tgt_cred.name, + input_field_name='password', + metadata={"secret_path": "/path/to/secret", "auth_path": "/path/to/auth", "secret_backend": "backend", "secret_key": "a_key"}, + state='present' + ), admin_user) + + assert not result.get('failed', False), result.get('msg', result) + assert result.get('changed'), result + + assert CredentialInputSource.objects.count() == 1 + cis = CredentialInputSource.objects.first() + + assert cis.metadata['secret_path'] == "/path/to/secret" + assert cis.metadata['auth_path'] == "/path/to/auth" + assert cis.metadata['secret_backend'] == "backend" + assert cis.metadata['secret_key'] == "a_key" + assert cis.source_credential.name == source_cred_hashi_secret.name + assert cis.target_credential.name == tgt_cred.name + assert cis.input_field_name == 'password' + assert result['id'] == cis.pk + + +# Test Hashicorp Vault signed ssh credential source +@pytest.fixture +def source_cred_hashi_ssh(organization): + # Make a credential type which will be used by the credential + ct = CredentialType.defaults['hashivault_ssh']() + ct.save() + return Credential.objects.create( + name='HashiCorp ssh Cred', + credential_type=ct, + inputs={ + "url": "https://ssh.hash.example.com", + "token": "myApiKey", + "role_id": "role", + "secret_id": "secret" + } + ) + + +@pytest.mark.django_db +def test_hashi_ssh_credential_source(run_module, admin_user, organization, source_cred_hashi_ssh, silence_deprecation): + ct = CredentialType.defaults['ssh']() + ct.save() + tgt_cred = Credential.objects.create( + name='Test Machine Credential', + organization=organization, + credential_type=ct, + inputs={'username': 'bob'} + ) + + result = run_module('tower_credential_input_source', dict( + source_credential=source_cred_hashi_ssh.name, + target_credential=tgt_cred.name, + input_field_name='password', + metadata={"secret_path": "/path/to/secret", "auth_path": "/path/to/auth", "role": "role", "public_key": "a_key", "valid_principals": "some_value"}, + state='present' + ), admin_user) + + assert not result.get('failed', False), result.get('msg', result) + assert result.get('changed'), result + + assert CredentialInputSource.objects.count() == 1 + cis = CredentialInputSource.objects.first() + + assert cis.metadata['secret_path'] == "/path/to/secret" + assert cis.metadata['auth_path'] == "/path/to/auth" + assert cis.metadata['role'] == "role" + assert cis.metadata['public_key'] == "a_key" + assert cis.metadata['valid_principals'] == "some_value" + assert cis.source_credential.name == source_cred_hashi_ssh.name + assert cis.target_credential.name == tgt_cred.name + assert cis.input_field_name == 'password' + assert result['id'] == cis.pk + + +# Test Azure Key Vault credential source +@pytest.fixture +def source_cred_azure_kv(organization): + # Make a credential type which will be used by the credential + ct = CredentialType.defaults['azure_kv']() + ct.save() + return Credential.objects.create( + name='Azure KV Cred', + credential_type=ct, + inputs={ + "url": "https://key.azure.example.com", + "client": "client", + "secret": "secret", + "tenant": "tenant", + "cloud_name": "the_cloud", + } + ) + + +@pytest.mark.django_db +def test_azure_kv_credential_source(run_module, admin_user, organization, source_cred_azure_kv, silence_deprecation): + ct = CredentialType.defaults['ssh']() + ct.save() + tgt_cred = Credential.objects.create( + name='Test Machine Credential', + organization=organization, + credential_type=ct, + inputs={'username': 'bob'} + ) + + result = run_module('tower_credential_input_source', dict( + source_credential=source_cred_azure_kv.name, + target_credential=tgt_cred.name, + input_field_name='password', + metadata={"secret_field": "my_pass"}, + state='present' + ), admin_user) + + assert not result.get('failed', False), result.get('msg', result) + assert result.get('changed'), result + + assert CredentialInputSource.objects.count() == 1 + cis = CredentialInputSource.objects.first() + + assert cis.metadata['secret_field'] == "my_pass" + assert cis.source_credential.name == source_cred_azure_kv.name + assert cis.target_credential.name == tgt_cred.name + assert cis.input_field_name == 'password' + assert result['id'] == cis.pk + + +# Test Changing Credential Source +@pytest.fixture +def source_cred_aim_alt(aim_cred_type): + return Credential.objects.create( + name='Alternate CyberArk AIM Cred', + credential_type=aim_cred_type, + inputs={ + "url": "https://cyberark-alt.example.com", + "app_id": "myAltID", + "verify": "false" + } + ) + + +@pytest.mark.django_db +def test_aim_credential_source(run_module, admin_user, organization, source_cred_aim, source_cred_aim_alt, silence_deprecation): + ct = CredentialType.defaults['ssh']() + ct.save() + tgt_cred = Credential.objects.create( + name='Test Machine Credential', + organization=organization, + credential_type=ct, + inputs={'username': 'bob'} + ) + + result = run_module('tower_credential_input_source', dict( + source_credential=source_cred_aim.name, + target_credential=tgt_cred.name, + input_field_name='password', + metadata={"object_query": "Safe=SUPERSAFE;Object=MyAccount"}, + state='present' + ), admin_user) + + assert not result.get('failed', False), result.get('msg', result) + assert result.get('changed'), result + + unchangedResult = run_module('tower_credential_input_source', dict( + source_credential=source_cred_aim.name, + target_credential=tgt_cred.name, + input_field_name='password', + metadata={"object_query": "Safe=SUPERSAFE;Object=MyAccount"}, + state='present' + ), admin_user) + + assert not unchangedResult.get('failed', False), result.get('msg', result) + assert not unchangedResult.get('changed'), result + + changedResult = run_module('tower_credential_input_source', dict( + source_credential=source_cred_aim_alt.name, + target_credential=tgt_cred.name, + input_field_name='password', + state='present' + ), admin_user) + + assert not changedResult.get('failed', False), changedResult.get('msg', result) + assert changedResult.get('changed'), result + + assert CredentialInputSource.objects.count() == 1 + cis = CredentialInputSource.objects.first() + + assert cis.metadata['object_query'] == "Safe=SUPERSAFE;Object=MyAccount" + assert cis.source_credential.name == source_cred_aim_alt.name + assert cis.target_credential.name == tgt_cred.name + assert cis.input_field_name == 'password' diff --git a/awx_collection/tests/integration/targets/tower_credential_input_source/tasks/main.yml b/awx_collection/tests/integration/targets/tower_credential_input_source/tasks/main.yml new file mode 100644 index 0000000000..45be47ddf7 --- /dev/null +++ b/awx_collection/tests/integration/targets/tower_credential_input_source/tasks/main.yml @@ -0,0 +1,105 @@ +--- +- name: Generate names + set_fact: + src_cred_name: src_cred + target_cred_name: target_cred + +- name: Add Tower credential Lookup + tower_credential: + description: Credential for Testing Source + name: "{{ src_cred_name }}" + credential_type: CyberArk AIM Central Credential Provider Lookup + inputs: + url: "https://cyberark.example.com" + app_id: "My-App-ID" + organization: Default + register: result + +- assert: + that: + - "result is changed" + +- name: Add Tower credential Target + tower_credential: + description: Credential for Testing Target + name: "{{ target_cred_name }}" + credential_type: Machine + inputs: + username: user + organization: Default + register: result + +- assert: + that: + - "result is changed" + +- name: Add credential Input Source + tower_credential_input_source: + input_field_name: password + target_credential: "{{ target_cred_name }}" + source_credential: "{{ src_cred_name }}" + metadata: + object_query: "Safe=MY_SAFE;Object=AWX-user" + object_query_format: "Exact" + state: present + +- assert: + that: + - "result is changed" + +- name: Add Second Tower credential Lookup + tower_credential: + description: Credential for Testing Source Change + name: "{{ src_cred_name }}-2" + credential_type: CyberArk AIM Central Credential Provider Lookup + inputs: + url: "https://cyberark-prod.example.com" + app_id: "My-App-ID" + organization: Default + register: result + +- name: Change credential Input Source + tower_credential_input_source: + input_field_name: password + target_credential: "{{ target_cred_name }}" + source_credential: "{{ src_cred_name }}-2" + state: present + +- assert: + that: + - "result is changed" + +- name: Remove a Tower credential source + tower_credential_input_source: + input_field_name: password + target_credential: "{{ target_cred_name }}" + state: absent + register: result + +- assert: + that: + - "result is changed" + +- name: Remove Tower credential Lookup + tower_credential: + name: "{{ src_cred_name }}" + organization: Default + credential_type: CyberArk AIM Central Credential Provider Lookup + state: absent + register: result + +- name: Remove Alt Tower credential Lookup + tower_credential: + name: "{{ src_cred_name }}-2" + organization: Default + credential_type: CyberArk AIM Central Credential Provider Lookup + state: absent + register: result + +- name: Remove Tower credential + tower_credential: + name: "{{ target_cred_name }}" + organization: Default + credential_type: Machine + state: absent + register: result