diff --git a/Makefile b/Makefile index 109d0be226..e0cfc4b78f 100644 --- a/Makefile +++ b/Makefile @@ -575,6 +575,10 @@ docker-compose: docker-auth docker-compose-cluster: docker-auth CURRENT_UID=$(shell id -u) TAG=$(COMPOSE_TAG) DEV_DOCKER_TAG_BASE=$(DEV_DOCKER_TAG_BASE) docker-compose -f tools/docker-compose-cluster.yml up +docker-compose-credential-plugins: docker-auth + echo -e "\033[0;31mTo generate a CyberArk Conjur API key: docker exec -it tools_conjur_1 conjurctl account create quick-start\033[0m" + CURRENT_UID=$(shell id -u) TAG=$(COMPOSE_TAG) DEV_DOCKER_TAG_BASE=$(DEV_DOCKER_TAG_BASE) docker-compose -f tools/docker-compose.yml -f tools/docker-credential-plugins-override.yml up --no-recreate awx + docker-compose-test: docker-auth cd tools && CURRENT_UID=$(shell id -u) TAG=$(COMPOSE_TAG) DEV_DOCKER_TAG_BASE=$(DEV_DOCKER_TAG_BASE) docker-compose run --rm --service-ports awx /bin/bash diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 7a67041128..66a17442f9 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, @@ -133,6 +133,8 @@ SUMMARIZABLE_FK_FIELDS = { 'notification_template': DEFAULT_SUMMARY_FIELDS, 'instance_group': {'id', 'name', 'controller_id'}, 'insights_credential': DEFAULT_SUMMARY_FIELDS, + 'source_credential': DEFAULT_SUMMARY_FIELDS + ('kind', 'cloud', 'credential_type_id'), + 'target_credential': DEFAULT_SUMMARY_FIELDS + ('kind', 'cloud', 'credential_type_id'), } @@ -2582,7 +2584,7 @@ class V2CredentialFields(BaseSerializer, metaclass=BaseSerializerMetaclass): class CredentialSerializer(BaseSerializer): - show_capabilities = ['edit', 'delete', 'copy'] + show_capabilities = ['edit', 'delete', 'copy', 'use'] capabilities_prefetch = ['admin', 'use'] class Meta: @@ -2629,6 +2631,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 +2818,32 @@ class CredentialSerializerCreate(CredentialSerializer): return credential +class CredentialInputSourceSerializer(BaseSerializer): + show_capabilities = ['delete'] + + class Meta: + model = CredentialInputSource + fields = ( + '*', + 'input_field_name', + 'metadata', + 'target_credential', + 'source_credential', + '-name', + ) + extra_kwargs = { + 'input_field_name': {'required': True}, + 'target_credential': {'required': True}, + 'source_credential': {'required': True}, + } + + def get_related(self, obj): + res = super(CredentialInputSourceSerializer, self).get_related(obj) + res['source_credential'] = obj.source_credential.get_absolute_url(request=self.context.get('request')) + res['target_credential'] = obj.target_credential.get_absolute_url(request=self.context.get('request')) + return res + + class UserCredentialSerializerCreate(CredentialSerializerCreate): class Meta: diff --git a/awx/api/urls/credential.py b/awx/api/urls/credential.py index c444da9090..e041e08477 100644 --- a/awx/api/urls/credential.py +++ b/awx/api/urls/credential.py @@ -12,6 +12,8 @@ from awx.api.views import ( CredentialOwnerUsersList, CredentialOwnerTeamsList, CredentialCopy, + CredentialInputSourceSubList, + CredentialExternalTest, ) @@ -24,6 +26,8 @@ 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'), + url(r'^(?P[0-9]+)/test/$', CredentialExternalTest.as_view(), name='credential_external_test'), ] __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/credential_type.py b/awx/api/urls/credential_type.py index 22c097523b..5fa033fd33 100644 --- a/awx/api/urls/credential_type.py +++ b/awx/api/urls/credential_type.py @@ -8,6 +8,7 @@ from awx.api.views import ( CredentialTypeDetail, CredentialTypeCredentialList, CredentialTypeActivityStreamList, + CredentialTypeExternalTest, ) @@ -16,6 +17,7 @@ urls = [ url(r'^(?P[0-9]+)/$', CredentialTypeDetail.as_view(), name='credential_type_detail'), url(r'^(?P[0-9]+)/credentials/$', CredentialTypeCredentialList.as_view(), name='credential_type_credential_list'), url(r'^(?P[0-9]+)/activity_stream/$', CredentialTypeActivityStreamList.as_view(), name='credential_type_activity_stream_list'), + url(r'^(?P[0-9]+)/test/$', CredentialTypeExternalTest.as_view(), name='credential_type_external_test'), ] __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..24b760d8b0 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -1419,6 +1419,88 @@ class CredentialCopy(CopyAPIView): copy_return_serializer_class = serializers.CredentialSerializer +class CredentialExternalTest(SubDetailAPIView): + """ + Test updates to the input values and metadata of an external credential + before saving them. + """ + + view_name = _('External Credential Test') + + model = models.Credential + serializer_class = serializers.EmptySerializer + + def post(self, request, *args, **kwargs): + obj = self.get_object() + backend_kwargs = {} + for field_name, value in obj.inputs.items(): + backend_kwargs[field_name] = obj.get_input(field_name) + for field_name, value in request.data.get('inputs', {}).items(): + if value != '$encrypted$': + backend_kwargs[field_name] = value + backend_kwargs.update(request.data.get('metadata', {})) + try: + obj.credential_type.plugin.backend(**backend_kwargs) + return Response({}, status=status.HTTP_202_ACCEPTED) + except requests.exceptions.HTTPError as exc: + message = 'HTTP {}\n{}'.format(exc.response.status_code, exc.response.text) + return Response({'inputs': message}, status=status.HTTP_400_BAD_REQUEST) + except Exception as exc: + return Response({'inputs': str(exc)}, status=status.HTTP_400_BAD_REQUEST) + + +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(SubListCreateAPIView): + + view_name = _("Credential Input Sources") + + model = models.CredentialInputSource + serializer_class = serializers.CredentialInputSourceSerializer + parent_model = models.Credential + relationship = 'input_sources' + parent_key = 'target_credential' + + +class CredentialTypeExternalTest(SubDetailAPIView): + """ + Test a complete set of input values for an external credential before + saving it. + """ + + view_name = _('External Credential Type Test') + + model = models.CredentialType + serializer_class = serializers.EmptySerializer + + def post(self, request, *args, **kwargs): + obj = self.get_object() + backend_kwargs = request.data.get('inputs', {}) + backend_kwargs.update(request.data.get('metadata', {})) + try: + obj.plugin.backend(**backend_kwargs) + return Response({}, status=status.HTTP_202_ACCEPTED) + except requests.exceptions.HTTPError as exc: + message = 'HTTP {}\n{}'.format(exc.response.status_code, exc.response.text) + return Response({'inputs': message}, status=status.HTTP_400_BAD_REQUEST) + except Exception as exc: + return Response({'inputs': str(exc)}, status=status.HTTP_400_BAD_REQUEST) + + 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..52a138b970 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, @@ -426,7 +426,7 @@ class BaseAccess(object): if display_method == 'schedule': user_capabilities['schedule'] = user_capabilities['start'] continue - elif display_method == 'delete' and not isinstance(obj, (User, UnifiedJob, CustomInventoryScript)): + elif display_method == 'delete' and not isinstance(obj, (User, UnifiedJob, CustomInventoryScript, CredentialInputSource)): user_capabilities['delete'] = user_capabilities['edit'] continue elif display_method == 'copy' and isinstance(obj, (Group, Host)): @@ -1162,6 +1162,55 @@ class CredentialAccess(BaseAccess): # return True return self.can_change(obj, None) + def get_user_capabilities(self, obj, **kwargs): + user_capabilities = super(CredentialAccess, self).get_user_capabilities(obj, **kwargs) + user_capabilities['use'] = self.can_use(obj) + return user_capabilities + + +class CredentialInputSourceAccess(BaseAccess): + ''' + I can see a CredentialInputSource when: + - I can see the associated target_credential + I can create/change a CredentialInputSource when: + - I'm an admin of the associated target_credential + - I have use access to the associated source credential + I can delete a CredentialInputSource when: + - I'm an admin of the associated target_credential + ''' + + model = CredentialInputSource + select_related = ('target_credential', 'source_credential') + + def filtered_queryset(self): + return CredentialInputSource.objects.filter( + target_credential__in=Credential.accessible_pk_qs(self.user, 'read_role')) + + @check_superuser + def can_read(self, obj): + return self.user in obj.target_credential.read_role + + @check_superuser + def can_add(self, data): + return ( + self.check_related('target_credential', Credential, data, role_field='admin_role') and + self.check_related('source_credential', Credential, data, role_field='use_role') + ) + + @check_superuser + def can_change(self, obj, data): + if self.can_add(data) is False: + return False + + return ( + self.user in obj.target_credential.admin_role and + self.user in obj.source_credential.use_role + ) + + @check_superuser + def can_delete(self, obj): + return self.user in obj.target_credential.admin_role + class TeamAccess(BaseAccess): ''' diff --git a/awx/main/credential_plugins/__init__.py b/awx/main/credential_plugins/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/awx/main/credential_plugins/aim.py b/awx/main/credential_plugins/aim.py new file mode 100644 index 0000000000..d59fcb639d --- /dev/null +++ b/awx/main/credential_plugins/aim.py @@ -0,0 +1,124 @@ +from .plugin import CredentialPlugin + +import os +import stat +import tempfile +import threading +from urllib.parse import quote, urlencode, urljoin + +from django.utils.translation import ugettext_lazy as _ +import requests + + +aim_inputs = { + 'fields': [{ + 'id': 'url', + 'label': _('CyberArk AIM URL'), + 'type': 'string', + }, { + 'id': 'app_id', + 'label': _('Application ID'), + 'type': 'string', + 'secret': True, + }, { + 'id': 'client_key', + 'label': _('Client Key'), + 'type': 'string', + 'secret': True, + 'multiline': True, + }, { + 'id': 'client_cert', + 'label': _('Client Certificate'), + 'type': 'string', + 'secret': True, + 'multiline': True, + }, { + 'id': 'verify', + 'label': _('Verify SSL Certificates'), + 'type': 'boolean', + 'default': True, + }], + 'metadata': [{ + 'id': 'object_query', + 'label': _('Object Query'), + 'type': 'string', + 'help_text': _('Lookup query for the object. Ex: "Safe=TestSafe;Object=testAccountName123"'), + }, { + 'id': 'object_query_format', + 'label': _('Object Query Format'), + 'type': 'string', + 'default': 'Exact', + 'choices': ['Exact', 'Regexp'] + }, { + 'id': 'reason', + 'label': _('Reason'), + 'type': 'string', + 'help_text': _('Object request reason. This is only needed if it is required by the object\'s policy.') + }], + 'required': ['url', 'app_id', 'object_query'], +} + + +def create_temporary_fifo(data): + """Open fifo named pipe in a new thread using a temporary file path. The + thread blocks until data is read from the pipe. + + Returns the path to the fifo. + + :param data(bytes): Data to write to the pipe. + """ + path = os.path.join(tempfile.mkdtemp(), next(tempfile._get_candidate_names())) + os.mkfifo(path, stat.S_IRUSR | stat.S_IWUSR) + + threading.Thread( + target=lambda p, d: open(p, 'wb').write(d), + args=(path, data) + ).start() + return path + + +def aim_backend(**kwargs): + url = kwargs['url'] + client_cert = kwargs.get('client_cert', None) + client_key = kwargs.get('client_key', None) + verify = kwargs['verify'] + app_id = kwargs['app_id'] + object_query = kwargs['object_query'] + object_query_format = kwargs['object_query_format'] + reason = kwargs.get('reason', None) + + query_params = { + 'AppId': app_id, + 'Query': object_query, + 'QueryFormat': object_query_format, + } + if reason: + query_params['reason'] = reason + + request_qs = '?' + urlencode(query_params, quote_via=quote) + request_url = urljoin(url, '/'.join(['AIMWebService', 'api', 'Accounts'])) + + cert = None + if client_cert and client_key: + cert = ( + create_temporary_fifo(client_cert.encode()), + create_temporary_fifo(client_key.encode()) + ) + elif client_cert: + cert = create_temporary_fifo(client_cert.encode()) + + res = requests.get( + request_url + request_qs, + timeout=30, + cert=cert, + verify=verify, + ) + res.raise_for_status() + return res.json()['Content'] + + +aim_plugin = CredentialPlugin( + 'CyberArk AIM Secret Lookup', + inputs=aim_inputs, + backend=aim_backend +) diff --git a/awx/main/credential_plugins/azure_kv.py b/awx/main/credential_plugins/azure_kv.py new file mode 100644 index 0000000000..ee193974d0 --- /dev/null +++ b/awx/main/credential_plugins/azure_kv.py @@ -0,0 +1,64 @@ +from .plugin import CredentialPlugin + +from django.utils.translation import ugettext_lazy as _ +from azure.keyvault import KeyVaultClient, KeyVaultAuthentication +from azure.common.credentials import ServicePrincipalCredentials + + +azure_keyvault_inputs = { + 'fields': [{ + 'id': 'url', + 'label': _('Vault URL (DNS Name)'), + 'type': 'string', + }, { + 'id': 'client', + 'label': _('Client ID'), + 'type': 'string' + }, { + 'id': 'secret', + 'label': _('Client Secret'), + 'type': 'string', + 'secret': True, + }, { + 'id': 'tenant', + 'label': _('Tenant ID'), + 'type': 'string' + }], + 'metadata': [{ + 'id': 'secret_field', + 'label': _('Secret Name'), + 'type': 'string', + 'help_text': _('The name of the secret to look up.'), + }, { + 'id': 'secret_version', + 'label': _('Secret Version'), + 'type': 'string', + 'help_text': _('Used to specify a specific secret version (if left empty, the latest version will be used).'), + }], + 'required': ['url', 'client', 'secret', 'tenant', 'secret_field'], +} + + +def azure_keyvault_backend(**kwargs): + url = kwargs['url'] + + def auth_callback(server, resource, scope): + credentials = ServicePrincipalCredentials( + url = url, + client_id = kwargs['client'], + secret = kwargs['secret'], + tenant = kwargs['tenant'], + resource = "https://vault.azure.net", + ) + token = credentials.token + return token['token_type'], token['access_token'] + + kv = KeyVaultClient(KeyVaultAuthentication(auth_callback)) + return kv.get_secret(url, kwargs['secret_field'], kwargs.get('secret_version', '')).value + + +azure_keyvault_plugin = CredentialPlugin( + 'Microsoft Azure Key Vault', + inputs=azure_keyvault_inputs, + backend=azure_keyvault_backend +) diff --git a/awx/main/credential_plugins/conjur.py b/awx/main/credential_plugins/conjur.py new file mode 100644 index 0000000000..e7bcf9c859 --- /dev/null +++ b/awx/main/credential_plugins/conjur.py @@ -0,0 +1,120 @@ +from .plugin import CredentialPlugin + +import base64 +import os +import stat +import tempfile +import threading +from urllib.parse import urljoin, quote_plus + +from django.utils.translation import ugettext_lazy as _ +import requests + + +conjur_inputs = { + 'fields': [{ + 'id': 'url', + 'label': _('Conjur URL'), + 'type': 'string', + }, { + 'id': 'api_key', + 'label': _('API Key'), + 'type': 'string', + 'secret': True, + }, { + 'id': 'account', + 'label': _('Account'), + 'type': 'string', + }, { + 'id': 'username', + 'label': _('Username'), + 'type': 'string', + }, { + 'id': 'cacert', + 'label': _('Public Key Certificate'), + 'type': 'string', + 'multiline': True + }], + 'metadata': [{ + 'id': 'secret_path', + 'label': _('Secret Identifier'), + 'type': 'string', + 'help_text': _('The identifier for the secret e.g., /some/identifier'), + }, { + 'id': 'secret_version', + 'label': _('Secret Version'), + 'type': 'string', + 'help_text': _('Used to specify a specific secret version (if left empty, the latest version will be used).'), + }], + 'required': ['url', 'api_key', 'account', 'username'], +} + + +def create_temporary_fifo(data): + """Open fifo named pipe in a new thread using a temporary file path. The + thread blocks until data is read from the pipe. + + Returns the path to the fifo. + + :param data(bytes): Data to write to the pipe. + """ + path = os.path.join(tempfile.mkdtemp(), next(tempfile._get_candidate_names())) + os.mkfifo(path, stat.S_IRUSR | stat.S_IWUSR) + + threading.Thread( + target=lambda p, d: open(p, 'wb').write(d), + args=(path, data) + ).start() + return path + + +def conjur_backend(**kwargs): + url = kwargs['url'] + api_key = kwargs['api_key'] + account = quote_plus(kwargs['account']) + username = quote_plus(kwargs['username']) + secret_path = quote_plus(kwargs['secret_path']) + version = kwargs.get('secret_version') + cacert = kwargs.get('cacert', None) + + auth_kwargs = { + 'headers': {'Content-Type': 'text/plain'}, + 'data': api_key + } + if cacert: + auth_kwargs['verify'] = create_temporary_fifo(cacert.encode()) + + # https://www.conjur.org/api.html#authentication-authenticate-post + resp = requests.post( + urljoin(url, '/'.join(['authn', account, username, 'authenticate'])), + **auth_kwargs + ) + resp.raise_for_status() + token = base64.b64encode(resp.content).decode('utf-8') + + lookup_kwargs = { + 'headers': {'Authorization': 'Token token="{}"'.format(token)}, + } + if cacert: + lookup_kwargs['verify'] = create_temporary_fifo(cacert.encode()) + + # https://www.conjur.org/api.html#secrets-retrieve-a-secret-get + path = urljoin(url, '/'.join([ + 'secrets', + account, + 'variable', + secret_path + ])) + if version: + path = '?'.join([path, version]) + + resp = requests.get(path, timeout=30, **lookup_kwargs) + resp.raise_for_status() + return resp.text + + +conjur_plugin = CredentialPlugin( + 'CyberArk Conjur Secret Lookup', + inputs=conjur_inputs, + backend=conjur_backend +) diff --git a/awx/main/credential_plugins/hashivault.py b/awx/main/credential_plugins/hashivault.py new file mode 100644 index 0000000000..fefa46cbae --- /dev/null +++ b/awx/main/credential_plugins/hashivault.py @@ -0,0 +1,151 @@ +import copy +import os +import pathlib +from urllib.parse import urljoin + +from .plugin import CredentialPlugin + +import requests +from django.utils.translation import ugettext_lazy as _ + + +base_inputs = { + 'fields': [{ + 'id': 'url', + 'label': _('Server URL'), + 'type': 'string', + 'help_text': _('The URL to the HashiCorp Vault'), + }, { + 'id': 'token', + 'label': _('Token'), + 'type': 'string', + 'secret': True, + 'help_text': _('The access token used to authenticate to the Vault server'), + }], + 'metadata': [{ + 'id': 'secret_path', + 'label': _('Path to Secret'), + 'type': 'string', + 'help_text': _('The path to the secret e.g., /some-engine/some-secret/'), + }], + 'required': ['url', 'token', 'secret_path'], +} + +hashi_kv_inputs = copy.deepcopy(base_inputs) +hashi_kv_inputs['fields'].append({ + 'id': 'api_version', + 'label': _('API Version'), + 'choices': ['v1', 'v2'], + 'help_text': _('API v1 is for static key/value lookups. API v2 is for versioned key/value lookups.'), + 'default': 'v1', +}) +hashi_kv_inputs['metadata'].extend([{ + 'id': 'secret_key', + 'label': _('Key Name'), + 'type': 'string', + 'help_text': _('The name of the key to look up in the secret.'), +}, { + 'id': 'secret_version', + 'label': _('Secret Version (v2 only)'), + 'type': 'string', + 'help_text': _('Used to specify a specific secret version (if left empty, the latest version will be used).'), +}]) +hashi_kv_inputs['required'].extend(['api_version', 'secret_key']) + +hashi_ssh_inputs = copy.deepcopy(base_inputs) +hashi_ssh_inputs['metadata'] = [{ + 'id': 'public_key', + 'label': _('Unsigned Public Key'), + 'type': 'string', + 'multiline': True, +}] + hashi_ssh_inputs['metadata'] + [{ + 'id': 'role', + 'label': _('Role Name'), + 'type': 'string', + 'help_text': _('The name of the role used to sign.') +}, { + 'id': 'valid_principals', + 'label': _('Valid Principals'), + 'type': 'string', + 'help_text': _('Valid principals (either usernames or hostnames) that the certificate should be signed for.'), +}] +hashi_ssh_inputs['required'].extend(['public_key', 'role']) + + +def kv_backend(**kwargs): + token = kwargs['token'] + url = urljoin(kwargs['url'], 'v1') + secret_path = kwargs['secret_path'] + secret_key = kwargs.get('secret_key', None) + + api_version = kwargs['api_version'] + + sess = requests.Session() + sess.headers['Authorization'] = 'Bearer {}'.format(token) + if api_version == 'v2': + params = {} + if kwargs.get('secret_version'): + params['version'] = kwargs['secret_version'] + try: + mount_point, *path = pathlib.Path(secret_path.lstrip(os.sep)).parts + '/'.join(*path) + except Exception: + mount_point, path = secret_path, [] + # https://www.vaultproject.io/api/secret/kv/kv-v2.html#read-secret-version + response = sess.get( + '/'.join([url, mount_point, 'data'] + path).rstrip('/'), + params=params, + timeout=30 + ) + response.raise_for_status() + json = response.json()['data'] + else: + # https://www.vaultproject.io/api/secret/kv/kv-v1.html#read-secret + response = sess.get('/'.join([url, secret_path]).rstrip('/'), timeout=30) + response.raise_for_status() + json = response.json() + + if secret_key: + try: + return json['data'][secret_key] + except KeyError: + raise RuntimeError( + '{} is not present at {}'.format(secret_key, secret_path) + ) + return json['data'] + + +def ssh_backend(**kwargs): + token = kwargs['token'] + url = urljoin(kwargs['url'], 'v1') + secret_path = kwargs['secret_path'] + role = kwargs['role'] + + sess = requests.Session() + sess.headers['Authorization'] = 'Bearer {}'.format(token) + json = { + 'public_key': kwargs['public_key'] + } + if kwargs.get('valid_principals'): + json['valid_principals'] = kwargs['valid_principals'] + # https://www.vaultproject.io/api/secret/ssh/index.html#sign-ssh-key + resp = sess.post( + '/'.join([url, secret_path, 'sign', role]).rstrip('/'), + json=json, + timeout=30 + ) + resp.raise_for_status() + return resp.json()['data']['signed_key'] + + +hashivault_kv_plugin = CredentialPlugin( + 'HashiCorp Vault Secret Lookup', + inputs=hashi_kv_inputs, + backend=kv_backend +) + +hashivault_ssh_plugin = CredentialPlugin( + 'HashiCorp Vault Signed SSH', + inputs=hashi_ssh_inputs, + backend=ssh_backend +) diff --git a/awx/main/credential_plugins/plugin.py b/awx/main/credential_plugins/plugin.py new file mode 100644 index 0000000000..c5edde7bc1 --- /dev/null +++ b/awx/main/credential_plugins/plugin.py @@ -0,0 +1,3 @@ +from collections import namedtuple + +CredentialPlugin = namedtuple('CredentialPlugin', ['name', 'inputs', 'backend']) diff --git a/awx/main/fields.py b/awx/main/fields.py index f8a20738ef..7f77d5632a 100644 --- a/awx/main/fields.py +++ b/awx/main/fields.py @@ -480,6 +480,69 @@ def format_ssh_private_key(value): return True +class DynamicCredentialInputField(JSONSchemaField): + """ + Used to validate JSON for + `awx.main.models.credential:CredentialInputSource().metadata`. + + Metadata for input sources is represented as a dictionary e.g., + {'secret_path': '/kv/somebody', 'secret_key': 'password'} + + For the data to be valid, the keys of this dictionary should correspond + with the metadata field (and datatypes) defined in the associated + target CredentialType e.g., + """ + + def schema(self, credential_type): + # determine the defined fields for the associated credential type + properties = {} + for field in credential_type.inputs.get('metadata', []): + field = field.copy() + properties[field['id']] = field + if field.get('choices', []): + field['enum'] = list(field['choices'])[:] + return { + 'type': 'object', + 'properties': properties, + 'additionalProperties': False, + } + + def validate(self, value, model_instance): + if not isinstance(value, dict): + return super(DynamicCredentialInputField, self).validate(value, model_instance) + + super(JSONSchemaField, self).validate(value, model_instance) + credential_type = model_instance.source_credential.credential_type + errors = {} + for error in Draft4Validator( + self.schema(credential_type), + format_checker=self.format_checker + ).iter_errors(value): + if error.validator == 'pattern' and 'error' in error.schema: + error.message = error.schema['error'].format(instance=error.instance) + if 'id' not in error.schema: + # If the error is not for a specific field, it's specific to + # `inputs` in general + raise django_exceptions.ValidationError( + error.message, + code='invalid', + params={'value': value}, + ) + errors[error.schema['id']] = [error.message] + + defined_metadata = [field.get('id') for field in credential_type.inputs.get('metadata', [])] + for field in credential_type.inputs.get('required', []): + if field in defined_metadata and not value.get(field, None): + errors[field] = [_('required for %s') % ( + credential_type.name + )] + + if errors: + raise serializers.ValidationError({ + 'metadata': errors + }) + + class CredentialInputField(JSONSchemaField): """ Used to validate JSON for @@ -592,18 +655,13 @@ class CredentialInputField(JSONSchemaField): ) errors[error.schema['id']] = [error.message] - inputs = model_instance.credential_type.inputs - for field in inputs.get('required', []): - if not value.get(field, None): - errors[field] = [_('required for %s') % ( - model_instance.credential_type.name - )] + defined_fields = model_instance.credential_type.defined_fields # `ssh_key_unlock` requirements are very specific and can't be # represented without complicated JSON schema if ( model_instance.credential_type.managed_by_tower is True and - 'ssh_key_unlock' in model_instance.credential_type.defined_fields + 'ssh_key_unlock' in defined_fields ): # in order to properly test the necessity of `ssh_key_unlock`, we diff --git a/awx/main/management/commands/setup_managed_credential_types.py b/awx/main/management/commands/setup_managed_credential_types.py new file mode 100644 index 0000000000..04e170528d --- /dev/null +++ b/awx/main/management/commands/setup_managed_credential_types.py @@ -0,0 +1,14 @@ +# Copyright (c) 2019 Ansible by Red Hat +# All Rights Reserved. + +from django.core.management.base import BaseCommand + +from awx.main.models import CredentialType + + +class Command(BaseCommand): + + help = 'Load default managed credential types.' + + def handle(self, *args, **options): + CredentialType.setup_tower_managed_defaults() diff --git a/awx/main/migrations/0067_v350_credential_plugins.py b/awx/main/migrations/0067_v350_credential_plugins.py new file mode 100644 index 0000000000..8d3a1f824f --- /dev/null +++ b/awx/main/migrations/0067_v350_credential_plugins.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import taggit.managers + +# AWX +import awx.main.fields +from awx.main.models import CredentialType + + +def setup_tower_managed_defaults(apps, schema_editor): + CredentialType.setup_tower_managed_defaults() + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('taggit', '0002_auto_20150616_2121'), + ('main', '0066_v350_inventorysource_custom_virtualenv'), + ] + + operations = [ + migrations.CreateModel( + name='CredentialInputSource', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(default=None, editable=False)), + ('modified', models.DateTimeField(default=None, editable=False)), + ('description', models.TextField(blank=True, default='')), + ('input_field_name', models.CharField(max_length=1024)), + ('metadata', awx.main.fields.DynamicCredentialInputField(blank=True, default={})), + ('created_by', models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{'class': 'credentialinputsource', 'model_name': 'credentialinputsource', 'app_label': 'main'}(class)s_created+", to=settings.AUTH_USER_MODEL)), + ('modified_by', models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{'class': 'credentialinputsource', 'model_name': 'credentialinputsource', 'app_label': 'main'}(class)s_modified+", to=settings.AUTH_USER_MODEL)), + ('source_credential', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='target_input_sources', to='main.Credential')), + ('tags', taggit.managers.TaggableManager(blank=True, help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags')), + ('target_credential', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='input_sources', to='main.Credential')), + ], + ), + migrations.AlterField( + model_name='credentialtype', + name='kind', + field=models.CharField(choices=[('ssh', 'Machine'), ('vault', 'Vault'), ('net', 'Network'), ('scm', 'Source Control'), ('cloud', 'Cloud'), ('insights', 'Insights'), ('external', 'External')], max_length=32), + ), + migrations.AlterUniqueTogether( + name='credentialinputsource', + unique_together=set([('target_credential', 'input_field_name')]), + ), + migrations.RunPython(setup_tower_managed_defaults), + ] diff --git a/awx/main/models/__init__.py b/awx/main/models/__init__.py index 848db3b593..2e4821dcff 100644 --- a/awx/main/models/__init__.py +++ b/awx/main/models/__init__.py @@ -16,7 +16,7 @@ from awx.main.models.organization import ( # noqa Organization, Profile, Team, UserSessionMembership ) from awx.main.models.credential import ( # noqa - Credential, CredentialType, ManagedCredentialType, V1Credential, build_safe_env + Credential, CredentialType, CredentialInputSource, ManagedCredentialType, V1Credential, build_safe_env ) from awx.main.models.projects import Project, ProjectUpdate # noqa from awx.main.models.inventory import ( # noqa diff --git a/awx/main/models/credential/__init__.py b/awx/main/models/credential/__init__.py index 1db1cedc44..1beec8cc58 100644 --- a/awx/main/models/credential/__init__.py +++ b/awx/main/models/credential/__init__.py @@ -4,6 +4,7 @@ import functools import inspect import logging import os +from pkg_resources import iter_entry_points import re import stat import tempfile @@ -17,16 +18,22 @@ from django.db import models from django.utils.translation import ugettext_lazy as _, ugettext_noop from django.core.exceptions import ValidationError from django.utils.encoding import force_text +from django.utils.functional import cached_property # AWX from awx.api.versioning import reverse from awx.main.fields import (ImplicitRoleField, CredentialInputField, CredentialTypeInputField, - CredentialTypeInjectorField) + CredentialTypeInjectorField, + DynamicCredentialInputField,) from awx.main.utils import decrypt_field, classproperty from awx.main.utils.safe_yaml import safe_dump from awx.main.validators import validate_ssh_private_key -from awx.main.models.base import CommonModelNameNotUnique, PasswordFieldsModel +from awx.main.models.base import ( + CommonModelNameNotUnique, + PasswordFieldsModel, + PrimordialModel +) from awx.main.models.mixins import ResourceMixin from awx.main.models.rbac import ( ROLE_SINGLETON_SYSTEM_ADMINISTRATOR, @@ -35,9 +42,13 @@ from awx.main.models.rbac import ( from awx.main.utils import encrypt_field from . import injectors as builtin_injectors -__all__ = ['Credential', 'CredentialType', 'V1Credential', 'build_safe_env'] +__all__ = ['Credential', 'CredentialType', 'CredentialInputSource', 'V1Credential', 'build_safe_env'] logger = logging.getLogger('awx.main.models.credential') +credential_plugins = dict( + (ep.name, ep.load()) + for ep in iter_entry_points('awx.credential_plugins') +) HIDDEN_PASSWORD = '**********' @@ -220,7 +231,6 @@ class V1Credential(object): } - class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin): ''' A credential contains information about how to talk to a remote resource @@ -364,6 +374,10 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin): needed.append('vault_password') return needed + @cached_property + def dynamic_input_fields(self): + return [obj.input_field_name for obj in self.input_sources.all()] + def _password_field_allows_ask(self, field): return field in self.credential_type.askable_fields @@ -441,6 +455,8 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin): :param field_name(str): The name of the input field. :param default(optional[str]): A default return value to use. """ + if self.kind != 'external' and field_name in self.dynamic_input_fields: + return self._get_dynamic_input(field_name) if field_name in self.credential_type.secret_fields: try: return decrypt_field(self, field_name) @@ -461,8 +477,17 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin): raise AttributeError(field_name) def has_input(self, field_name): + if field_name in self.dynamic_input_fields: + return True return field_name in self.inputs and self.inputs[field_name] not in ('', None) + def _get_dynamic_input(self, field_name): + for input_source in self.input_sources.all(): + if input_source.input_field_name == field_name: + return input_source.get_input_value() + else: + raise ValueError('{} is not a dynamic input field'.format(field_name)) + class CredentialType(CommonModelNameNotUnique): ''' @@ -484,6 +509,7 @@ class CredentialType(CommonModelNameNotUnique): ('scm', _('Source Control')), ('cloud', _('Cloud')), ('insights', _('Insights')), + ('external', _('External')), ) kind = models.CharField( @@ -552,6 +578,16 @@ class CredentialType(CommonModelNameNotUnique): if field.get('ask_at_runtime', False) is True ] + @property + def plugin(self): + if self.kind != 'external': + raise AttributeError('plugin') + [plugin] = [ + plugin for ns, plugin in credential_plugins.items() + if ns == self.namespace + ] + return plugin + def default_for_field(self, field_id): for field in self.inputs.get('fields', []): if field['id'] == field_id: @@ -583,6 +619,15 @@ class CredentialType(CommonModelNameNotUnique): created.inputs = created.injectors = {} created.save() + @classmethod + def load_plugin(cls, ns, plugin): + ManagedCredentialType( + namespace=ns, + name=plugin.name, + kind='external', + inputs=plugin.inputs + ) + @classmethod def from_v1_kind(cls, kind, data={}): match = None @@ -653,15 +698,15 @@ class CredentialType(CommonModelNameNotUnique): # build a normal namespace with secret values decrypted (for # ansible-playbook) and a safe namespace with secret values hidden (for # DB storage) - for field_name, value in credential.inputs.items(): + injectable_fields = list(credential.inputs.keys()) + credential.dynamic_input_fields + for field_name in list(set(injectable_fields)): + value = credential.get_input(field_name) if type(value) is bool: - # boolean values can't be secret/encrypted + # boolean values can't be secret/encrypted/external safe_namespace[field_name] = namespace[field_name] = value continue - value = credential.get_input(field_name) - if field_name in self.secret_fields: safe_namespace[field_name] = '**********' elif len(value): @@ -774,6 +819,12 @@ ManagedCredentialType( 'format': 'ssh_private_key', 'secret': True, 'multiline': True + }, { + 'id': 'ssh_public_key_data', + 'label': ugettext_noop('Signed SSH Certificate'), + 'type': 'string', + 'multiline': True, + 'secret': True, }, { 'id': 'ssh_key_unlock', 'label': ugettext_noop('Private Key Passphrase'), @@ -1253,3 +1304,70 @@ ManagedCredentialType( } }, ) + + +class CredentialInputSource(PrimordialModel): + + class Meta: + app_label = 'main' + unique_together = (('target_credential', 'input_field_name'),) + + target_credential = models.ForeignKey( + 'Credential', + related_name='input_sources', + on_delete=models.CASCADE, + null=True, + ) + source_credential = models.ForeignKey( + 'Credential', + related_name='target_input_sources', + on_delete=models.CASCADE, + null=True, + ) + input_field_name = models.CharField( + max_length=1024, + ) + metadata = DynamicCredentialInputField( + blank=True, + default={} + ) + + def clean_target_credential(self): + if self.target_credential.kind == 'external': + raise ValidationError(_('Target must be a non-external credential')) + return self.target_credential + + def clean_source_credential(self): + if self.source_credential.kind != 'external': + raise ValidationError(_('Source must be an external credential')) + return self.source_credential + + def clean_input_field_name(self): + defined_fields = self.target_credential.credential_type.defined_fields + if self.input_field_name not in defined_fields: + raise ValidationError(_( + 'Input field must be defined on target credential (options are {}).'.format( + ', '.join(sorted(defined_fields)) + ) + )) + return self.input_field_name + + def get_input_value(self): + backend = self.source_credential.credential_type.plugin.backend + backend_kwargs = {} + for field_name, value in self.source_credential.inputs.items(): + if field_name in self.source_credential.credential_type.secret_fields: + backend_kwargs[field_name] = decrypt_field(self.source_credential, field_name) + else: + backend_kwargs[field_name] = value + + backend_kwargs.update(self.metadata) + 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 ns, plugin in credential_plugins.items(): + CredentialType.load_plugin(ns, plugin) diff --git a/awx/main/models/unified_jobs.py b/awx/main/models/unified_jobs.py index d30da550f4..1b341a5476 100644 --- a/awx/main/models/unified_jobs.py +++ b/awx/main/models/unified_jobs.py @@ -1230,6 +1230,23 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique self.save(update_fields=['job_explanation']) return (False, None) + # verify that any associated credentials aren't missing required field data + missing_credential_inputs = [] + for credential in self.credentials.all(): + defined_fields = credential.credential_type.defined_fields + for required in credential.credential_type.inputs.get('required', []): + if required in defined_fields and not credential.has_input(required): + missing_credential_inputs.append(required) + + if missing_credential_inputs: + self.job_explanation = '{} cannot start because Credential {} does not provide one or more required fields ({}).'.format( + self._meta.verbose_name.title(), + credential.name, + ', '.join(sorted(missing_credential_inputs)) + ) + self.save(update_fields=['job_explanation']) + return (False, None) + needed = self.get_passwords_needed_to_start() try: start_args = json.loads(decrypt_field(self, 'start_args')) diff --git a/awx/main/tasks.py b/awx/main/tasks.py index a95074b19d..ede7e04de9 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -772,7 +772,12 @@ class BaseTask(object): 'credentials': { : '/path/to/decrypted/data', : '/path/to/decrypted/data', - : '/path/to/decrypted/data', + ... + }, + 'certificates': { + : /path/to/signed/ssh/certificate, + : /path/to/signed/ssh/certificate, + ... } } ''' @@ -787,7 +792,6 @@ class BaseTask(object): # and we're running an earlier version (<6.5). if 'OPENSSH PRIVATE KEY' in data and not openssh_keys_supported: raise RuntimeError(OPENSSH_KEY_ERROR) - for credential, data in private_data.get('credentials', {}).items(): # OpenSSH formatted keys must have a trailing newline to be # accepted by ssh-add. if 'OPENSSH PRIVATE KEY' in data and not data.endswith('\n'): @@ -813,6 +817,13 @@ class BaseTask(object): f.close() os.chmod(path, stat.S_IRUSR | stat.S_IWUSR) private_data_files['credentials'][credential] = path + for credential, data in private_data.get('certificates', {}).items(): + name = 'credential_%d-cert.pub' % credential.pk + path = os.path.join(private_data_dir, name) + with open(path, 'w') as f: + f.write(data) + f.close() + os.chmod(path, stat.S_IRUSR | stat.S_IWUSR) return private_data_files def build_passwords(self, instance, runtime_passwords): @@ -1269,16 +1280,23 @@ class RunJob(BaseTask): 'credentials': { : , : , - : + ... + }, + 'certificates': { + : , + : , + ... } } ''' private_data = {'credentials': {}} - for credential in job.credentials.all(): + for credential in job.credentials.prefetch_related('input_sources__source_credential').all(): # If we were sent SSH credentials, decrypt them and send them # back (they will be written to a temporary file). if credential.has_input('ssh_key_data'): private_data['credentials'][credential] = credential.get_input('ssh_key_data', default='') + if credential.has_input('ssh_public_key_data'): + private_data.setdefault('certificates', {})[credential] = credential.get_input('ssh_public_key_data', default='') if credential.kind == 'openstack': openstack_auth = dict(auth_url=credential.get_input('host', default=''), @@ -1503,7 +1521,7 @@ class RunJob(BaseTask): return self._write_extra_vars_file(private_data_dir, extra_vars, safe_dict) def build_credentials_list(self, job): - return job.credentials.all() + return job.credentials.prefetch_related('input_sources__source_credential').all() def get_password_prompts(self, passwords={}): d = super(RunJob, self).get_password_prompts(passwords) @@ -2187,7 +2205,12 @@ class RunAdHocCommand(BaseTask): 'credentials': { : , : , - : + ... + }, + 'certificates': { + : , + : , + ... } } ''' @@ -2197,6 +2220,8 @@ class RunAdHocCommand(BaseTask): private_data = {'credentials': {}} if creds and creds.has_input('ssh_key_data'): private_data['credentials'][creds] = creds.get_input('ssh_key_data', default='') + if creds and creds.has_input('ssh_public_key_data'): + private_data.setdefault('certificates', {})[creds] = creds.get_input('ssh_public_key_data', default='') return private_data def build_passwords(self, ad_hoc_command, runtime_passwords): diff --git a/awx/main/tests/conftest.py b/awx/main/tests/conftest.py index ed4acd4b48..5b660b29ca 100644 --- a/awx/main/tests/conftest.py +++ b/awx/main/tests/conftest.py @@ -4,6 +4,7 @@ import pytest from unittest import mock from contextlib import contextmanager +from awx.main.models import Credential from awx.main.tests.factories import ( create_organization, create_job_template, @@ -139,3 +140,11 @@ 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. + # We mock that behavior out of credentials by default unless we need to + # test it explicitly. + with mock.patch.object(Credential, 'dynamic_input_fields', new=[]) as _fixture: + yield _fixture diff --git a/awx/main/tests/functional/api/test_credential.py b/awx/main/tests/functional/api/test_credential.py index 98e4a5b0a7..2cb287b044 100644 --- a/awx/main/tests/functional/api/test_credential.py +++ b/awx/main/tests/functional/api/test_credential.py @@ -763,7 +763,7 @@ def test_falsey_field_data(get, post, organization, admin, field_value): 'credential_type': net.pk, 'organization': organization.id, 'inputs': { - 'username': 'joe-user', # username is required + 'username': 'joe-user', 'authorize': field_value } } @@ -952,9 +952,15 @@ def test_vault_password_required(post, organization, admin): }, admin ) - assert response.status_code == 400 - assert response.data['inputs'] == {'vault_password': ['required for Vault']} - assert Credential.objects.count() == 0 + assert response.status_code == 201 + assert Credential.objects.count() == 1 + + # vault_password must be specified by launch time + j = Job() + j.save() + j.credentials.add(Credential.objects.first()) + assert j.pre_start() == (False, None) + assert 'required fields (vault_password)' in j.job_explanation # @@ -1236,14 +1242,15 @@ def test_aws_create_fail_required_fields(post, organization, admin, version, par params, admin ) - assert response.status_code == 400 + assert response.status_code == 201 + assert Credential.objects.count() == 1 - assert Credential.objects.count() == 0 - errors = response.data - if version == 'v2': - errors = response.data['inputs'] - assert errors['username'] == ['required for %s' % aws.name] - assert errors['password'] == ['required for %s' % aws.name] + # username and password must be specified by launch time + j = Job() + j.save() + j.credentials.add(Credential.objects.first()) + assert j.pre_start() == (False, None) + assert 'required fields (password, username)' in j.job_explanation # @@ -1307,15 +1314,15 @@ def test_vmware_create_fail_required_fields(post, organization, admin, version, params, admin ) - assert response.status_code == 400 + assert response.status_code == 201 + assert Credential.objects.count() == 1 - assert Credential.objects.count() == 0 - errors = response.data - if version == 'v2': - errors = response.data['inputs'] - assert errors['username'] == ['required for %s' % vmware.name] - assert errors['password'] == ['required for %s' % vmware.name] - assert errors['host'] == ['required for %s' % vmware.name] + # username, password, and host must be specified by launch time + j = Job() + j.save() + j.credentials.add(Credential.objects.first()) + assert j.pre_start() == (False, None) + assert 'required fields (host, password, username)' in j.job_explanation # @@ -1406,14 +1413,14 @@ def test_openstack_create_fail_required_fields(post, organization, admin, versio params, admin ) - assert response.status_code == 400 - errors = response.data - if version == 'v2': - errors = response.data['inputs'] - assert errors['username'] == ['required for %s' % openstack.name] - assert errors['password'] == ['required for %s' % openstack.name] - assert errors['host'] == ['required for %s' % openstack.name] - assert errors['project'] == ['required for %s' % openstack.name] + assert response.status_code == 201 + + # username, password, host, and project must be specified by launch time + j = Job() + j.save() + j.credentials.add(Credential.objects.first()) + assert j.pre_start() == (False, None) + assert 'required fields (host, password, project, username)' in j.job_explanation @pytest.mark.django_db 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..a013c79ee2 --- /dev/null +++ b/awx/main/tests/functional/api/test_credential_input_sources.py @@ -0,0 +1,378 @@ +import pytest + +from awx.main.models import CredentialInputSource +from awx.api.versioning import reverse + + +@pytest.mark.django_db +def test_associate_credential_input_source(get, post, delete, admin, vault_credential, external_credential): + list_url = reverse( + 'api:credential_input_source_list', + kwargs={'version': 'v2'} + ) + + # attach + params = { + 'target_credential': vault_credential.pk, + 'source_credential': external_credential.pk, + 'input_field_name': 'vault_password', + 'metadata': {'key': 'some_example_key'} + } + response = post(list_url, params, admin) + assert response.status_code == 201 + + detail = get(response.data['url'], admin) + assert detail.status_code == 200 + + response = get(list_url, admin) + assert response.status_code == 200 + assert response.data['count'] == 1 + assert CredentialInputSource.objects.count() == 1 + input_source = CredentialInputSource.objects.first() + assert input_source.metadata == {'key': 'some_example_key'} + + # detach + response = delete( + reverse( + 'api:credential_input_source_detail', + kwargs={'version': 'v2', 'pk': detail.data['id']} + ), + admin + ) + assert response.status_code == 204 + + response = get(list_url, admin) + assert response.status_code == 200 + assert response.data['count'] == 0 + assert CredentialInputSource.objects.count() == 0 + + +@pytest.mark.django_db +@pytest.mark.parametrize('metadata', [ + {}, # key is required + {'key': None}, # must be a string + {'key': 123}, # must be a string + {'extraneous': 'foo'}, # invalid parameter +]) +def test_associate_credential_input_source_with_invalid_metadata(get, post, admin, vault_credential, external_credential, metadata): + list_url = reverse( + 'api:credential_input_source_list', + kwargs={'version': 'v2'}, + ) + + params = { + 'target_credential': vault_credential.pk, + 'source_credential': external_credential.pk, + 'input_field_name': 'vault_password', + 'metadata': metadata, + } + response = post(list_url, params, admin) + assert response.status_code == 400 + assert b'metadata' in response.content + + +@pytest.mark.django_db +def test_create_from_list(get, post, admin, vault_credential, external_credential): + params = { + 'source_credential': external_credential.pk, + 'target_credential': vault_credential.pk, + 'input_field_name': 'vault_password', + 'metadata': {'key': 'some_example_key'}, + } + assert post(reverse( + 'api:credential_input_source_list', + kwargs={'version': 'v2'} + ), params, admin).status_code == 201 + assert CredentialInputSource.objects.count() == 1 + + +@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 = { + 'target_credential': other_external_credential.pk, + 'source_credential': external_credential.pk, + 'input_field_name': 'token', + 'metadata': {'key': 'some_key'}, + } + 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_input_source_rbac_associate(get, post, delete, alice, vault_credential, external_credential): + list_url = reverse( + 'api:credential_input_source_list', + kwargs={'version': 'v2'} + ) + params = { + 'target_credential': vault_credential.pk, + 'source_credential': external_credential.pk, + 'input_field_name': 'vault_password', + 'metadata': {'key': 'some_key'}, + } + + # alice can't admin the target *or* source cred + response = post(list_url, params, alice) + assert response.status_code == 403 + + # alice can't use the source cred + vault_credential.admin_role.members.add(alice) + response = post(list_url, params, alice) + assert response.status_code == 403 + + # alice is allowed to associate now + external_credential.use_role.members.add(alice) + response = post(list_url, params, alice) + assert response.status_code == 201 + + # now let's try disassociation + detail = get(response.data['url'], alice) + assert detail.status_code == 200 + vault_credential.admin_role.members.remove(alice) + external_credential.use_role.members.remove(alice) + + # now that permissions are removed, alice can't *read* the input source + assert get(response.data['url'], alice).status_code == 403 + + # alice can't admin the target (so she can't remove the input source) + delete_url = reverse( + 'api:credential_input_source_detail', + kwargs={'version': 'v2', 'pk': detail.data['id']} + ) + response = delete(delete_url, alice) + assert response.status_code == 403 + + # alice is allowed to disassociate now + vault_credential.admin_role.members.add(alice) + response = delete(delete_url, alice) + assert response.status_code == 204 + + +@pytest.mark.django_db +def test_input_source_detail_rbac(get, post, patch, delete, admin, alice, + vault_credential, external_credential, + other_external_credential): + sublist_url = reverse( + 'api:credential_input_source_sublist', + kwargs={'version': 'v2', 'pk': vault_credential.pk} + ) + params = { + 'source_credential': external_credential.pk, + 'input_field_name': 'vault_password', + 'metadata': {'key': 'some_key'}, + } + + response = post(sublist_url, params, admin) + assert response.status_code == 201 + + url = response.data['url'] + + # alice can't read the input source directly because she can't read the target cred + detail = get(url, alice) + assert detail.status_code == 403 + + # alice can read the input source directly + vault_credential.read_role.members.add(alice) + detail = get(url, alice) + assert detail.status_code == 200 + + # she can also see it on the credential sublist + response = get(sublist_url, admin) + assert response.status_code == 200 + assert response.data['count'] == 1 + + # alice can't change or delete the input source because she can't change + # the target cred and she can't use the source cred + assert patch(url, {'input_field_name': 'vault_id'}, alice).status_code == 403 + assert delete(url, alice).status_code == 403 + + # alice still can't change the input source because she she can't use the + # source cred + vault_credential.admin_role.members.add(alice) + assert patch(url, {'input_field_name': 'vault_id'}, alice).status_code == 403 + + # alice can now admin the target cred and use the source cred, so she can + # change the input field name + external_credential.use_role.members.add(alice) + assert patch(url, {'input_field_name': 'vault_id'}, alice).status_code == 200 + assert CredentialInputSource.objects.first().input_field_name == 'vault_id' + + # she _cannot_, however, apply a source credential she doesn't have access to + assert patch(url, {'source_credential': other_external_credential.pk}, alice).status_code == 403 + + assert delete(url, alice).status_code == 204 + assert CredentialInputSource.objects.count() == 0 + + +@pytest.mark.django_db +def test_input_source_create_rbac(get, post, patch, delete, alice, + vault_credential, external_credential, + other_external_credential): + list_url = reverse( + 'api:credential_input_source_list', + kwargs={'version': 'v2'} + ) + params = { + 'target_credential': vault_credential.pk, + 'source_credential': external_credential.pk, + 'input_field_name': 'vault_password', + 'metadata': {'key': 'some_key'}, + } + + # alice can't create the inv source because she has access to neither credential + response = post(list_url, params, alice) + assert response.status_code == 403 + + # alice still can't because she can't use the source credential + vault_credential.admin_role.members.add(alice) + response = post(list_url, params, alice) + assert response.status_code == 403 + + # alice can create an input source if she has permissions on both credentials + external_credential.use_role.members.add(alice) + response = post(list_url, params, alice) + assert response.status_code == 201 + assert CredentialInputSource.objects.count() == 1 + + +@pytest.mark.django_db +def test_input_source_rbac_swap_target_credential(get, post, put, patch, admin, alice, + machine_credential, vault_credential, + external_credential): + # If you change the target credential for an input source, + # you have to have admin role on the *original* credential (so you can + # remove the relationship) *and* on the *new* credential (so you can apply the + # new relationship) + list_url = reverse( + 'api:credential_input_source_list', + kwargs={'version': 'v2'} + ) + params = { + 'target_credential': vault_credential.pk, + 'source_credential': external_credential.pk, + 'input_field_name': 'vault_password', + 'metadata': {'key': 'some_key'}, + } + + response = post(list_url, params, admin) + assert response.status_code == 201 + url = response.data['url'] + + # alice starts with use permission on the source credential + # alice starts with no permissions on the target credential + external_credential.admin_role.members.add(alice) + + # alice can't change target cred because she can't admin either one + assert patch(url, { + 'target_credential': machine_credential.pk, + 'input_field_name': 'password' + }, alice).status_code == 403 + + # alice still can't change target cred because she can't admin *the new one* + vault_credential.admin_role.members.add(alice) + assert patch(url, { + 'target_credential': machine_credential.pk, + 'input_field_name': 'password' + }, alice).status_code == 403 + + machine_credential.admin_role.members.add(alice) + assert patch(url, { + 'target_credential': machine_credential.pk, + 'input_field_name': 'password' + }, alice).status_code == 200 + + +@pytest.mark.django_db +def test_input_source_rbac_change_metadata(get, post, put, patch, admin, alice, + machine_credential, external_credential): + # To change an input source, a user must have admin permissions on the + # target credential and use permissions on the source credential. + list_url = reverse( + 'api:credential_input_source_list', + kwargs={'version': 'v2'} + ) + params = { + 'target_credential': machine_credential.pk, + 'source_credential': external_credential.pk, + 'input_field_name': 'password', + 'metadata': {'key': 'some_key'}, + } + + response = post(list_url, params, admin) + assert response.status_code == 201 + url = response.data['url'] + + # alice can't change input source metadata because she isn't an admin of the + # target credential and doesn't have use permission on the source credential + assert patch(url, { + 'metadata': {'key': 'some_other_key'} + }, alice).status_code == 403 + + # alice still can't change input source metadata because she doesn't have + # use permission on the source credential. + machine_credential.admin_role.members.add(alice) + assert patch(url, { + 'metadata': {'key': 'some_other_key'} + }, alice).status_code == 403 + + external_credential.use_role.members.add(alice) + assert patch(url, { + 'metadata': {'key': 'some_other_key'} + }, alice).status_code == 200 + + +@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 = { + 'target_credential': vault_credential.pk, + 'source_credential': 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 = { + 'target_credential': vault_credential.pk, + 'source_credential': external_credential.pk, + 'input_field_name': 'not_defined_for_credential_type', + 'metadata': {'key': 'some_key'} + } + 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 (options are vault_id, vault_password).'] + + +@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 = [{ + 'target_credential': vault_credential.pk, + 'source_credential': external_credential.pk, + 'input_field_name': 'vault_password' + }, { + 'target_credential': vault_credential.pk, + 'source_credential': other_external_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..625ff0c007 100644 --- a/awx/main/tests/functional/conftest.py +++ b/awx/main/tests/functional/conftest.py @@ -250,6 +250,42 @@ 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.' + }], + 'metadata': [{ + 'id': 'key', + 'label': 'Key', + 'type': 'string' + }, { + 'id': 'version', + 'label': 'Version', + 'type': 'string' + }], + 'required': ['url', 'token', 'key'], + } + 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 +329,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") diff --git a/awx/main/tests/functional/test_credential.py b/awx/main/tests/functional/test_credential.py index 4b3ff2d75e..4683dcbfde 100644 --- a/awx/main/tests/functional/test_credential.py +++ b/awx/main/tests/functional/test_credential.py @@ -75,10 +75,15 @@ GLqbpJyX2r3p/Rmo6mLY71SqpA== @pytest.mark.django_db def test_default_cred_types(): assert sorted(CredentialType.defaults.keys()) == [ + 'aim', 'aws', + 'azure_kv', 'azure_rm', 'cloudforms', + 'conjur', 'gce', + 'hashivault_kv', + 'hashivault_ssh', 'insights', 'net', 'openstack', @@ -181,7 +186,7 @@ def test_ssh_key_data_validation(organization, kind, ssh_key_data, ssh_key_unloc @pytest.mark.django_db @pytest.mark.parametrize('inputs, valid', [ ({'vault_password': 'some-pass'}, True), - ({}, False), + ({}, True), ({'vault_password': 'dev-pass', 'vault_id': 'dev'}, True), ({'vault_password': 'dev-pass', 'vault_id': 'dev@prompt'}, False), # @ not allowed ]) diff --git a/awx/main/tests/unit/test_tasks.py b/awx/main/tests/unit/test_tasks.py index ca4172cc67..07a6959f58 100644 --- a/awx/main/tests/unit/test_tasks.py +++ b/awx/main/tests/unit/test_tasks.py @@ -700,7 +700,8 @@ class TestJobCredentials(TestJobExecution): __iter__ = lambda *args: iter(job._credentials), first = lambda: job._credentials[0] ), - 'spec_set': ['all', 'add', 'filter'] + 'prefetch_related': lambda _: credentials_mock, + 'spec_set': ['all', 'add', 'filter', 'prefetch_related'], }) with mock.patch.object(UnifiedJob, 'credentials', credentials_mock): diff --git a/awx/ui/client/features/_index.less b/awx/ui/client/features/_index.less index b7165c94c9..2769b29de7 100644 --- a/awx/ui/client/features/_index.less +++ b/awx/ui/client/features/_index.less @@ -1,5 +1,6 @@ @import 'portalMode/_index'; @import 'output/_index'; +@import 'credentials/_index'; /** @define Popup Modal after create new token and applicaiton and save form */ .PopupModal { diff --git a/awx/ui/client/features/credentials/_index.less b/awx/ui/client/features/credentials/_index.less new file mode 100644 index 0000000000..f968043d3c --- /dev/null +++ b/awx/ui/client/features/credentials/_index.less @@ -0,0 +1,20 @@ +.InputSourceLookup-selectedItem { + display: flex; + flex: 0 0 100%; + align-items: center; + min-height: 50px; + margin-top: 16px; + border-radius: 5px; + background-color: @default-no-items-bord; + border: 1px solid @default-border; +} + +.InputSourceLookup-selectedItemLabel { + color: @default-interface-txt; + text-transform: uppercase; + margin: 0 @at-space-2x; +} + +.InputSourceLookup-selectedItemText { + font-style: italic; +} diff --git a/awx/ui/client/features/credentials/add-credentials.controller.js b/awx/ui/client/features/credentials/add-credentials.controller.js deleted file mode 100644 index 36428d50a8..0000000000 --- a/awx/ui/client/features/credentials/add-credentials.controller.js +++ /dev/null @@ -1,155 +0,0 @@ -function AddCredentialsController ( - models, - $state, - $scope, - strings, - componentsStrings, - ConfigService -) { - const vm = this || {}; - - const { me, credential, credentialType, organization } = models; - - vm.mode = 'add'; - vm.strings = strings; - vm.panelTitle = strings.get('add.PANEL_TITLE'); - - vm.tab = { - details: { _active: true }, - permissions: { _disabled: true } - }; - - vm.form = credential.createFormSchema('post', { - omit: ['user', 'team', 'inputs'] - }); - - vm.form._formName = 'credential'; - - vm.form.disabled = !credential.isCreatable(); - - vm.form.organization._resource = 'organization'; - vm.form.organization._route = 'credentials.add.organization'; - vm.form.organization._model = organization; - vm.form.organization._placeholder = strings.get('inputs.ORGANIZATION_PLACEHOLDER'); - - vm.form.credential_type._resource = 'credential_type'; - vm.form.credential_type._route = 'credentials.add.credentialType'; - vm.form.credential_type._model = credentialType; - vm.form.credential_type._placeholder = strings.get('inputs.CREDENTIAL_TYPE_PLACEHOLDER'); - - const gceFileInputSchema = { - id: 'gce_service_account_key', - type: 'file', - label: strings.get('inputs.GCE_FILE_INPUT_LABEL'), - help_text: strings.get('inputs.GCE_FILE_INPUT_HELP_TEXT'), - }; - - let gceFileInputPreEditValues; - - vm.form.inputs = { - _get: () => { - credentialType.mergeInputProperties(); - - const fields = credentialType.get('inputs.fields'); - - if (credentialType.get('name') === 'Google Compute Engine') { - fields.splice(2, 0, gceFileInputSchema); - $scope.$watch(`vm.form.${gceFileInputSchema.id}._value`, vm.gceOnFileInputChanged); - } else if (credentialType.get('name') === 'Machine') { - const apiConfig = ConfigService.get(); - const become = fields.find((field) => field.id === 'become_method'); - become._isDynamic = true; - become._choices = Array.from(apiConfig.become_methods, method => method[0]); - } - - return fields; - }, - _source: vm.form.credential_type, - _reference: 'vm.form.inputs', - _key: 'inputs' - }; - - vm.form.save = data => { - data.user = me.get('id'); - - if (_.get(data.inputs, gceFileInputSchema.id)) { - delete data.inputs[gceFileInputSchema.id]; - } - - const filteredInputs = _.omit(data.inputs, (value) => value === ''); - data.inputs = filteredInputs; - - return credential.request('post', { data }); - }; - - vm.form.onSaveSuccess = res => { - $state.go('credentials.edit', { credential_id: res.data.id }, { reload: true }); - }; - - vm.gceOnFileInputChanged = (value, oldValue) => { - if (value === oldValue) return; - - const gceFileIsLoaded = !!value; - const gceFileInputState = vm.form[gceFileInputSchema.id]; - const { obj, error } = vm.gceParseFileInput(value); - - gceFileInputState._isValid = !error; - gceFileInputState._message = error ? componentsStrings.get('message.INVALID_INPUT') : ''; - - vm.form.project._disabled = gceFileIsLoaded; - vm.form.username._disabled = gceFileIsLoaded; - vm.form.ssh_key_data._disabled = gceFileIsLoaded; - vm.form.ssh_key_data._displayHint = !vm.form.ssh_key_data._disabled; - - if (gceFileIsLoaded) { - gceFileInputPreEditValues = Object.assign({}, { - project: vm.form.project._value, - ssh_key_data: vm.form.ssh_key_data._value, - username: vm.form.username._value - }); - vm.form.project._value = _.get(obj, 'project_id', ''); - vm.form.ssh_key_data._value = _.get(obj, 'private_key', ''); - vm.form.username._value = _.get(obj, 'client_email', ''); - } else { - vm.form.project._value = gceFileInputPreEditValues.project; - vm.form.ssh_key_data._value = gceFileInputPreEditValues.ssh_key_data; - vm.form.username._value = gceFileInputPreEditValues.username; - } - }; - - vm.gceParseFileInput = value => { - let obj; - let error; - - try { - obj = angular.fromJson(value); - } catch (err) { - error = err; - } - - return { obj, error }; - }; - - $scope.$watch('organization', () => { - if ($scope.organization) { - vm.form.organization._idFromModal = $scope.organization; - } - }); - - $scope.$watch('credential_type', () => { - if ($scope.credential_type) { - vm.form.credential_type._idFromModal = $scope.credential_type; - } - }); -} - -AddCredentialsController.$inject = [ - 'resolvedModels', - '$state', - '$scope', - 'CredentialsStrings', - 'ComponentsStrings', - 'ConfigService' -]; - -export default AddCredentialsController; diff --git a/awx/ui/client/features/credentials/add-edit-credentials.controller.js b/awx/ui/client/features/credentials/add-edit-credentials.controller.js new file mode 100644 index 0000000000..63c54c9c32 --- /dev/null +++ b/awx/ui/client/features/credentials/add-edit-credentials.controller.js @@ -0,0 +1,650 @@ +/* eslint camelcase: 0 */ +/* eslint arrow-body-style: 0 */ +function AddEditCredentialsController ( + models, + $state, + $scope, + strings, + componentsStrings, + ConfigService, + ngToast, + Wait, + $filter, + CredentialType, + GetBasePath, + Rest, +) { + const vm = this || {}; + const { + me, + credential, + credentialType, + organization, + isOrgEditableByUser, + sourceCredentials, + } = models; + + const omit = ['user', 'team', 'inputs']; + const isEditable = credential.isEditable(); + const isExternal = credentialType.get('kind') === 'external'; + const mode = $state.current.name.startsWith('credentials.add') ? 'add' : 'edit'; + + vm.mode = mode; + vm.strings = strings; + + if (mode === 'edit') { + vm.panelTitle = credential.get('name'); + vm.tab = { + details: { + _active: true, + _go: 'credentials.edit', + _params: { credential_id: credential.get('id') } + }, + permissions: { + _go: 'credentials.edit.permissions', + _params: { credential_id: credential.get('id') } + } + }; + + if (isEditable) { + vm.form = credential.createFormSchema('put', { omit }); + } else { + vm.form = credential.createFormSchema({ omit }); + vm.form.disabled = !isEditable; + } + + vm.form.organization._disabled = !isOrgEditableByUser; + // Only exists for permissions compatibility + $scope.credential_obj = credential.get(); + + vm.form.organization._resource = 'organization'; + vm.form.organization._model = organization; + vm.form.organization._route = 'credentials.edit.organization'; + vm.form.organization._value = credential.get('summary_fields.organization.id'); + vm.form.organization._displayValue = credential.get('summary_fields.organization.name'); + vm.form.organization._placeholder = strings.get('inputs.ORGANIZATION_PLACEHOLDER'); + + vm.form.credential_type._resource = 'credential_type'; + vm.form.credential_type._model = credentialType; + vm.form.credential_type._route = 'credentials.edit.credentialType'; + vm.form.credential_type._placeholder = strings.get('inputs.CREDENTIAL_TYPE_PLACEHOLDER'); + vm.form.credential_type._value = credentialType.get('id'); + vm.form.credential_type._displayValue = credentialType.get('name'); + vm.isTestable = (isEditable && credentialType.get('kind') === 'external'); + + if (credential.get('related.input_sources.results.length' > 0)) { + vm.form.credential_type._disabled = true; + } + + $scope.$watch('$state.current.name', (value) => { + if (/credentials.edit($|\.organization$|\.credentialType$)/.test(value)) { + vm.tab.details._active = true; + vm.tab.permissions._active = false; + } else { + vm.tab.permissions._active = true; + vm.tab.details._active = false; + } + }); + } else if (mode === 'add') { + vm.panelTitle = strings.get('add.PANEL_TITLE'); + vm.tab = { + details: { _active: true }, + permissions: { _disabled: true } + }; + vm.form = credential.createFormSchema('post', { + omit: ['user', 'team', 'inputs'] + }); + + vm.form._formName = 'credential'; + vm.form.disabled = !credential.isCreatable(); + + vm.form.organization._resource = 'organization'; + vm.form.organization._route = 'credentials.add.organization'; + vm.form.organization._model = organization; + vm.form.organization._placeholder = strings.get('inputs.ORGANIZATION_PLACEHOLDER'); + + vm.form.credential_type._resource = 'credential_type'; + vm.form.credential_type._route = 'credentials.add.credentialType'; + vm.form.credential_type._model = credentialType; + vm.form.credential_type._placeholder = strings.get('inputs.CREDENTIAL_TYPE_PLACEHOLDER'); + vm.isTestable = credentialType.get('kind') === 'external'; + } + + $scope.$watch('organization', () => { + if ($scope.organization) { + vm.form.organization._idFromModal = $scope.organization; + } + }); + + $scope.$watch('credential_type', () => { + if ($scope.credential_type) { + vm.form.credential_type._idFromModal = $scope.credential_type; + } + }); + + const gceFileInputSchema = { + id: 'gce_service_account_key', + type: 'file', + label: strings.get('inputs.GCE_FILE_INPUT_LABEL'), + help_text: strings.get('inputs.GCE_FILE_INPUT_HELP_TEXT'), + }; + + let gceFileInputPreEditValues; + + vm.form.inputs = { + _get ({ getSubmitData, check }) { + const apiConfig = ConfigService.get(); + + credentialType.mergeInputProperties(); + const fields = credential.assignInputGroupValues( + apiConfig, + credentialType, + sourceCredentials + ); + + if (credentialType.get('name') === 'Google Compute Engine') { + fields.splice(2, 0, gceFileInputSchema); + $scope.$watch(`vm.form.${gceFileInputSchema.id}._value`, gceOnFileInputChanged); + if (mode === 'edit') { + $scope.$watch('vm.form.ssh_key_data._isBeingReplaced', gceOnReplaceKeyChanged); + } + } + + vm.inputSources.initialItems = credential.get('related.input_sources.results'); + vm.inputSources.items = []; + vm.inputSources.changedInputFields = []; + if (credential.get('credential_type') === credentialType.get('id')) { + vm.inputSources.items = credential.get('related.input_sources.results'); + } + vm.isTestable = (isEditable && credentialType.get('kind') === 'external'); + vm.getSubmitData = getSubmitData; + vm.checkForm = check; + + return fields; + }, + _onRemoveTag ({ id }) { + vm.onInputSourceClear(id); + }, + _onInputLookup ({ id }) { + vm.onInputSourceOpen(id); + }, + _source: vm.form.credential_type, + _reference: 'vm.form.inputs', + _key: 'inputs', + border: true, + title: true, + }; + + vm.externalTest = { + form: { + inputs: { + _get: () => vm.externalTest.metadataInputs, + _reference: 'vm.form.inputs', + _key: 'inputs', + _source: { _value: {} }, + }, + }, + metadataInputs: null, + }; + vm.inputSources = { + tabs: { + credential: { + _active: true, + _disabled: false, + }, + metadata: { + _active: false, + _disabled: false, + } + }, + form: { + inputs: { + _get: () => vm.inputSources.metadataInputs, + _reference: 'vm.form.inputs', + _key: 'inputs', + _source: { _value: {} }, + }, + }, + field: null, + credentialTypeId: null, + credentialTypeName: null, + credentialId: null, + credentialName: null, + metadataInputs: null, + changedInputFields: [], + initialItems: credential.get('related.input_sources.results'), + items: credential.get('related.input_sources.results'), + }; + + function setInputSourceTab (name) { + const metaIsActive = name === 'metadata'; + vm.inputSources.tabs.credential._active = !metaIsActive; + vm.inputSources.tabs.credential._disabled = false; + vm.inputSources.tabs.metadata._active = metaIsActive; + vm.inputSources.tabs.metadata._disabled = false; + } + + function unsetInputSourceTabs () { + vm.inputSources.tabs.credential._active = false; + vm.inputSources.tabs.credential._disabled = false; + vm.inputSources.tabs.metadata._active = false; + vm.inputSources.tabs.metadata._disabled = false; + } + + vm.onInputSourceClear = (field) => { + vm.form[field].tagMode = true; + vm.form[field].asTag = false; + vm.form[field]._value = ''; + vm.form[field]._tagValue = ''; + vm.form[field]._isValid = true; + vm.form[field]._rejected = false; + vm.inputSources.items = vm.inputSources.items + .filter(({ input_field_name }) => input_field_name !== field); + vm.inputSources.changedInputFields.push(field); + }; + + vm.onInputSourceOpen = (field) => { + // We get here when the input source lookup modal for a field is opened. If source + // credential and metadata values for this field already exist in the initial API data + // or from it being set during a prior visit to the lookup, we initialize the lookup with + // these values here before opening it. + const sourceItem = vm.inputSources.items + .find(({ input_field_name }) => input_field_name === field); + if (sourceItem) { + const { source_credential, summary_fields } = sourceItem; + const { source_credential: { credential_type_id, name } } = summary_fields; + vm.inputSources.credentialId = source_credential; + vm.inputSources.credentialName = name; + vm.inputSources.credentialTypeId = credential_type_id; + vm.inputSources._value = credential_type_id; + } else { + vm.inputSources.credentialId = null; + vm.inputSources.credentialName = null; + vm.inputSources.credentialTypeId = null; + vm.inputSources._value = null; + } + + setInputSourceTab('credential'); + vm.inputSources.field = field; + }; + + vm.onInputSourceClose = () => { + // We get here if the lookup was closed or canceled so we clear the state for the lookup + // and metadata form without storing any changes. + vm.inputSources.field = null; + vm.inputSources.credentialId = null; + vm.inputSources.credentialName = null; + vm.inputSources.metadataInputs = null; + unsetInputSourceTabs(); + }; + + /** + * Extract the current set of input values from the metadata form and reshape them to a + * metadata object that can be sent to the api later or reloaded when re-opening the form. + */ + function getMetadataFormSubmitData ({ inputs }) { + return inputs._group.reduce((metadata, { id, _value }) => { + if (_value !== undefined) { + metadata[id] = _value; + } + return metadata; + }, {}); + } + + vm.onInputSourceNext = () => { + const { field, credentialId, credentialTypeId } = vm.inputSources; + Wait('start'); + new CredentialType('get', credentialTypeId) + .then(model => { + model.mergeInputProperties('metadata'); + vm.inputSources.metadataInputs = model.get('inputs.metadata'); + vm.inputSources.credentialTypeName = model.get('name'); + // Pre-populate the input values for the metadata form if state for this specific + // field_name->source_credential link already exists. This occurs one of two ways: + // + // 1. This field->source_credential link already exists in the API and so we're + // showing the current state as it exists on the backend. + // 2. The metadata form for this specific field->source_credential combination was + // set during a prior visit to this lookup and so we're reflecting the most + // recent set of (unsaved) metadata values provided by the user for this field. + // + // Note: Prior state for a given credential input field is only set for one source + // credential at a time. Linking a field to a source credential will remove all + // other prior input state for that field. + const [metavals] = vm.inputSources.items + .filter(({ input_field_name }) => input_field_name === field) + .filter(({ source_credential }) => source_credential === credentialId) + .map(({ metadata }) => metadata); + Object.keys(metavals || {}).forEach(key => { + const obj = vm.inputSources.metadataInputs.find(o => o.id === key); + if (obj) obj._value = metavals[key]; + }); + setInputSourceTab('metadata'); + }) + .finally(() => Wait('stop')); + }; + + vm.onInputSourceSelect = () => { + const { field, credentialId, credentialName, credentialTypeId } = vm.inputSources; + const metadata = getMetadataFormSubmitData(vm.inputSources.form); + // Remove any input source objects already stored for this field then store the metadata + // and currently selected source credential as a credential input source object that + // can be sent to the api later or reloaded into the form if it is reopened. + vm.inputSources.items = vm.inputSources.items + .filter(({ input_field_name }) => input_field_name !== field) + .concat([{ + metadata, + input_field_name: field, + source_credential: credentialId, + target_credential: credential.get('id'), + summary_fields: { + source_credential: { + name: credentialName, + credential_type_id: credentialTypeId + } + }, + }]); + // Record that this field was changed + vm.inputSources.changedInputFields.push(field); + // Now that we've extracted and stored the selected source credential and metadata values + // for this field, we clear the state for the source credential lookup and metadata form. + vm.inputSources.field = null; + vm.inputSources.metadataInputs = null; + unsetInputSourceTabs(); + // We've linked this field to a credential, so display value as a credential tag + vm.form[field]._value = ''; + vm.form[field]._tagValue = credentialName; + vm.form[field]._isValid = true; + vm.form[field].asTag = true; + vm.checkForm(); + }; + + vm.onInputSourceTabSelect = (name) => { + if (name === 'metadata') { + // Clicking on the metadata tab should have identical behavior to clicking the 'next' + // button, so we pass-through to the same handler here. + vm.onInputSourceNext(); + } else { + setInputSourceTab('credential'); + } + }; + + vm.onInputSourceItemSelect = ({ id, credential_type, name }) => { + vm.inputSources.credentialId = id; + vm.inputSources.credentialName = name; + vm.inputSources.credentialTypeId = credential_type; + vm.inputSources._value = credential_type; + }; + + vm.onInputSourceTest = () => { + // We get here if the test button on the metadata form for the field of a non-external + // credential was used. All input values for the external credential are already stored + // on the backend, so we are only testing how it works with a set of metadata before + // linking it. + const metadata = getMetadataFormSubmitData(vm.inputSources.form); + const name = $filter('sanitize')(vm.inputSources.credentialTypeName); + const endpoint = `${vm.inputSources.credentialId}/test/`; + return runTest({ name, model: credential, endpoint, data: { metadata } }); + }; + + function onExternalTestOpen () { + // We get here if test button on the top-level form for an external credential type was + // used. We load the metadata schema for this particular external credential type and + // use it to generate and open a form for submitting test values. + credentialType.mergeInputProperties('metadata'); + vm.externalTest.metadataInputs = credentialType.get('inputs.metadata'); + } + vm.form.secondary = onExternalTestOpen; + + vm.onExternalTestClose = () => { + // We get here if the metadata test form for an external credential type was canceled or + // closed so we clear the form state and close without submitting any data to the test api, + vm.externalTest.metadataInputs = null; + }; + + vm.onExternalTest = () => { + const name = $filter('sanitize')(credentialType.get('name')); + const { inputs } = vm.getSubmitData(); + const metadata = getMetadataFormSubmitData(vm.externalTest.form); + // We get here if the test button on the top-level form for an external credential type was + // used. We need to see if the currently selected credential type is the one loaded from + // the api when we initialized the view or if its type was changed on the form and hasn't + // been saved. If the credential type hasn't been changed, it means some of the input + // values for the credential may be stored in the backend and not in the form, so we need + // to use the test endpoint for the credential. If the credential type has been changed, + // the user must provide a complete set of input values for the credential to save their + // changes, so we use the generic test endpoint for the credental type as if we were + // testing a completely new and unsaved credential. + let model; + if (credential.get('credential_type') !== credentialType.get('id')) { + model = credentialType; + } else { + model = credential; + } + + const endpoint = `${model.get('id')}/test/`; + return runTest({ name, model, endpoint, data: { inputs, metadata } }); + }; + + vm.filterInputSourceCredentialResults = (data) => { + // If an external credential is changed to have a non-external `credential_type` while + // editing, we avoid showing a self-reference in the list of selectable external + // credentials for input fields by filtering it out here. + if (isExternal) { + data.results = data.results.filter(({ id }) => id !== credential.get('id')); + } + + // only show credentials we can use + data.results = data.results + .filter(({ summary_fields }) => summary_fields.user_capabilities.use); + + return data; + }; + + function runTest ({ name, model, endpoint, data: { inputs, metadata } }) { + return model.http.post({ url: endpoint, data: { inputs, metadata }, replace: false }) + .then(() => { + const icon = 'fa-check-circle'; + const msg = strings.get('edit.TEST_PASSED'); + const content = buildTestNotificationContent({ name, icon, msg }); + ngToast.success({ + content, + dismissButton: false, + dismissOnTimeout: true + }); + }) + .catch(({ data }) => { + const icon = 'fa-exclamation-triangle'; + const msg = data.inputs || strings.get('edit.TEST_FAILED'); + const content = buildTestNotificationContent({ name, icon, msg }); + ngToast.danger({ + content, + dismissButton: false, + dismissOnTimeout: true + }); + }); + } + + function buildTestNotificationContent ({ name, msg, icon }) { + const sanitize = $filter('sanitize'); + const content = `
+
+ +
+
+ ${sanitize(name)}: ${sanitize(msg)} +
+
`; + return content; + } + + function deleteInputSource ({ id }) { + Rest.setUrl(`${GetBasePath('credential_input_sources')}${id}/`); + return Rest.destroy(); + } + + function createInputSource (data) { + Rest.setUrl(GetBasePath('credential_input_sources')); + return Rest.post(data); + } + + function create (data) { + data.user = me.get('id'); + + if (_.get(data.inputs, gceFileInputSchema.id)) { + delete data.inputs[gceFileInputSchema.id]; + } + + const updatedLinkedFieldNames = vm.inputSources.items + .map(({ input_field_name }) => input_field_name); + const sourcesToAssociate = [...vm.inputSources.items]; + + // remove inputs with empty string values + let filteredInputs = _.omit(data.inputs, (value) => value === ''); + // remove inputs that are to be linked to an external credential + filteredInputs = _.omit(filteredInputs, updatedLinkedFieldNames); + data.inputs = filteredInputs; + + return credential.request('post', { data }) + .then(() => { + sourcesToAssociate.forEach(obj => { obj.target_credential = credential.get('id'); }); + return Promise.all(sourcesToAssociate.map(createInputSource)); + }); + } + + /** + * If a credential's `credential_type` is changed while editing, the inputs associated with + * the old type need to be cleared before saving the inputs associated with the new type. + * Otherwise inputs are merged together making the request invalid. + */ + function update (data) { + data.user = me.get('id'); + credential.unset('inputs'); + + if (_.get(data.inputs, gceFileInputSchema.id)) { + delete data.inputs[gceFileInputSchema.id]; + } + + const initialLinkedFieldNames = vm.inputSources.initialItems + .map(({ input_field_name }) => input_field_name); + const updatedLinkedFieldNames = vm.inputSources.items + .map(({ input_field_name }) => input_field_name); + + const fieldsToDisassociate = initialLinkedFieldNames + .filter(name => !updatedLinkedFieldNames.includes(name)) + .concat(updatedLinkedFieldNames) + .filter(name => vm.inputSources.changedInputFields.includes(name)); + const fieldsToAssociate = updatedLinkedFieldNames + .filter(name => vm.inputSources.changedInputFields.includes(name)); + + const sourcesToDisassociate = fieldsToDisassociate + .map(name => vm.inputSources.initialItems + .find(({ input_field_name }) => input_field_name === name)) + .filter(source => source !== undefined); + const sourcesToAssociate = fieldsToAssociate + .map(name => vm.inputSources.items + .find(({ input_field_name }) => input_field_name === name)) + .filter(source => source !== undefined); + + // remove inputs with empty string values + let filteredInputs = _.omit(data.inputs, (value) => value === ''); + // remove inputs that are to be linked to an external credential + filteredInputs = _.omit(filteredInputs, updatedLinkedFieldNames); + data.inputs = filteredInputs; + + return credential.request('put', { data }) + .then(() => Promise.all(sourcesToDisassociate.map(deleteInputSource))) + .then(() => Promise.all(sourcesToAssociate.map(createInputSource))); + } + + vm.form.save = data => { + if (mode === 'edit') { + return update(data); + } + return create(data); + }; + + vm.form.onSaveSuccess = () => { + $state.go('credentials.edit', { credential_id: credential.get('id') }, { reload: true }); + }; + + function gceOnReplaceKeyChanged (value) { + vm.form[gceFileInputSchema.id]._disabled = !value; + } + + function gceOnFileInputChanged (value, oldValue) { + if (value === oldValue) return; + + const gceFileIsLoaded = !!value; + const gceFileInputState = vm.form[gceFileInputSchema.id]; + const { obj, error } = gceParseFileInput(value); + + gceFileInputState._isValid = !error; + gceFileInputState._message = error ? componentsStrings.get('message.INVALID_INPUT') : ''; + + vm.form.project._disabled = gceFileIsLoaded; + vm.form.username._disabled = gceFileIsLoaded; + vm.form.ssh_key_data._disabled = gceFileIsLoaded; + vm.form.ssh_key_data._displayHint = !vm.form.ssh_key_data._disabled; + + if (gceFileIsLoaded) { + gceFileInputPreEditValues = Object.assign({}, { + project: vm.form.project._value, + ssh_key_data: vm.form.ssh_key_data._value, + username: vm.form.username._value + }); + + vm.form.project.asTag = false; + vm.form.project._value = _.get(obj, 'project_id', ''); + vm.inputSources.changedInputFields.push('project'); + vm.inputSources.items = vm.inputSources.items + .filter(({ input_field_name }) => input_field_name !== 'project'); + + vm.form.ssh_key_data.asTag = false; + vm.form.ssh_key_data._value = _.get(obj, 'private_key', ''); + vm.inputSources.changedInputFields.push('ssh_key_data'); + vm.inputSources.items = vm.inputSources.items + .filter(({ input_field_name }) => input_field_name !== 'ssh_key_data'); + + vm.form.username.asTag = false; + vm.form.username._value = _.get(obj, 'client_email', ''); + vm.inputSources.changedInputFields.push('username'); + vm.inputSources.items = vm.inputSources.items + .filter(({ input_field_name }) => input_field_name !== 'username'); + } else { + vm.form.project._value = gceFileInputPreEditValues.project; + vm.form.ssh_key_data._value = gceFileInputPreEditValues.ssh_key_data; + vm.form.username._value = gceFileInputPreEditValues.username; + } + } + + function gceParseFileInput (value) { + let obj; + let error; + + try { + obj = angular.fromJson(value); + } catch (err) { + error = err; + } + + return { obj, error }; + } +} + +AddEditCredentialsController.$inject = [ + 'resolvedModels', + '$state', + '$scope', + 'CredentialsStrings', + 'ComponentsStrings', + 'ConfigService', + 'ngToast', + 'Wait', + '$filter', + 'CredentialTypeModel', + 'GetBasePath', + 'Rest', +]; + +export default AddEditCredentialsController; diff --git a/awx/ui/client/features/credentials/add-edit-credentials.view.html b/awx/ui/client/features/credentials/add-edit-credentials.view.html index dcc9d47e8b..b642a6da07 100644 --- a/awx/ui/client/features/credentials/add-edit-credentials.view.html +++ b/awx/ui/client/features/credentials/add-edit-credentials.view.html @@ -23,6 +23,7 @@ + @@ -42,5 +43,24 @@
- + +
diff --git a/awx/ui/client/features/credentials/credentials.strings.js b/awx/ui/client/features/credentials/credentials.strings.js index cb6260ce55..55e19ca152 100644 --- a/awx/ui/client/features/credentials/credentials.strings.js +++ b/awx/ui/client/features/credentials/credentials.strings.js @@ -11,7 +11,7 @@ function CredentialsStrings (BaseString) { ns.tab = { DETAILS: t.s('Details'), - PERMISSIONS: t.s('Permissions') + PERMISSIONS: t.s('Permissions'), }; ns.inputs = { @@ -22,10 +22,29 @@ function CredentialsStrings (BaseString) { GCE_FILE_INPUT_HELP_TEXT: t.s('Provide account information using Google Compute Engine JSON credentials file.') }; + ns.externalTest = { + TITLE: t.s('Test External Credential') + }; + + ns.inputSources = { + TITLE: t.s('Set Input Source'), + CREDENTIAL: t.s('CREDENTIAL'), + METADATA: t.s('METADATA'), + NO_MATCH: t.s('No records matched your search.'), + NO_RECORDS: t.s('No external credentials available.'), + SELECTED: t.s('selected'), + NONE_SELECTED: t.s('No credential selected'), + }; + ns.add = { PANEL_TITLE: t.s('NEW CREDENTIAL') }; + ns.edit = { + TEST_PASSED: t.s('Test passed.'), + TEST_FAILED: t.s('Test failed.') + }; + ns.permissions = { TITLE: t.s('CREDENTIALS PERMISSIONS') }; diff --git a/awx/ui/client/features/credentials/edit-credentials.controller.js b/awx/ui/client/features/credentials/edit-credentials.controller.js deleted file mode 100644 index 67f8590f95..0000000000 --- a/awx/ui/client/features/credentials/edit-credentials.controller.js +++ /dev/null @@ -1,216 +0,0 @@ -function EditCredentialsController ( - models, - $state, - $scope, - strings, - componentsStrings, - ConfigService -) { - const vm = this || {}; - - const { me, credential, credentialType, organization, isOrgCredAdmin } = models; - - const omit = ['user', 'team', 'inputs']; - const isEditable = credential.isEditable(); - - vm.mode = 'edit'; - vm.strings = strings; - vm.panelTitle = credential.get('name'); - - vm.tab = { - details: { - _active: true, - _go: 'credentials.edit', - _params: { credential_id: credential.get('id') } - }, - permissions: { - _go: 'credentials.edit.permissions', - _params: { credential_id: credential.get('id') } - } - }; - - $scope.$watch('$state.current.name', (value) => { - if (/credentials.edit($|\.organization$|\.credentialType$)/.test(value)) { - vm.tab.details._active = true; - vm.tab.permissions._active = false; - } else { - vm.tab.permissions._active = true; - vm.tab.details._active = false; - } - }); - - $scope.$watch('organization', () => { - if ($scope.organization) { - vm.form.organization._idFromModal = $scope.organization; - } - }); - - $scope.$watch('credential_type', () => { - if ($scope.credential_type) { - vm.form.credential_type._idFromModal = $scope.credential_type; - } - }); - - // Only exists for permissions compatibility - $scope.credential_obj = credential.get(); - - if (isEditable) { - vm.form = credential.createFormSchema('put', { omit }); - } else { - vm.form = credential.createFormSchema({ omit }); - vm.form.disabled = !isEditable; - } - - const isOrgAdmin = _.some(me.get('related.admin_of_organizations.results'), (org) => org.id === organization.get('id')); - const isSuperuser = me.get('is_superuser'); - const isCurrentAuthor = Boolean(credential.get('summary_fields.created_by.id') === me.get('id')); - vm.form.organization._disabled = true; - - if (isSuperuser || isOrgAdmin || isOrgCredAdmin || (credential.get('organization') === null && isCurrentAuthor)) { - vm.form.organization._disabled = false; - } - - vm.form.organization._resource = 'organization'; - vm.form.organization._model = organization; - vm.form.organization._route = 'credentials.edit.organization'; - vm.form.organization._value = credential.get('summary_fields.organization.id'); - vm.form.organization._displayValue = credential.get('summary_fields.organization.name'); - vm.form.organization._placeholder = strings.get('inputs.ORGANIZATION_PLACEHOLDER'); - - vm.form.credential_type._resource = 'credential_type'; - vm.form.credential_type._model = credentialType; - vm.form.credential_type._route = 'credentials.edit.credentialType'; - vm.form.credential_type._value = credentialType.get('id'); - vm.form.credential_type._displayValue = credentialType.get('name'); - vm.form.credential_type._placeholder = strings.get('inputs.CREDENTIAL_TYPE_PLACEHOLDER'); - - const gceFileInputSchema = { - id: 'gce_service_account_key', - type: 'file', - label: strings.get('inputs.GCE_FILE_INPUT_LABEL'), - help_text: strings.get('inputs.GCE_FILE_INPUT_HELP_TEXT'), - }; - - let gceFileInputPreEditValues; - - vm.form.inputs = { - _get () { - let fields; - - credentialType.mergeInputProperties(); - - if (credentialType.get('id') === credential.get('credential_type')) { - fields = credential.assignInputGroupValues(credentialType.get('inputs.fields')); - } else { - fields = credentialType.get('inputs.fields'); - } - - if (credentialType.get('name') === 'Google Compute Engine') { - fields.splice(2, 0, gceFileInputSchema); - - $scope.$watch(`vm.form.${gceFileInputSchema.id}._value`, vm.gceOnFileInputChanged); - $scope.$watch('vm.form.ssh_key_data._isBeingReplaced', vm.gceOnReplaceKeyChanged); - } else if (credentialType.get('name') === 'Machine') { - const apiConfig = ConfigService.get(); - const become = fields.find((field) => field.id === 'become_method'); - become._isDynamic = true; - become._choices = Array.from(apiConfig.become_methods, method => method[0]); - // Add the value to the choices if it doesn't exist in the preset list - if (become._value && become._value !== '') { - const optionMatches = become._choices - .findIndex((option) => option === become._value); - if (optionMatches === -1) { - become._choices.push(become._value); - } - } - } - - return fields; - }, - _source: vm.form.credential_type, - _reference: 'vm.form.inputs', - _key: 'inputs' - }; - - /** - * If a credential's `credential_type` is changed while editing, the inputs associated with - * the old type need to be cleared before saving the inputs associated with the new type. - * Otherwise inputs are merged together making the request invalid. - */ - vm.form.save = data => { - data.user = me.get('id'); - credential.unset('inputs'); - - if (_.get(data.inputs, gceFileInputSchema.id)) { - delete data.inputs[gceFileInputSchema.id]; - } - - const filteredInputs = _.omit(data.inputs, (value) => value === ''); - data.inputs = filteredInputs; - - return credential.request('put', { data }); - }; - - vm.form.onSaveSuccess = () => { - $state.go('credentials.edit', { credential_id: credential.get('id') }, { reload: true }); - }; - - vm.gceOnReplaceKeyChanged = value => { - vm.form[gceFileInputSchema.id]._disabled = !value; - }; - - vm.gceOnFileInputChanged = (value, oldValue) => { - if (value === oldValue) return; - - const gceFileIsLoaded = !!value; - const gceFileInputState = vm.form[gceFileInputSchema.id]; - const { obj, error } = vm.gceParseFileInput(value); - - gceFileInputState._isValid = !error; - gceFileInputState._message = error ? componentsStrings.get('message.INVALID_INPUT') : ''; - - vm.form.project._disabled = gceFileIsLoaded; - vm.form.username._disabled = gceFileIsLoaded; - vm.form.ssh_key_data._disabled = gceFileIsLoaded; - vm.form.ssh_key_data._displayHint = !vm.form.ssh_key_data._disabled; - - if (gceFileIsLoaded) { - gceFileInputPreEditValues = Object.assign({}, { - project: vm.form.project._value, - ssh_key_data: vm.form.ssh_key_data._value, - username: vm.form.username._value - }); - vm.form.project._value = _.get(obj, 'project_id', ''); - vm.form.ssh_key_data._value = _.get(obj, 'private_key', ''); - vm.form.username._value = _.get(obj, 'client_email', ''); - } else { - vm.form.project._value = gceFileInputPreEditValues.project; - vm.form.ssh_key_data._value = gceFileInputPreEditValues.ssh_key_data; - vm.form.username._value = gceFileInputPreEditValues.username; - } - }; - - vm.gceParseFileInput = value => { - let obj; - let error; - - try { - obj = angular.fromJson(value); - } catch (err) { - error = err; - } - - return { obj, error }; - }; -} - -EditCredentialsController.$inject = [ - 'resolvedModels', - '$state', - '$scope', - 'CredentialsStrings', - 'ComponentsStrings', - 'ConfigService' -]; - -export default EditCredentialsController; diff --git a/awx/ui/client/features/credentials/external-test-modal.component.js b/awx/ui/client/features/credentials/external-test-modal.component.js new file mode 100644 index 0000000000..8600cc45f1 --- /dev/null +++ b/awx/ui/client/features/credentials/external-test-modal.component.js @@ -0,0 +1,23 @@ +const templateUrl = require('~features/credentials/external-test-modal.partial.html'); + +function ExternalTestModalController (strings) { + const vm = this || {}; + + vm.strings = strings; + vm.title = strings.get('externalTest.TITLE'); +} + +ExternalTestModalController.$inject = [ + 'CredentialsStrings', +]; + +export default { + templateUrl, + controller: ExternalTestModalController, + controllerAs: 'vm', + bindings: { + onClose: '=', + onSubmit: '=', + form: '=', + }, +}; diff --git a/awx/ui/client/features/credentials/external-test-modal.partial.html b/awx/ui/client/features/credentials/external-test-modal.partial.html new file mode 100644 index 0000000000..5f35774655 --- /dev/null +++ b/awx/ui/client/features/credentials/external-test-modal.partial.html @@ -0,0 +1,20 @@ + + + + + + {{::vm.strings.get('CLOSE')}} + + + {{::vm.strings.get('RUN')}} + + + + diff --git a/awx/ui/client/features/credentials/index.js b/awx/ui/client/features/credentials/index.js index 38e291ef8e..1bdec18f93 100644 --- a/awx/ui/client/features/credentials/index.js +++ b/awx/ui/client/features/credentials/index.js @@ -1,7 +1,8 @@ import LegacyCredentials from './legacy.credentials'; -import AddController from './add-credentials.controller'; -import EditController from './edit-credentials.controller'; +import AddEditController from './add-edit-credentials.controller'; import CredentialsStrings from './credentials.strings'; +import InputSourceLookupComponent from './input-source-lookup.component'; +import ExternalTestModalComponent from './external-test-modal.component'; const MODULE_NAME = 'at.features.credentials'; @@ -15,7 +16,9 @@ function CredentialsResolve ( CredentialType, Organization, ProcessErrors, - strings + strings, + Rest, + GetBasePath, ) { const id = $stateParams.credential_id; @@ -27,6 +30,7 @@ function CredentialsResolve ( promises.credential = new Credential('options'); promises.credentialType = new CredentialType(); promises.organization = new Organization(); + promises.sourceCredentials = $q.resolve({ data: { count: 0, results: [] } }); return $q.all(promises); } @@ -38,17 +42,32 @@ function CredentialsResolve ( const typeId = models.credential.get('credential_type'); const orgId = models.credential.get('organization'); + Rest.setUrl(GetBasePath('credentials')); + const params = { target_input_sources__target_credential: id }; + const sourceCredentialsPromise = Rest.get({ params }); + const dependents = { credentialType: new CredentialType('get', typeId), - organization: new Organization('get', orgId) + organization: new Organization('get', orgId), + credentialInputSources: models.credential.extend('GET', 'input_sources'), + sourceCredentials: sourceCredentialsPromise }; + dependents.isOrgCredAdmin = dependents.organization.then((org) => org.search({ role_level: 'credential_admin_role' })); return $q.all(dependents) .then(related => { models.credentialType = related.credentialType; models.organization = related.organization; - models.isOrgCredAdmin = related.isOrgCredAdmin; + models.sourceCredentials = related.sourceCredentials; + + const isOrgAdmin = _.some(models.me.get('related.admin_of_organizations.results'), (org) => org.id === models.organization.get('id')); + const isSuperuser = models.me.get('is_superuser'); + const isCurrentAuthor = Boolean(models.credential.get('summary_fields.created_by.id') === models.me.get('id')); + + models.isOrgEditableByUser = (isSuperuser || isOrgAdmin + || related.isOrgCredAdmin + || (models.credential.get('organization') === null && isCurrentAuthor)); return models; }); @@ -69,7 +88,9 @@ CredentialsResolve.$inject = [ 'CredentialTypeModel', 'OrganizationModel', 'ProcessErrors', - 'CredentialsStrings' + 'CredentialsStrings', + 'Rest', + 'GetBasePath', ]; function CredentialsRun ($stateExtender, legacy, strings) { @@ -86,7 +107,7 @@ function CredentialsRun ($stateExtender, legacy, strings) { views: { 'add@credentials': { templateUrl: addEditTemplate, - controller: AddController, + controller: AddEditController, controllerAs: 'vm' } }, @@ -109,7 +130,7 @@ function CredentialsRun ($stateExtender, legacy, strings) { views: { 'edit@credentials': { templateUrl: addEditTemplate, - controller: EditController, + controller: AddEditController, controllerAs: 'vm' } }, @@ -135,10 +156,11 @@ CredentialsRun.$inject = [ angular .module(MODULE_NAME, []) - .controller('AddController', AddController) - .controller('EditController', EditController) + .controller('AddEditController', AddEditController) .service('LegacyCredentialsService', LegacyCredentials) .service('CredentialsStrings', CredentialsStrings) + .component('atInputSourceLookup', InputSourceLookupComponent) + .component('atExternalCredentialTest', ExternalTestModalComponent) .run(CredentialsRun); export default MODULE_NAME; diff --git a/awx/ui/client/features/credentials/input-source-lookup.component.js b/awx/ui/client/features/credentials/input-source-lookup.component.js new file mode 100644 index 0000000000..6d6d8841de --- /dev/null +++ b/awx/ui/client/features/credentials/input-source-lookup.component.js @@ -0,0 +1,39 @@ +const templateUrl = require('~features/credentials/input-source-lookup.partial.html'); + +function InputSourceLookupController (strings, wait) { + const vm = this || {}; + + vm.strings = strings; + vm.title = strings.get('inputSources.TITLE'); + + vm.$onInit = () => wait('start'); + + vm.onReady = () => { + vm.isReady = true; + wait('stop'); + }; +} + +InputSourceLookupController.$inject = [ + 'CredentialsStrings', + 'Wait', +]; + +export default { + templateUrl, + controller: InputSourceLookupController, + controllerAs: 'vm', + bindings: { + tabs: '=', + onClose: '=', + onNext: '=', + onSelect: '=', + onTabSelect: '=', + onItemSelect: '=', + onTest: '=', + selectedId: '=', + selectedName: '=', + form: '=', + resultsFilter: '=', + }, +}; diff --git a/awx/ui/client/features/credentials/input-source-lookup.partial.html b/awx/ui/client/features/credentials/input-source-lookup.partial.html new file mode 100644 index 0000000000..3c5b0478e2 --- /dev/null +++ b/awx/ui/client/features/credentials/input-source-lookup.partial.html @@ -0,0 +1,89 @@ + + + + {{::vm.strings.get('inputSources.CREDENTIAL')}} + + + {{::vm.strings.get('inputSources.METADATA')}} + + +
+
+ {{::vm.strings.get('inputSources.SELECTED')}} +
+ +
+ +
+
+ {{::vm.strings.get('inputSources.NONE_SELECTED')}} +
+
+
+ + + + + + + {{::vm.strings.get('TEST')}} + + + {{::vm.strings.get('CANCEL')}} + + + {{::vm.strings.get('NEXT')}} + + + {{::vm.strings.get('OK')}} + + + +
diff --git a/awx/ui/client/features/output/details.component.js b/awx/ui/client/features/output/details.component.js index 67f3bee4a0..a2519ef002 100644 --- a/awx/ui/client/features/output/details.component.js +++ b/awx/ui/client/features/output/details.component.js @@ -465,7 +465,13 @@ function getCredentialDetails () { } function buildCredentialDetails (credential) { - const icon = `${credential.kind}`; + let icon; + if (credential.cloud) { + icon = 'cloud'; + } else { + icon = `${credential.kind}`; + } + const link = `/#/credentials/${credential.id}`; const tooltip = strings.get('tooltips.CREDENTIAL'); const value = $filter('sanitize')(credential.name); diff --git a/awx/ui/client/legacy/styles/ansible-ui.less b/awx/ui/client/legacy/styles/ansible-ui.less index 87b02838b4..27c0984669 100644 --- a/awx/ui/client/legacy/styles/ansible-ui.less +++ b/awx/ui/client/legacy/styles/ansible-ui.less @@ -2262,6 +2262,7 @@ body { .Toast-wrapper { display: flex; max-width: 250px; + word-break: break-word; } .Toast-icon { diff --git a/awx/ui/client/lib/components/_index.less b/awx/ui/client/lib/components/_index.less index ba606cad79..a0be9a31a7 100644 --- a/awx/ui/client/lib/components/_index.less +++ b/awx/ui/client/lib/components/_index.less @@ -1,4 +1,5 @@ @import 'action/_index'; +@import 'dialog/_index'; @import 'input/_index'; @import 'launchTemplateButton/_index'; @import 'layout/_index'; diff --git a/awx/ui/client/lib/components/action/_index.less b/awx/ui/client/lib/components/action/_index.less index 6208e2a41d..c5be9803a3 100644 --- a/awx/ui/client/lib/components/action/_index.less +++ b/awx/ui/client/lib/components/action/_index.less @@ -1,7 +1,7 @@ .at-ActionGroup { margin-top: @at-margin-panel; - button:last-child { - margin-left: @at-margin-panel-inset; + button { + margin-left: 15px; } } diff --git a/awx/ui/client/lib/components/action/action-button.directive.js b/awx/ui/client/lib/components/action/action-button.directive.js new file mode 100644 index 0000000000..733faa6f97 --- /dev/null +++ b/awx/ui/client/lib/components/action/action-button.directive.js @@ -0,0 +1,48 @@ +const templateUrl = require('~components/action/action-button.partial.html'); + +function link (scope, element, attrs, controllers) { + const [actionButtonController] = controllers; + + actionButtonController.init(scope); +} + +function ActionButtonController () { + const vm = this || {}; + + vm.init = (scope) => { + const { variant } = scope; + + if (variant === 'primary') { + vm.color = 'success'; + vm.fill = ''; + } + + if (variant === 'secondary') { + vm.color = 'info'; + vm.fill = ''; + } + + if (variant === 'tertiary') { + vm.color = 'default'; + vm.fill = 'Hollow'; + } + }; +} + +function atActionButton () { + return { + restrict: 'E', + transclude: true, + replace: true, + templateUrl, + require: ['atActionButton'], + controller: ActionButtonController, + controllerAs: 'vm', + link, + scope: { + variant: '@', + } + }; +} + +export default atActionButton; diff --git a/awx/ui/client/lib/components/action/action-button.partial.html b/awx/ui/client/lib/components/action/action-button.partial.html new file mode 100644 index 0000000000..d9f9283351 --- /dev/null +++ b/awx/ui/client/lib/components/action/action-button.partial.html @@ -0,0 +1,3 @@ + diff --git a/awx/ui/client/lib/components/components.strings.js b/awx/ui/client/lib/components/components.strings.js index ce5de265b3..e91a3ec5b2 100644 --- a/awx/ui/client/lib/components/components.strings.js +++ b/awx/ui/client/lib/components/components.strings.js @@ -4,12 +4,12 @@ function ComponentsStrings (BaseString) { const { t } = this; const ns = this.components; - ns.REPLACE = t.s('REPLACE'); - ns.REVERT = t.s('REVERT'); + ns.REPLACE = t.s('Replace'); + ns.REVERT = t.s('Revert'); ns.ENCRYPTED = t.s('ENCRYPTED'); ns.OPTIONS = t.s('OPTIONS'); - ns.SHOW = t.s('SHOW'); - ns.HIDE = t.s('HIDE'); + ns.SHOW = t.s('Show'); + ns.HIDE = t.s('Hide'); ns.message = { REQUIRED_INPUT_MISSING: t.s('Please enter a value.'), @@ -40,7 +40,7 @@ function ComponentsStrings (BaseString) { }; ns.textarea = { - SSH_KEY_HINT: t.s('HINT: Drag and drop an SSH private key file on the field below.') + SSH_KEY_HINT: t.s('HINT: Drag and drop private file on the field below.') }; ns.lookup = { diff --git a/awx/ui/client/lib/components/dialog/_index.less b/awx/ui/client/lib/components/dialog/_index.less new file mode 100644 index 0000000000..60ff25c935 --- /dev/null +++ b/awx/ui/client/lib/components/dialog/_index.less @@ -0,0 +1,43 @@ +.at-Dialog { + display: block; + border: none; + opacity: 1; + background: rgba(0, 0, 0, 0.3); + animation-name: at-DialogFadeIn; + animation-iteration-count: 1; + animation-timing-function: ease-in; + animation-duration: 0.3s; +} + +@keyframes at-DialogFadeIn { + 0% { opacity: 0; background: rgba(0, 0, 0, 0); } + 100% { opacity: 1; background: rgba(0, 0, 0, 0.3); } +} + +.at-Dialog-body { + font-size: @at-font-size; + padding: @at-padding-panel 0; +} + +.at-Dialog-dismiss { + .at-mixin-ButtonIcon(); + font-size: @at-font-size-modal-dismiss; + color: @at-color-icon-dismiss; + text-align: right; +} + +.at-Dialog-heading { + margin: 0; + overflow: visible; + + & > .at-Dialog-dismiss { + margin: 0; + } +} + +.at-Dialog-title { + margin: 0; + padding: 0; + + .at-mixin-Heading(@at-font-size-modal-heading); +} diff --git a/awx/ui/client/lib/components/dialog/dialog.component.js b/awx/ui/client/lib/components/dialog/dialog.component.js new file mode 100644 index 0000000000..663ccb9749 --- /dev/null +++ b/awx/ui/client/lib/components/dialog/dialog.component.js @@ -0,0 +1,34 @@ +const templateUrl = require('~components/dialog/dialog.partial.html'); + +const overlayClass = 'at-Dialog'; + +function DialogController () { + const vm = this || {}; + + vm.handleClick = ({ target }) => { + if (!vm.onClose) { + return; + } + + const targetElement = $(target); + + if (targetElement.hasClass(overlayClass)) { + vm.onClose(); + } + }; +} + +DialogController.$inject = [ + '$element', +]; + +export default { + templateUrl, + controller: DialogController, + controllerAs: 'vm', + transclude: true, + bindings: { + title: '=', + onClose: '=', + }, +}; diff --git a/awx/ui/client/lib/components/dialog/dialog.partial.html b/awx/ui/client/lib/components/dialog/dialog.partial.html new file mode 100644 index 0000000000..639a2fed97 --- /dev/null +++ b/awx/ui/client/lib/components/dialog/dialog.partial.html @@ -0,0 +1,19 @@ + diff --git a/awx/ui/client/lib/components/form/action.directive.js b/awx/ui/client/lib/components/form/action.directive.js index 4ba3c7d67a..ab8d6e72a3 100644 --- a/awx/ui/client/lib/components/form/action.directive.js +++ b/awx/ui/client/lib/components/form/action.directive.js @@ -23,6 +23,9 @@ function atFormActionController ($state, strings) { case 'save': vm.setSaveDefaults(); break; + case 'secondary': + vm.setSecondaryDefaults(); + break; default: vm.setCustomDefaults(); } @@ -43,6 +46,13 @@ function atFormActionController ($state, strings) { scope.color = 'success'; scope.action = () => { form.submit(); }; }; + + vm.setSecondaryDefaults = () => { + scope.text = strings.get('TEST'); + scope.fill = ''; + scope.color = 'info'; + scope.action = () => { form.submitSecondary(); }; + }; } atFormActionController.$inject = ['$state', 'ComponentsStrings']; diff --git a/awx/ui/client/lib/components/form/action.partial.html b/awx/ui/client/lib/components/form/action.partial.html index 245c649de1..78c89d0f91 100644 --- a/awx/ui/client/lib/components/form/action.partial.html +++ b/awx/ui/client/lib/components/form/action.partial.html @@ -1,5 +1,7 @@ diff --git a/awx/ui/client/lib/components/form/form.directive.js b/awx/ui/client/lib/components/form/form.directive.js index 97a30911d0..7a4d7154d8 100644 --- a/awx/ui/client/lib/components/form/form.directive.js +++ b/awx/ui/client/lib/components/form/form.directive.js @@ -30,7 +30,6 @@ function AtFormController (eventService, strings) { ({ modal } = scope[scope.ns]); vm.state.disabled = scope.state.disabled; - vm.setListeners(); }; @@ -62,6 +61,35 @@ function AtFormController (eventService, strings) { scope.$apply(vm.submit); }; + vm.getSubmitData = () => vm.components + .filter(component => component.category === 'input') + .reduce((values, component) => { + if (component.state._value === undefined) { + return values; + } + + if (component.state._format === 'selectFromOptions') { + values[component.state.id] = component.state._value[0]; + } else if (component.state._key && typeof component.state._value === 'object') { + values[component.state.id] = component.state._value[component.state._key]; + } else if (component.state._group) { + values[component.state._key] = values[component.state._key] || {}; + values[component.state._key][component.state.id] = component.state._value; + } else { + values[component.state.id] = component.state._value; + } + + return values; + }, {}); + + vm.submitSecondary = () => { + if (!vm.state.isValid) { + return; + } + const data = vm.getSubmitData(); + scope.state.secondary(data); + }; + vm.submit = () => { if (!vm.state.isValid) { return; @@ -69,26 +97,7 @@ function AtFormController (eventService, strings) { vm.state.disabled = true; - const data = vm.components - .filter(component => component.category === 'input') - .reduce((values, component) => { - if (component.state._value === undefined) { - return values; - } - - if (component.state._format === 'selectFromOptions') { - values[component.state.id] = component.state._value[0]; - } else if (component.state._key && typeof component.state._value === 'object') { - values[component.state.id] = component.state._value[component.state._key]; - } else if (component.state._group) { - values[component.state._key] = values[component.state._key] || {}; - values[component.state._key][component.state.id] = component.state._value; - } else { - values[component.state.id] = component.state._value; - } - - return values; - }, {}); + const data = vm.getSubmitData(); scope.state.save(data) .then(scope.state.onSaveSuccess) @@ -179,6 +188,10 @@ function AtFormController (eventService, strings) { continue; } + if (vm.components[i].state.asTag) { + continue; + } + if (!vm.components[i].state._isValid) { isValid = false; break; @@ -194,6 +207,10 @@ function AtFormController (eventService, strings) { if (isValid !== vm.state.isValid) { vm.state.isValid = isValid; } + + if (isValid !== scope.state.isValid) { + scope.state.isValid = isValid; + } }; vm.deregisterInputGroup = components => { diff --git a/awx/ui/client/lib/components/index.js b/awx/ui/client/lib/components/index.js index 078bd16a06..f6ae129858 100644 --- a/awx/ui/client/lib/components/index.js +++ b/awx/ui/client/lib/components/index.js @@ -1,6 +1,8 @@ import atLibServices from '~services'; import actionGroup from '~components/action/action-group.directive'; +import actionButton from '~components/action/action-button.directive'; +import dialog from '~components/dialog/dialog.component'; import divider from '~components/utility/divider.directive'; import dynamicSelect from '~components/input/dynamic-select.directive'; import form from '~components/form/form.directive'; @@ -20,6 +22,7 @@ import inputTextareaSecret from '~components/input/textarea-secret.directive'; import launchTemplate from '~components/launchTemplateButton/launchTemplateButton.component'; import layout from '~components/layout/layout.directive'; import list from '~components/list/list.directive'; +import lookupList from '~components/lookup-list/lookup-list.component'; import modal from '~components/modal/modal.directive'; import panel from '~components/panel/panel.directive'; import panelBody from '~components/panel/body.directive'; @@ -53,6 +56,8 @@ angular atCodeMirror ]) .directive('atActionGroup', actionGroup) + .directive('atActionButton', actionButton) + .component('atDialog', dialog) .directive('atDivider', divider) .directive('atDynamicSelect', dynamicSelect) .directive('atForm', form) @@ -72,6 +77,7 @@ angular .component('atLaunchTemplate', launchTemplate) .directive('atLayout', layout) .directive('atList', list) + .component('atLookupList', lookupList) .directive('atListToolbar', toolbar) .component('atRelaunch', relaunch) .directive('atRow', row) diff --git a/awx/ui/client/lib/components/input/_index.less b/awx/ui/client/lib/components/input/_index.less index 47f0fc35fc..68be7f59d0 100644 --- a/awx/ui/client/lib/components/input/_index.less +++ b/awx/ui/client/lib/components/input/_index.less @@ -75,6 +75,12 @@ height: @at-height-textarea; } +.at-Input-button--long-sm { + .at-mixin-InputButton(); + max-width: @at-width-input-button-md; + min-height: @at-height-textarea; +} + .at-Input-button--active { .at-mixin-ButtonColor(at-color-info, at-color-default); } @@ -328,3 +334,26 @@ margin: 0; } } + +.at-InputTaggedTextarea { + .at-mixin-FontFixedWidth(); + min-height: @at-height-textarea; + padding: 6px @at-padding-input 0 @at-padding-input; + border-radius: @at-border-radius; +} + +.at-InputTagContainer { + display: flex; + width: 100%; + flex-wrap: wrap; + + .TagComponent { + max-height: @at-space-4x; + } + + .TagComponent-name { + align-self: auto; + word-break: break-all; + font-family: 'Open Sans', sans-serif; + } +} diff --git a/awx/ui/client/lib/components/input/base.controller.js b/awx/ui/client/lib/components/input/base.controller.js index 37d7c467f7..eabd536e34 100644 --- a/awx/ui/client/lib/components/input/base.controller.js +++ b/awx/ui/client/lib/components/input/base.controller.js @@ -51,6 +51,10 @@ function BaseInputController (strings) { let isValid = true; let message = ''; + if (scope.state.asTag) { + return (isValid, message); + } + if (scope.state._value || scope.state._displayValue) { scope.state._touched = true; } diff --git a/awx/ui/client/lib/components/input/group.directive.js b/awx/ui/client/lib/components/input/group.directive.js index 291b534ac9..5272b6cbe7 100644 --- a/awx/ui/client/lib/components/input/group.directive.js +++ b/awx/ui/client/lib/components/input/group.directive.js @@ -48,7 +48,7 @@ function AtInputGroupController ($scope, $compile) { state._value = source._value; - const inputs = state._get(source._value); + const inputs = state._get(form); const group = vm.createComponentConfigs(inputs); vm.insert(group); @@ -66,7 +66,9 @@ function AtInputGroupController ($scope, $compile) { _element: vm.createComponent(input, i), _key: 'inputs', _group: true, - _groupIndex: i + _groupIndex: i, + _onInputLookup: state._onInputLookup, + _onRemoveTag: state._onRemoveTag, }, input)); }); } @@ -97,6 +99,7 @@ function AtInputGroupController ($scope, $compile) { if (input.secret) { config._component = 'at-input-textarea-secret'; + input.format = 'ssh_private_key'; } else { config._component = 'at-input-textarea'; } @@ -111,12 +114,16 @@ function AtInputGroupController ($scope, $compile) { config._component = 'at-input-checkbox'; } else if (input.type === 'file') { config._component = 'at-input-file'; - } else if (input.choices) { + } + + if (input.choices) { config._component = 'at-input-select'; config._format = 'array'; config._data = input.choices; config._exp = 'choice for (index, choice) in state._data'; - } else { + } + + if (!config._component) { const preface = vm.strings.get('group.UNSUPPORTED_ERROR_PREFACE'); throw new Error(`${preface}: ${input.type}`); } @@ -160,7 +167,6 @@ function AtInputGroupController ($scope, $compile) { `); $compile(component)(scope.$parent); - return component; }; diff --git a/awx/ui/client/lib/components/input/group.partial.html b/awx/ui/client/lib/components/input/group.partial.html index 45d4a845e0..c32d29bb1e 100644 --- a/awx/ui/client/lib/components/input/group.partial.html +++ b/awx/ui/client/lib/components/input/group.partial.html @@ -1,7 +1,7 @@
-
+
-
+

diff --git a/awx/ui/client/lib/components/input/label.partial.html b/awx/ui/client/lib/components/input/label.partial.html index c8d4802ffd..5f4cab5ee9 100644 --- a/awx/ui/client/lib/components/input/label.partial.html +++ b/awx/ui/client/lib/components/input/label.partial.html @@ -2,10 +2,11 @@ * {{::state.label | translate}} - {{::state._hint}} + {{::state._hint}}