diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 7a67041128..ca4262dbed 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -46,7 +46,7 @@ from awx.main.constants import ( CENSOR_VALUE, ) from awx.main.models import ( - ActivityStream, AdHocCommand, AdHocCommandEvent, Credential, + ActivityStream, AdHocCommand, AdHocCommandEvent, Credential, CredentialInputSource, CredentialType, CustomInventoryScript, Fact, Group, Host, Instance, InstanceGroup, Inventory, InventorySource, InventoryUpdate, InventoryUpdateEvent, Job, JobEvent, JobHostSummary, JobLaunchConfig, @@ -2629,6 +2629,7 @@ class CredentialSerializer(BaseSerializer): )) if self.version > 1: res['copy'] = self.reverse('api:credential_copy', kwargs={'pk': obj.pk}) + res['input_sources'] = self.reverse('api:credential_input_source_sublist', kwargs={'pk': obj.pk}) # TODO: remove when API v1 is removed if self.version > 1: @@ -2815,6 +2816,43 @@ class CredentialSerializerCreate(CredentialSerializer): return credential +class CredentialInputSourceSerializer(BaseSerializer): + source_credential_name = serializers.SerializerMethodField( + read_only=True, + help_text=_('The name of the source credential.') + ) + source_credential_type = serializers.SerializerMethodField( + read_only=True, + help_text=_('The credential type of the source credential.') + ) + + class Meta: + model = CredentialInputSource + fields = ( + 'id', + 'type', + 'url', + 'input_field_name', + 'target_credential', + 'source_credential', + 'source_credential_type', + 'source_credential_name', + 'created', + 'modified', + ) + extra_kwargs = { + 'input_field_name': {'required': True}, + 'target_credential': {'required': True}, + 'source_credential': {'required': True}, + } + + def get_source_credential_name(self, obj): + return obj.source_credential.name + + def get_source_credential_type(self, obj): + return obj.source_credential.credential_type.id + + class UserCredentialSerializerCreate(CredentialSerializerCreate): class Meta: diff --git a/awx/api/urls/credential.py b/awx/api/urls/credential.py index c444da9090..3143b91c9e 100644 --- a/awx/api/urls/credential.py +++ b/awx/api/urls/credential.py @@ -12,6 +12,7 @@ from awx.api.views import ( CredentialOwnerUsersList, CredentialOwnerTeamsList, CredentialCopy, + CredentialInputSourceSubList, ) @@ -24,6 +25,7 @@ urls = [ url(r'^(?P[0-9]+)/owner_users/$', CredentialOwnerUsersList.as_view(), name='credential_owner_users_list'), url(r'^(?P[0-9]+)/owner_teams/$', CredentialOwnerTeamsList.as_view(), name='credential_owner_teams_list'), url(r'^(?P[0-9]+)/copy/$', CredentialCopy.as_view(), name='credential_copy'), + url(r'^(?P[0-9]+)/input_sources/$', CredentialInputSourceSubList.as_view(), name='credential_input_source_sublist'), ] __all__ = ['urls'] diff --git a/awx/api/urls/credential_input_source.py b/awx/api/urls/credential_input_source.py new file mode 100644 index 0000000000..5f660dfdf8 --- /dev/null +++ b/awx/api/urls/credential_input_source.py @@ -0,0 +1,17 @@ +# Copyright (c) 2019 Ansible, Inc. +# All Rights Reserved. + +from django.conf.urls import url + +from awx.api.views import ( + CredentialInputSourceDetail, + CredentialInputSourceList, +) + + +urls = [ + url(r'^$', CredentialInputSourceList.as_view(), name='credential_input_source_list'), + url(r'^(?P[0-9]+)/$', CredentialInputSourceDetail.as_view(), name='credential_input_source_detail'), +] + +__all__ = ['urls'] diff --git a/awx/api/urls/urls.py b/awx/api/urls/urls.py index 52e9ef1cf0..c5da931a69 100644 --- a/awx/api/urls/urls.py +++ b/awx/api/urls/urls.py @@ -47,6 +47,7 @@ from .inventory_update import urls as inventory_update_urls from .inventory_script import urls as inventory_script_urls from .credential_type import urls as credential_type_urls from .credential import urls as credential_urls +from .credential_input_source import urls as credential_input_source_urls from .role import urls as role_urls from .job_template import urls as job_template_urls from .job import urls as job_urls @@ -119,6 +120,7 @@ v1_urls = [ v2_urls = [ url(r'^$', ApiV2RootView.as_view(), name='api_v2_root_view'), url(r'^credential_types/', include(credential_type_urls)), + url(r'^credential_input_sources/', include(credential_input_source_urls)), url(r'^hosts/(?P[0-9]+)/ansible_facts/$', HostAnsibleFactsDetail.as_view(), name='host_ansible_facts_detail'), url(r'^jobs/(?P[0-9]+)/extra_credentials/$', JobExtraCredentialsList.as_view(), name='job_extra_credentials_list'), url(r'^jobs/(?P[0-9]+)/credentials/$', JobCredentialsList.as_view(), name='job_credentials_list'), diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 902c7d6910..fec83b854e 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -1419,6 +1419,33 @@ class CredentialCopy(CopyAPIView): copy_return_serializer_class = serializers.CredentialSerializer +class CredentialInputSourceDetail(RetrieveUpdateDestroyAPIView): + + view_name = _("Credential Input Source Detail") + + model = models.CredentialInputSource + serializer_class = serializers.CredentialInputSourceSerializer + + +class CredentialInputSourceList(ListCreateAPIView): + + view_name = _("Credential Input Sources") + + model = models.CredentialInputSource + serializer_class = serializers.CredentialInputSourceSerializer + + +class CredentialInputSourceSubList(SubListAPIView): + + view_name = _("Credential Input Sources") + + model = models.CredentialInputSource + serializer_class = serializers.CredentialInputSourceSerializer + parent_model = models.Credential + relationship = 'input_source' + parent_key = 'target_credential' + + class HostRelatedSearchMixin(object): @property diff --git a/awx/api/views/root.py b/awx/api/views/root.py index c7ecbbeef5..66bc11b710 100644 --- a/awx/api/views/root.py +++ b/awx/api/views/root.py @@ -101,6 +101,7 @@ class ApiVersionRootView(APIView): data['credentials'] = reverse('api:credential_list', request=request) if get_request_version(request) > 1: data['credential_types'] = reverse('api:credential_type_list', request=request) + data['credential_input_sources'] = reverse('api:credential_input_source_list', request=request) data['applications'] = reverse('api:o_auth2_application_list', request=request) data['tokens'] = reverse('api:o_auth2_token_list', request=request) data['inventory'] = reverse('api:inventory_list', request=request) diff --git a/awx/main/access.py b/awx/main/access.py index 284893eb31..d13f4ed682 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -30,8 +30,8 @@ from awx.main.utils import ( ) from awx.main.models import ( ActivityStream, AdHocCommand, AdHocCommandEvent, Credential, CredentialType, - CustomInventoryScript, Group, Host, Instance, InstanceGroup, Inventory, - InventorySource, InventoryUpdate, InventoryUpdateEvent, Job, JobEvent, + CredentialInputSource, CustomInventoryScript, Group, Host, Instance, InstanceGroup, + Inventory, InventorySource, InventoryUpdate, InventoryUpdateEvent, Job, JobEvent, JobHostSummary, JobLaunchConfig, JobTemplate, Label, Notification, NotificationTemplate, Organization, Project, ProjectUpdate, ProjectUpdateEvent, Role, Schedule, SystemJob, SystemJobEvent, @@ -1163,6 +1163,19 @@ class CredentialAccess(BaseAccess): return self.can_change(obj, None) +class CredentialInputSourceAccess(BaseAccess): + ''' + I can see credential input sources when: + - I'm a superuser (TODO: Update) + I can create credential input sources when: + - I'm a superuser (TODO: Update) + I can delete credential input sources when: + - I'm a superuser (TODO: Update) + ''' + + model = CredentialInputSource + + class TeamAccess(BaseAccess): ''' I can see a team when: diff --git a/awx/main/models/credential/__init__.py b/awx/main/models/credential/__init__.py index ba50d7b723..13fb146672 100644 --- a/awx/main/models/credential/__init__.py +++ b/awx/main/models/credential/__init__.py @@ -1339,6 +1339,10 @@ class CredentialInputSource(PrimordialModel): backend_kwargs[field_name] = value return backend(**backend_kwargs) + def get_absolute_url(self, request=None): + view_name = 'api:credential_input_source_detail' + return reverse(view_name, kwargs={'pk': self.pk}, request=request) + for plugin in credential_plugins: CredentialType.load_plugin(plugin) diff --git a/awx/main/tests/conftest.py b/awx/main/tests/conftest.py index e79fb7b67a..7418a0d735 100644 --- a/awx/main/tests/conftest.py +++ b/awx/main/tests/conftest.py @@ -140,6 +140,7 @@ def pytest_runtest_teardown(item, nextitem): # this is a local test cache, so we want every test to start with empty cache cache.clear() + @pytest.fixture(scope='session', autouse=True) def mock_external_credential_input_sources(): # Credential objects query their related input sources on initialization. diff --git a/awx/main/tests/functional/api/test_credential_input_sources.py b/awx/main/tests/functional/api/test_credential_input_sources.py new file mode 100644 index 0000000000..3524ca70fe --- /dev/null +++ b/awx/main/tests/functional/api/test_credential_input_sources.py @@ -0,0 +1,115 @@ +import pytest + +from awx.api.versioning import reverse + + +@pytest.mark.django_db +def test_create_credential_input_source(get, post, admin, vault_credential, external_credential): + list_url = reverse( + 'api:credential_input_source_list', + kwargs={'version': 'v2'} + ) + sublist_url = reverse( + 'api:credential_input_source_sublist', + kwargs={'version': 'v2', 'pk': vault_credential.pk} + ) + params = { + 'source_credential': external_credential.pk, + 'target_credential': vault_credential.pk, + 'input_field_name': 'vault_password' + } + response = post(list_url, params, admin) + assert response.status_code == 201 + + response = get(response.data['url'], admin) + assert response.status_code == 200 + + response = get(list_url, admin) + assert response.status_code == 200 + assert response.data['count'] == 1 + + response = get(sublist_url, admin) + assert response.status_code == 200 + assert response.data['count'] == 1 + + +@pytest.mark.django_db +def test_create_credential_input_source_using_sublist_returns_405(post, admin, vault_credential, external_credential): + sublist_url = reverse( + 'api:credential_input_source_sublist', + kwargs={'version': 'v2', 'pk': vault_credential.pk} + ) + params = { + 'source_credential': external_credential.pk, + 'target_credential': vault_credential.pk, + 'input_field_name': 'vault_password' + } + response = post(sublist_url, params, admin) + assert response.status_code == 405 + + +@pytest.mark.django_db +def test_create_credential_input_source_with_external_target_returns_400(post, admin, external_credential, other_external_credential): + list_url = reverse( + 'api:credential_input_source_list', + kwargs={'version': 'v2'} + ) + params = { + 'source_credential': external_credential.pk, + 'target_credential': other_external_credential.pk, + 'input_field_name': 'token' + } + response = post(list_url, params, admin) + assert response.status_code == 400 + assert response.data['target_credential'] == ['Target must be a non-external credential'] + + +@pytest.mark.django_db +def test_create_credential_input_source_with_non_external_source_returns_400(post, admin, credential, vault_credential): + list_url = reverse( + 'api:credential_input_source_list', + kwargs={'version': 'v2'} + ) + params = { + 'source_credential': credential.pk, + 'target_credential': vault_credential.pk, + 'input_field_name': 'vault_password' + } + response = post(list_url, params, admin) + assert response.status_code == 400 + assert response.data['source_credential'] == ['Source must be an external credential'] + + +@pytest.mark.django_db +def test_create_credential_input_source_with_undefined_input_returns_400(post, admin, vault_credential, external_credential): + list_url = reverse( + 'api:credential_input_source_list', + kwargs={'version': 'v2'} + ) + params = { + 'source_credential': external_credential.pk, + 'target_credential': vault_credential.pk, + 'input_field_name': 'not_defined_for_credential_type' + } + response = post(list_url, params, admin) + assert response.status_code == 400 + assert response.data['input_field_name'] == ['Input field must be defined on target credential.'] + + +@pytest.mark.django_db +def test_create_credential_input_source_with_already_used_input_returns_400(post, admin, vault_credential, external_credential, other_external_credential): + list_url = reverse( + 'api:credential_input_source_list', + kwargs={'version': 'v2'} + ) + all_params = [{ + 'source_credential': external_credential.pk, + 'target_credential': vault_credential.pk, + 'input_field_name': 'vault_password' + }, { + 'source_credential': other_external_credential.pk, + 'target_credential': vault_credential.pk, + 'input_field_name': 'vault_password' + }] + all_responses = [post(list_url, params, admin) for params in all_params] + assert all_responses.pop().status_code == 400 diff --git a/awx/main/tests/functional/conftest.py b/awx/main/tests/functional/conftest.py index 8837339783..8dcbc381a4 100644 --- a/awx/main/tests/functional/conftest.py +++ b/awx/main/tests/functional/conftest.py @@ -250,6 +250,33 @@ def credentialtype_insights(): return insights_type +@pytest.fixture +def credentialtype_external(): + external_type_inputs = { + 'fields': [{ + 'id': 'url', + 'label': 'Server URL', + 'type': 'string', + 'help_text': 'The server url.' + }, { + 'id': 'token', + 'label': 'Token', + 'type': 'string', + 'secret': True, + 'help_text': 'An access token for the server.' + }], + 'required': ['url', 'token'], + } + external_type = CredentialType( + kind='external', + managed_by_tower=True, + name='External Service', + inputs=external_type_inputs + ) + external_type.save() + return external_type + + @pytest.fixture def credential(credentialtype_aws): return Credential.objects.create(credential_type=credentialtype_aws, name='test-cred', @@ -293,6 +320,18 @@ def org_credential(organization, credentialtype_aws): organization=organization) +@pytest.fixture +def external_credential(credentialtype_external): + return Credential.objects.create(credential_type=credentialtype_external, name='external-cred', + inputs={'url': 'http://testhost.com', 'token': 'secret1'}) + + +@pytest.fixture +def other_external_credential(credentialtype_external): + return Credential.objects.create(credential_type=credentialtype_external, name='other-external-cred', + inputs={'url': 'http://testhost.com', 'token': 'secret2'}) + + @pytest.fixture def inventory(organization): return organization.inventories.create(name="test-inv")