diff --git a/awx/api/filters.py b/awx/api/filters.py index 03f848e6a8..e4d45bece9 100644 --- a/awx/api/filters.py +++ b/awx/api/filters.py @@ -269,8 +269,10 @@ class FieldLookupBackend(BaseFilterBackend): # Make legacy v1 Job/Template fields work for backwards compatability # TODO: remove after API v1 deprecation period - if queryset.model._meta.object_name in ('JobTemplate', 'Job') and key in ('cloud_credential', 'network_credential'): - key = 'extra_credentials' + if queryset.model._meta.object_name in ('JobTemplate', 'Job') and key in ( + 'credential', 'vault_credential', 'cloud_credential', 'network_credential' + ): + key = 'credentials' # Make legacy v1 Credential fields work for backwards compatability # TODO: remove after API v1 deprecation period diff --git a/awx/api/generics.py b/awx/api/generics.py index f69a4efd09..581fddb861 100644 --- a/awx/api/generics.py +++ b/awx/api/generics.py @@ -189,6 +189,7 @@ class APIView(views.APIView): 'new_in_300': getattr(self, 'new_in_300', False), 'new_in_310': getattr(self, 'new_in_310', False), 'new_in_320': getattr(self, 'new_in_320', False), + 'new_in_330': getattr(self, 'new_in_330', False), 'new_in_api_v2': getattr(self, 'new_in_api_v2', False), 'deprecated': getattr(self, 'deprecated', False), } diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 19fae02fd5..6bd0c9a9e6 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -54,6 +54,8 @@ from awx.api.fields import BooleanNullField, CharNullField, ChoiceNullField, Ver logger = logging.getLogger('awx.api.serializers') +DEPRECATED = 'This resource has been deprecated and will be removed in a future release' + # Fields that should be summarized regardless of object type. DEFAULT_SUMMARY_FIELDS = ('id', 'name', 'description')# , 'created_by', 'modified_by')#, 'type') @@ -538,6 +540,13 @@ class BaseSerializer(serializers.ModelSerializer): kwargs['request'] = self.context.get('request') return reverse(*args, **kwargs) + @property + def is_detail_view(self): + if 'view' in self.context: + if 'pk' in self.context['view'].kwargs: + return True + return False + class EmptySerializer(serializers.Serializer): pass @@ -2286,8 +2295,8 @@ class V1JobOptionsSerializer(BaseSerializer): fields = ('*', 'cloud_credential', 'network_credential') V1_FIELDS = { - 'cloud_credential': models.PositiveIntegerField(blank=True, null=True, default=None), - 'network_credential': models.PositiveIntegerField(blank=True, null=True, default=None) + 'cloud_credential': models.PositiveIntegerField(blank=True, null=True, default=None, help_text=DEPRECATED), + 'network_credential': models.PositiveIntegerField(blank=True, null=True, default=None, help_text=DEPRECATED), } def build_field(self, field_name, info, model_class, nested_depth): @@ -2297,20 +2306,41 @@ class V1JobOptionsSerializer(BaseSerializer): return super(V1JobOptionsSerializer, self).build_field(field_name, info, model_class, nested_depth) +@six.add_metaclass(BaseSerializerMetaclass) +class LegacyCredentialFields(BaseSerializer): + + class Meta: + model = Credential + fields = ('*', 'credential', 'vault_credential') + + LEGACY_FIELDS = { + 'credential': models.PositiveIntegerField(blank=True, null=True, default=None, help_text=DEPRECATED), + 'vault_credential': models.PositiveIntegerField(blank=True, null=True, default=None, help_text=DEPRECATED), + } + + def build_field(self, field_name, info, model_class, nested_depth): + if field_name in self.LEGACY_FIELDS: + return self.build_standard_field(field_name, + self.LEGACY_FIELDS[field_name]) + return super(LegacyCredentialFields, self).build_field(field_name, info, model_class, nested_depth) + + class JobOptionsSerializer(LabelsListMixin, BaseSerializer): class Meta: fields = ('*', 'job_type', 'inventory', 'project', 'playbook', - 'credential', 'vault_credential', 'forks', 'limit', - 'verbosity', 'extra_vars', 'job_tags', 'force_handlers', - 'skip_tags', 'start_at_task', 'timeout', 'use_fact_cache',) + 'forks', 'limit', 'verbosity', 'extra_vars', 'job_tags', + 'force_handlers', 'skip_tags', 'start_at_task', 'timeout', + 'use_fact_cache',) def get_fields(self): fields = super(JobOptionsSerializer, self).get_fields() # TODO: remove when API v1 is removed - if self.version == 1 and 'credential' in self.Meta.fields: + if self.version == 1: fields.update(V1JobOptionsSerializer().get_fields()) + + fields.update(LegacyCredentialFields().get_fields()) return fields def get_related(self, obj): @@ -2321,17 +2351,22 @@ class JobOptionsSerializer(LabelsListMixin, BaseSerializer): if obj.project: res['project'] = self.reverse('api:project_detail', kwargs={'pk': obj.project.pk}) if obj.credential: - res['credential'] = self.reverse('api:credential_detail', kwargs={'pk': obj.credential.pk}) + res['credential'] = self.reverse('api:credential_detail', kwargs={'pk': obj.credential}) if obj.vault_credential: - res['vault_credential'] = self.reverse('api:credential_detail', kwargs={'pk': obj.vault_credential.pk}) + res['vault_credential'] = self.reverse('api:credential_detail', kwargs={'pk': obj.vault_credential}) if self.version > 1: if isinstance(obj, UnifiedJobTemplate): res['extra_credentials'] = self.reverse( 'api:job_template_extra_credentials_list', kwargs={'pk': obj.pk} ) + res['credentials'] = self.reverse( + 'api:job_template_credentials_list', + kwargs={'pk': obj.pk} + ) elif isinstance(obj, UnifiedJob): res['extra_credentials'] = self.reverse('api:job_extra_credentials_list', kwargs={'pk': obj.pk}) + res['credentials'] = self.reverse('api:job_credentials_list', kwargs={'pk': obj.pk}) else: cloud_cred = obj.cloud_credential if cloud_cred: @@ -2352,64 +2387,67 @@ class JobOptionsSerializer(LabelsListMixin, BaseSerializer): ret['project'] = None if 'playbook' in ret: ret['playbook'] = '' - if 'credential' in ret and not obj.credential: - ret['credential'] = None - if 'vault_credential' in ret and not obj.vault_credential: - ret['vault_credential'] = None - if self.version == 1 and 'credential' in self.Meta.fields: + ret['credential'] = obj.credential + ret['vault_credential'] = obj.vault_credential + if self.version == 1: ret['cloud_credential'] = obj.cloud_credential ret['network_credential'] = obj.network_credential return ret def create(self, validated_data): deprecated_fields = {} - for key in ('cloud_credential', 'network_credential'): + for key in ('credential', 'vault_credential', 'cloud_credential', 'network_credential'): if key in validated_data: deprecated_fields[key] = validated_data.pop(key) obj = super(JobOptionsSerializer, self).create(validated_data) - if self.version == 1 and deprecated_fields: # TODO: remove in 3.3 + if deprecated_fields: # TODO: remove in 3.3 self._update_deprecated_fields(deprecated_fields, obj) return obj def update(self, obj, validated_data): deprecated_fields = {} - for key in ('cloud_credential', 'network_credential'): + for key in ('credential', 'vault_credential', 'cloud_credential', 'network_credential'): if key in validated_data: deprecated_fields[key] = validated_data.pop(key) obj = super(JobOptionsSerializer, self).update(obj, validated_data) - if self.version == 1 and deprecated_fields: # TODO: remove in 3.3 + if deprecated_fields: # TODO: remove in 3.3 self._update_deprecated_fields(deprecated_fields, obj) return obj def _update_deprecated_fields(self, fields, obj): for key, existing in ( + ('credential', obj.credentials.filter(credential_type__kind='ssh')), + ('vault_credential', obj.credentials.filter(credential_type__kind='vault')), ('cloud_credential', obj.cloud_credentials), ('network_credential', obj.network_credentials), ): if key in fields: for cred in existing: - obj.extra_credentials.remove(cred) + obj.credentials.remove(cred) if fields[key]: - obj.extra_credentials.add(fields[key]) + obj.credentials.add(fields[key]) obj.save() def validate(self, attrs): v1_credentials = {} view = self.context.get('view', None) - if self.version == 1: # TODO: remove in 3.3 - for attr, kind, error in ( - ('cloud_credential', 'cloud', _('You must provide a cloud credential.')), - ('network_credential', 'net', _('You must provide a network credential.')) - ): - if attr in attrs: - v1_credentials[attr] = None - pk = attrs.pop(attr) - if pk: - cred = v1_credentials[attr] = Credential.objects.get(pk=pk) - if cred.credential_type.kind != kind: - raise serializers.ValidationError({attr: error}) - if (not view) or (not view.request) or (view.request.user not in cred.use_role): - raise PermissionDenied() + for attr, kind, error in ( + ('cloud_credential', 'cloud', _('You must provide a cloud credential.')), + ('network_credential', 'net', _('You must provide a network credential.')), + ('credential', 'ssh', _('You must provide an SSH credential.')), + ('vault_credential', 'vault', _('You must provide a vault credential.')), + ): + if kind in ('cloud', 'net') and self.version > 1: + continue # cloud and net deprecated creds are v1 only + if attr in attrs: + v1_credentials[attr] = None + pk = attrs.pop(attr) + if pk: + cred = v1_credentials[attr] = Credential.objects.get(pk=pk) + if cred.credential_type.kind != kind: + raise serializers.ValidationError({attr: error}) + if view and view.request and view.request.user not in cred.use_role: + raise PermissionDenied() if 'project' in self.fields and 'playbook' in self.fields: project = attrs.get('project', self.instance and self.instance.project or None) @@ -2492,19 +2530,11 @@ class JobTemplateSerializer(JobTemplateMixin, UnifiedJobTemplateSerializer, JobO return attrs.get(fd, self.instance and getattr(self.instance, fd) or None) inventory = get_field_from_model_or_attrs('inventory') - credential = get_field_from_model_or_attrs('credential') - vault_credential = get_field_from_model_or_attrs('vault_credential') project = get_field_from_model_or_attrs('project') prompting_error_message = _("Must either set a default value or ask to prompt on launch.") if project is None: raise serializers.ValidationError({'project': _("Job types 'run' and 'check' must have assigned a project.")}) - elif all([ - credential is None, - vault_credential is None, - not get_field_from_model_or_attrs('ask_credential_on_launch'), - ]): - raise serializers.ValidationError({'credential': prompting_error_message}) elif inventory is None and not get_field_from_model_or_attrs('ask_inventory_on_launch'): raise serializers.ValidationError({'inventory': prompting_error_message}) @@ -2515,17 +2545,27 @@ class JobTemplateSerializer(JobTemplateMixin, UnifiedJobTemplateSerializer, JobO def get_summary_fields(self, obj): summary_fields = super(JobTemplateSerializer, self).get_summary_fields(obj) - if 'pk' in self.context['view'].kwargs and self.version > 1: # TODO: remove version check in 3.3 + if self.is_detail_view: + all_creds = [] extra_creds = [] - for cred in obj.extra_credentials.all(): - extra_creds.append({ + for cred in obj.credentials.all(): + summarized_cred = { 'id': cred.pk, 'name': cred.name, 'description': cred.description, 'kind': cred.kind, 'credential_type_id': cred.credential_type_id - }) - summary_fields['extra_credentials'] = extra_creds + } + all_creds.append(summarized_cred) + if cred.credential_type.kind in ('cloud', 'net'): + extra_creds.append(summarized_cred) + elif cred.credential_type.kind == 'ssh': + summary_fields['credential'] = summarized_cred + elif cred.credential_type.kind == 'vault': + summary_fields['vault_credential'] = summarized_cred + if self.version > 1: + summary_fields['extra_credentials'] = extra_creds + summary_fields['credentials'] = all_creds return summary_fields @@ -2618,17 +2658,27 @@ class JobSerializer(UnifiedJobSerializer, JobOptionsSerializer): def get_summary_fields(self, obj): summary_fields = super(JobSerializer, self).get_summary_fields(obj) - if 'pk' in self.context['view'].kwargs and self.version > 1: # TODO: remove version check in 3.3 + if self.is_detail_view: # TODO: remove version check in 3.3 + all_creds = [] extra_creds = [] - for cred in obj.extra_credentials.all(): - extra_creds.append({ + for cred in obj.credentials.all(): + summarized_cred = { 'id': cred.pk, 'name': cred.name, 'description': cred.description, 'kind': cred.kind, 'credential_type_id': cred.credential_type_id - }) - summary_fields['extra_credentials'] = extra_creds + } + all_creds.append(summarized_cred) + if cred.credential_type.kind in ('cloud', 'net'): + extra_creds.append(summarized_cred) + elif cred.credential_type.kind == 'ssh': + summary_fields['credential'] = summarized_cred + elif cred.credential_type.kind == 'vault': + summary_fields['vault_credential'] = summarized_cred + if self.version > 1: + summary_fields['extra_credentials'] = extra_creds + summary_fields['credentials'] = all_creds return summary_fields @@ -3250,7 +3300,7 @@ class JobLaunchSerializer(BaseSerializer): model = JobTemplate fields = ('can_start_without_user_input', 'passwords_needed_to_start', 'extra_vars', 'limit', 'job_tags', 'skip_tags', 'job_type', 'inventory', - 'credential', 'extra_credentials', 'ask_variables_on_launch', 'ask_tags_on_launch', + 'credentials', 'ask_variables_on_launch', 'ask_tags_on_launch', 'ask_diff_mode_on_launch', 'ask_skip_tags_on_launch', 'ask_job_type_on_launch', 'ask_limit_on_launch', 'ask_verbosity_on_launch', 'ask_inventory_on_launch', 'ask_credential_on_launch', 'survey_enabled', 'variables_needed_to_start', 'credential_needed_to_start', @@ -3260,8 +3310,7 @@ class JobLaunchSerializer(BaseSerializer): 'ask_skip_tags_on_launch', 'ask_job_type_on_launch', 'ask_verbosity_on_launch', 'ask_inventory_on_launch', 'ask_credential_on_launch',) extra_kwargs = { - 'credential': {'write_only': True,}, - 'extra_credentials': {'write_only': True, 'default': [], 'allow_empty': True}, + 'credentials': {'write_only': True, 'default': [], 'allow_empty': True}, 'limit': {'write_only': True,}, 'job_tags': {'write_only': True,}, 'skip_tags': {'write_only': True,}, @@ -3270,15 +3319,8 @@ class JobLaunchSerializer(BaseSerializer): 'verbosity': {'write_only': True,} } - # TODO: remove in 3.3 - def get_fields(self): - ret = super(JobLaunchSerializer, self).get_fields() - if self.version == 1: - ret.pop('extra_credentials') - return ret - def get_credential_needed_to_start(self, obj): - return not (obj and obj.credential) + return False def get_inventory_needed_to_start(self, obj): return not (obj and obj.inventory) @@ -3293,11 +3335,15 @@ class JobLaunchSerializer(BaseSerializer): ask_for_vars_dict['vault_credential'] = False defaults_dict = {} for field in ask_for_vars_dict: - if field in ('inventory', 'credential', 'vault_credential'): + if field == 'inventory': defaults_dict[field] = dict( name=getattrd(obj, '%s.name' % field, None), id=getattrd(obj, '%s.pk' % field, None)) - elif field == 'extra_credentials': + elif field in ('credential', 'vault_credential', 'extra_credentials'): + # don't prefill legacy defaults; encourage API users to specify + # credentials at launch time using the new `credentials` key + pass + elif field == 'credentials': if self.version > 1: defaults_dict[field] = [ dict( @@ -3305,7 +3351,7 @@ class JobLaunchSerializer(BaseSerializer): name=cred.name, credential_type=cred.credential_type.pk ) - for cred in obj.extra_credentials.all() + for cred in obj.credentials.all() ] else: defaults_dict[field] = getattr(obj, field) @@ -3326,23 +3372,7 @@ class JobLaunchSerializer(BaseSerializer): if obj.inventory and obj.inventory.pending_deletion is True: errors['inventory'] = _("The inventory associated with this Job Template is being deleted.") - if (not obj.ask_credential_on_launch) or (not attrs.get('credential', None)): - credential = obj.credential - else: - credential = attrs.get('credential', None) - - # fill passwords dict with request data passwords - for cred in (credential, obj.vault_credential): - if cred and cred.passwords_needed: - passwords = self.context.get('passwords') - try: - for p in cred.passwords_needed: - passwords[p] = data[p] - except KeyError: - errors.setdefault('passwords_needed_to_start', []).extend(cred.passwords_needed) - extra_vars = attrs.get('extra_vars', {}) - try: extra_vars = parse_yaml_or_json(extra_vars, silent_failure=False) except ParseError as e: @@ -3354,14 +3384,15 @@ class JobLaunchSerializer(BaseSerializer): if validation_errors: errors['variables_needed_to_start'] = validation_errors - extra_cred_kinds = [] - for cred in data.get('extra_credentials', []): + # Prohibit credential assign of the same CredentialType.kind + # Note: when multi-vault is supported, we'll have to carve out an + # exception to this logic + distinct_cred_kinds = [] + for cred in data.get('credentials', []): cred = Credential.objects.get(id=cred) - if cred.credential_type.pk in extra_cred_kinds: - errors['extra_credentials'] = _('Cannot assign multiple %s credentials.' % cred.credential_type.name) - if cred.credential_type.kind not in ('net', 'cloud'): - errors['extra_credentials'] = _('Extra credentials must be network or cloud.') - extra_cred_kinds.append(cred.credential_type.pk) + if cred.credential_type.pk in distinct_cred_kinds: + errors['credentials'] = _('Cannot assign multiple %s credentials.' % cred.credential_type.name) + distinct_cred_kinds.append(cred.credential_type.pk) # Special prohibited cases for scan jobs errors.update(obj._extra_job_type_errors(data)) @@ -3375,9 +3406,8 @@ class JobLaunchSerializer(BaseSerializer): JT_job_tags = obj.job_tags JT_skip_tags = obj.skip_tags JT_inventory = obj.inventory - JT_credential = obj.credential JT_verbosity = obj.verbosity - extra_credentials = attrs.pop('extra_credentials', None) + credentials = attrs.pop('credentials', None) attrs = super(JobLaunchSerializer, self).validate(attrs) obj.extra_vars = JT_extra_vars obj.limit = JT_limit @@ -3385,10 +3415,29 @@ class JobLaunchSerializer(BaseSerializer): obj.skip_tags = JT_skip_tags obj.job_tags = JT_job_tags obj.inventory = JT_inventory - obj.credential = JT_credential obj.verbosity = JT_verbosity - if extra_credentials is not None: - attrs['extra_credentials'] = extra_credentials + if credentials is not None: + attrs['credentials'] = credentials + + # if the POST includes a list of credentials, verify that they don't + # require launch-time passwords + # if the POST *does not* include a list of credentials, fall back to + # checking the credentials on the JobTemplate + credentials = attrs['credentials'] if 'credentials' in data else obj.credentials.all() + passwords_needed = [] + for cred in credentials: + if cred.passwords_needed: + passwords = self.context.get('passwords') + try: + for p in cred.passwords_needed: + passwords[p] = data[p] + except KeyError: + passwords_needed.extend(cred.passwords_needed) + if len(passwords_needed): + raise serializers.ValidationError({ + 'passwords_needed_to_start': passwords_needed + }) + return attrs diff --git a/awx/api/templates/api/_new_in_awx.md b/awx/api/templates/api/_new_in_awx.md index 994a18dc48..b3e4552517 100644 --- a/awx/api/templates/api/_new_in_awx.md +++ b/awx/api/templates/api/_new_in_awx.md @@ -10,4 +10,5 @@ {% if new_in_300 %}> _Added in Ansible Tower 3.0.0_{% endif %} {% if new_in_310 %}> _New in Ansible Tower 3.1.0_{% endif %} {% if new_in_320 %}> _New in Ansible Tower 3.2.0_{% endif %} +{% if new_in_330 %}> _New in Ansible Tower 3.3.0_{% endif %} {% endif %} diff --git a/awx/api/templates/api/job_template_launch.md b/awx/api/templates/api/job_template_launch.md index 10c2c4288e..5fec56ec6c 100644 --- a/awx/api/templates/api/job_template_launch.md +++ b/awx/api/templates/api/job_template_launch.md @@ -26,9 +26,6 @@ The response will include the following fields: job_template (array, read-only) * `survey_enabled`: Flag indicating whether the job_template has an enabled survey (boolean, read-only) -* `credential_needed_to_start`: Flag indicating the presence of a credential - associated with the job template. If not then one should be supplied when - launching the job (boolean, read-only) * `inventory_needed_to_start`: Flag indicating the presence of an inventory associated with the job template. If not then one should be supplied when launching the job (boolean, read-only) @@ -36,9 +33,8 @@ The response will include the following fields: Make a POST request to this resource to launch the job_template. If any passwords, inventory, or extra variables (extra_vars) are required, they must be passed via POST data, with extra_vars given as a YAML or JSON string and -escaped parentheses. If `credential_needed_to_start` is `True` then the -`credential` field is required and if the `inventory_needed_to_start` is -`True` then the `inventory` is required as well. +escaped parentheses. If the `inventory_needed_to_start` is `True` then the +`inventory` is required. If successful, the response status code will be 201. If any required passwords are not provided, a 400 status code will be returned. If the job cannot be diff --git a/awx/api/urls/urls.py b/awx/api/urls/urls.py index d808e9f47d..15336f4603 100644 --- a/awx/api/urls/urls.py +++ b/awx/api/urls/urls.py @@ -18,7 +18,9 @@ from awx.api.views import ( UnifiedJobTemplateList, UnifiedJobList, HostAnsibleFactsDetail, + JobCredentialsList, JobExtraCredentialsList, + JobTemplateCredentialsList, JobTemplateExtraCredentialsList, ) @@ -108,7 +110,9 @@ v2_urls = [ url(r'^credential_types/', include(credential_type_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'), url(r'^job_templates/(?P[0-9]+)/extra_credentials/$', JobTemplateExtraCredentialsList.as_view(), name='job_template_extra_credentials_list'), + url(r'^job_templates/(?P[0-9]+)/credentials/$', JobTemplateCredentialsList.as_view(), name='job_template_credentials_list'), ] app_name = 'api' diff --git a/awx/api/views.py b/awx/api/views.py index 1328a6571c..b873c883f1 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -13,7 +13,7 @@ import sys import logging import requests from base64 import b64encode -from collections import OrderedDict +from collections import OrderedDict, Iterable # Django from django.conf import settings @@ -2669,7 +2669,7 @@ class JobTemplateList(ListCreateAPIView): always_allow_superuser = False capabilities_prefetch = [ 'admin', 'execute', - {'copy': ['project.use', 'inventory.use', 'credential.use', 'vault_credential.use']} + {'copy': ['project.use', 'inventory.use']} ] def post(self, request, *args, **kwargs): @@ -2711,15 +2711,13 @@ class JobTemplateLaunch(RetrieveAPIView): data['extra_vars'] = extra_vars ask_for_vars_dict = obj._ask_for_vars_dict() ask_for_vars_dict.pop('extra_vars') - if get_request_version(self.request) == 1: # TODO: remove in 3.3 - ask_for_vars_dict.pop('extra_credentials') for field in ask_for_vars_dict: if not ask_for_vars_dict[field]: data.pop(field, None) - elif field == 'inventory' or field == 'credential': + elif field == 'inventory': data[field] = getattrd(obj, "%s.%s" % (field, 'id'), None) - elif field == 'extra_credentials': - data[field] = [cred.id for cred in obj.extra_credentials.all()] + elif field == 'credentials': + data[field] = [cred.id for cred in obj.credentials.all()] else: data[field] = getattr(obj, field) return data @@ -2733,13 +2731,56 @@ class JobTemplateLaunch(RetrieveAPIView): if fd not in request.data and id_fd in request.data: request.data[fd] = request.data[id_fd] - if get_request_version(self.request) == 1 and 'extra_credentials' in request.data: # TODO: remove in 3.3 + # This block causes `extra_credentials` to _always_ be ignored for + # the launch endpoint if we're accessing `/api/v1/` + if get_request_version(self.request) == 1 and 'extra_credentials' in request.data: if hasattr(request.data, '_mutable') and not request.data._mutable: request.data._mutable = True extra_creds = request.data.pop('extra_credentials', None) if extra_creds is not None: ignored_fields['extra_credentials'] = extra_creds + # Automatically convert legacy launch credential arguments into a list of `.credentials` + if 'credentials' in request.data and ( + 'credential' in request.data or + 'vault_credential' in request.data or + 'extra_credentials' in request.data + ): + return Response(dict( + error=_("'credentials' cannot be used in combination with 'credential', 'vault_credential', or 'extra_credentials'.")), # noqa + status=status.HTTP_400_BAD_REQUEST + ) + + if ( + 'credential' in request.data or + 'vault_credential' in request.data or + 'extra_credentials' in request.data + ): + # make a list of the current credentials + existing_credentials = obj.credentials.all() + new_credentials = [] + for key, conditional in ( + ('credential', lambda cred: cred.credential_type.kind != 'ssh'), + ('vault_credential', lambda cred: cred.credential_type.kind != 'vault'), + ('extra_credentials', lambda cred: cred.credential_type.kind not in ('cloud', 'net')) + ): + if key in request.data: + # if a specific deprecated key is specified, remove all + # credentials of _that_ type from the list of current + # credentials + existing_credentials = filter(conditional, existing_credentials) + prompted_value = request.data.pop(key) + + # add the deprecated credential specified in the request + if not isinstance(prompted_value, Iterable): + prompted_value = [prompted_value] + new_credentials.extend(prompted_value) + + # combine the list of "new" and the filtered list of "old" + new_credentials.extend([cred.pk for cred in existing_credentials]) + if new_credentials: + request.data['credentials'] = new_credentials + passwords = {} serializer = self.serializer_class(instance=obj, data=request.data, context={'obj': obj, 'data': request.data, 'passwords': passwords}) if not serializer.is_valid(): @@ -2749,17 +2790,14 @@ class JobTemplateLaunch(RetrieveAPIView): prompted_fields = _accepted_or_ignored[0] ignored_fields.update(_accepted_or_ignored[1]) - for fd, model in ( - ('credential', Credential), - ('vault_credential', Credential), - ('inventory', Inventory)): - if fd in prompted_fields and prompted_fields[fd] != getattrd(obj, '{}.pk'.format(fd), None): - new_res = get_object_or_400(model, pk=get_pk_from_dict(prompted_fields, fd)) - use_role = getattr(new_res, 'use_role') - if request.user not in use_role: - raise PermissionDenied() + fd = 'inventory' + if fd in prompted_fields and prompted_fields[fd] != getattrd(obj, '{}.pk'.format(fd), None): + new_res = get_object_or_400(Inventory, pk=get_pk_from_dict(prompted_fields, fd)) + use_role = getattr(new_res, 'use_role') + if request.user not in use_role: + raise PermissionDenied() - for cred in prompted_fields.get('extra_credentials', []): + for cred in prompted_fields.get('credentials', []): new_credential = get_object_or_400(Credential, pk=cred) if request.user not in new_credential.use_role: raise PermissionDenied() @@ -2920,17 +2958,17 @@ class JobTemplateNotificationTemplatesSuccessList(SubListCreateAttachDetachAPIVi new_in_300 = True -class JobTemplateExtraCredentialsList(SubListCreateAttachDetachAPIView): +class JobTemplateCredentialsList(SubListCreateAttachDetachAPIView): model = Credential serializer_class = CredentialSerializer parent_model = JobTemplate - relationship = 'extra_credentials' - new_in_320 = True + relationship = 'credentials' + new_in_330 = True new_in_api_v2 = True def get_queryset(self): - # Return the full list of extra_credentials + # Return the full list of credentials parent = self.get_parent_object() self.check_parent_access(parent) sublist_qs = getattrd(parent, self.relationship) @@ -2941,15 +2979,29 @@ class JobTemplateExtraCredentialsList(SubListCreateAttachDetachAPIView): return sublist_qs def is_valid_relation(self, parent, sub, created=False): - current_extra_types = [ - cred.credential_type.pk for cred in parent.extra_credentials.all() - ] + current_extra_types = [cred.credential_type.pk for cred in parent.credentials.all()] if sub.credential_type.pk in current_extra_types: return {'error': _('Cannot assign multiple %s credentials.' % sub.credential_type.name)} - if sub.credential_type.kind not in ('net', 'cloud'): + return super(JobTemplateCredentialsList, self).is_valid_relation(parent, sub, created) + + +class JobTemplateExtraCredentialsList(JobTemplateCredentialsList): + + deprecated = True + new_in_320 = True + new_in_330 = False + + def get_queryset(self): + sublist_qs = super(JobTemplateExtraCredentialsList, self).get_queryset() + sublist_qs = sublist_qs.filter(**{'credential_type__kind__in': ['cloud', 'net']}) + return sublist_qs + + def is_valid_relation(self, parent, sub, created=False): + valid = super(JobTemplateExtraCredentialsList, self).is_valid_relation(parent, sub, created) + if sub.credential_type.kind not in ('cloud', 'net'): return {'error': _('Extra credentials must be network or cloud.')} - return super(JobTemplateExtraCredentialsList, self).is_valid_relation(parent, sub, created) + return valid class JobTemplateLabelList(DeleteLastUnattachLabelMixin, SubListCreateAttachDetachAPIView): @@ -3720,14 +3772,26 @@ class JobDetail(UnifiedJobDeletionMixin, RetrieveUpdateDestroyAPIView): return super(JobDetail, self).update(request, *args, **kwargs) -class JobExtraCredentialsList(SubListAPIView): +class JobCredentialsList(SubListAPIView): model = Credential serializer_class = CredentialSerializer parent_model = Job - relationship = 'extra_credentials' - new_in_320 = True + relationship = 'credentials' new_in_api_v2 = True + new_in_330 = True + + +class JobExtraCredentialsList(JobCredentialsList): + + deprecated = True + new_in_320 = True + new_in_330 = False + + def get_queryset(self): + sublist_qs = super(JobExtraCredentialsList, self).get_queryset() + sublist_qs = sublist_qs.filter(**{'credential_type__kind__in': ['cloud', 'net']}) + return sublist_qs class JobLabelList(SubListAPIView): @@ -4252,7 +4316,6 @@ class UnifiedJobTemplateList(ListAPIView): capabilities_prefetch = [ 'admin', 'execute', {'copy': ['jobtemplate.project.use', 'jobtemplate.inventory.use', - 'jobtemplate.credential.use', 'jobtemplate.vault_credential.use', 'workflowjobtemplate.organization.admin']} ] diff --git a/awx/main/access.py b/awx/main/access.py index 9f907ac9d1..afe461b397 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -1149,7 +1149,7 @@ class JobTemplateAccess(BaseAccess): model = JobTemplate select_related = ('created_by', 'modified_by', 'inventory', 'project', - 'credential', 'next_schedule',) + 'next_schedule',) def filtered_queryset(self): return self.model.accessible_objects(self.user, 'read_role') @@ -1187,13 +1187,10 @@ class JobTemplateAccess(BaseAccess): else: return None - # If a credential is provided, the user should have use access to it. - if not self.check_related('credential', Credential, data, role_field='use_role'): - return False - - # If a vault credential is provided, the user should have use access to it. - if not self.check_related('vault_credential', Credential, data, role_field='use_role'): - return False + # If credentials is provided, the user should have use access to them. + for pk in data.get('credentials', []): + if self.user not in get_object_or_400(Credential, pk=pk).use_role: + return False # If an inventory is provided, the user should have use access. inventory = get_value(Inventory, 'inventory') @@ -1239,7 +1236,7 @@ class JobTemplateAccess(BaseAccess): self.check_license(feature='surveys') return True - for required_field in ('credential', 'inventory', 'project', 'vault_credential'): + for required_field in ('inventory', 'project'): required_obj = getattr(obj, required_field, None) if required_field not in data_for_change and required_obj is not None: data_for_change[required_field] = required_obj.pk @@ -1288,7 +1285,7 @@ class JobTemplateAccess(BaseAccess): if not obj.project.organization: return False return self.user.can_access(type(sub_obj), "read", sub_obj) and self.user in obj.project.organization.admin_role - if relationship == 'extra_credentials' and isinstance(sub_obj, Credential): + if relationship == 'credentials' and isinstance(sub_obj, Credential): return self.user in obj.admin_role and self.user in sub_obj.use_role return super(JobTemplateAccess, self).can_attach( obj, sub_obj, relationship, data, skip_sub_obj_read_check=skip_sub_obj_read_check) @@ -1297,7 +1294,7 @@ class JobTemplateAccess(BaseAccess): def can_unattach(self, obj, sub_obj, relationship, *args, **kwargs): if relationship == "instance_groups": return self.can_attach(obj, sub_obj, relationship, *args, **kwargs) - if relationship == 'extra_credentials' and isinstance(sub_obj, Credential): + if relationship == 'credentials' and isinstance(sub_obj, Credential): return self.user in obj.admin_role return super(JobTemplateAccess, self).can_attach(obj, sub_obj, relationship, *args, **kwargs) @@ -1316,7 +1313,7 @@ class JobAccess(BaseAccess): model = Job select_related = ('created_by', 'modified_by', 'job_template', 'inventory', - 'project', 'credential', 'job_template',) + 'project', 'job_template',) prefetch_related = ( 'unified_job_template', 'instance_group', @@ -1399,29 +1396,27 @@ class JobAccess(BaseAccess): if self.user.is_superuser: return True + credential_access = all([self.user in cred.use_role for cred in obj.credentials.all()]) inventory_access = obj.inventory and self.user in obj.inventory.use_role - credential_access = obj.credential and self.user in obj.credential.use_role - job_extra_credentials = set(obj.extra_credentials.all()) - if job_extra_credentials: - credential_access = False + job_credentials = set(obj.credentials.all()) # Check if JT execute access (and related prompts) is sufficient if obj.job_template is not None: prompts_access = True job_fields = {} - jt_extra_credentials = set(obj.job_template.extra_credentials.all()) + jt_credentials = set(obj.job_template.credentials.all()) for fd in obj.job_template._ask_for_vars_dict(): - if fd == 'extra_credentials': - job_fields[fd] = job_extra_credentials + if fd == 'credentials': + job_fields[fd] = job_credentials job_fields[fd] = getattr(obj, fd) accepted_fields, ignored_fields = obj.job_template._accept_or_ignore_job_kwargs(**job_fields) # Check if job fields are not allowed by current _on_launch settings for fd in ignored_fields: if fd == 'extra_vars': continue # we cannot yet validate validity of prompted extra_vars - elif fd == 'extra_credentials': - if job_extra_credentials != jt_extra_credentials: - # Job has extra_credentials that are not promptable + elif fd == 'credentials': + if job_credentials != jt_credentials: + # Job has credentials that are not promptable prompts_access = False break elif job_fields[fd] != getattr(obj.job_template, fd): @@ -1431,19 +1426,16 @@ class JobAccess(BaseAccess): # For those fields that are allowed by prompting, but differ # from JT, assure that user has explicit access to them if prompts_access: - if obj.credential != obj.job_template.credential and not credential_access: - prompts_access = False if obj.inventory != obj.job_template.inventory and not inventory_access: prompts_access = False - if prompts_access and job_extra_credentials != jt_extra_credentials: - for cred in job_extra_credentials: + if prompts_access and job_credentials != jt_credentials: + for cred in job_credentials: if self.user not in cred.use_role: prompts_access = False break if prompts_access and self.user in obj.job_template.execute_role: return True - org_access = obj.inventory and self.user in obj.inventory.organization.admin_role project_access = obj.project is None or self.user in obj.project.admin_role diff --git a/awx/main/management/commands/create_preload_data.py b/awx/main/management/commands/create_preload_data.py index c2acd7938e..842816cabe 100644 --- a/awx/main/management/commands/create_preload_data.py +++ b/awx/main/management/commands/create_preload_data.py @@ -45,10 +45,10 @@ class Command(BaseCommand): inventory=i, variables="ansible_connection: local", created_by=superuser) - JobTemplate.objects.create(name='Demo Job Template', - playbook='hello_world.yml', - project=p, - inventory=i, - credential=c) + jt = JobTemplate.objects.create(name='Demo Job Template', + playbook='hello_world.yml', + project=p, + inventory=i) + jt.credentials.add(c) print('Default organization added.') print('Demo Credential, Inventory, and Job Template added.') diff --git a/awx/main/migrations/0009_v330_multi_credential.py b/awx/main/migrations/0009_v330_multi_credential.py new file mode 100644 index 0000000000..5eef9126db --- /dev/null +++ b/awx/main/migrations/0009_v330_multi_credential.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + +from awx.main.migrations import _migration_utils as migration_utils +from awx.main.migrations._multi_cred import migrate_to_multi_cred + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0008_v320_drop_v1_credential_fields'), + ] + + operations = [ + migrations.AddField( + model_name='unifiedjob', + name='credentials', + field=models.ManyToManyField(related_name='unifiedjobs', to='main.Credential'), + ), + migrations.AddField( + model_name='unifiedjobtemplate', + name='credentials', + field=models.ManyToManyField(related_name='unifiedjobtemplates', to='main.Credential'), + ), + migrations.RunPython(migration_utils.set_current_apps_for_migrations), + migrations.RunPython(migrate_to_multi_cred), + migrations.RemoveField( + model_name='job', + name='credential', + ), + migrations.RemoveField( + model_name='job', + name='extra_credentials', + ), + migrations.RemoveField( + model_name='job', + name='vault_credential', + ), + migrations.RemoveField( + model_name='jobtemplate', + name='credential', + ), + migrations.RemoveField( + model_name='jobtemplate', + name='extra_credentials', + ), + migrations.RemoveField( + model_name='jobtemplate', + name='vault_credential', + ), + ] diff --git a/awx/main/migrations/_multi_cred.py b/awx/main/migrations/_multi_cred.py new file mode 100644 index 0000000000..c7c8252870 --- /dev/null +++ b/awx/main/migrations/_multi_cred.py @@ -0,0 +1,12 @@ +def migrate_to_multi_cred(app, schema_editor): + Job = app.get_model('main', 'Job') + JobTemplate = app.get_model('main', 'JobTemplate') + + for cls in (Job, JobTemplate): + for j in cls.objects.iterator(): + if j.credential: + j.credentials.add(j.credential) + if j.vault_credential: + j.credentials.add(j.vault_credential) + for cred in j.extra_credentials.all(): + j.credentials.add(cred) diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index 8c6bec874b..f59e27d59e 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -91,26 +91,6 @@ class JobOptions(BaseModel): default='', blank=True, ) - credential = models.ForeignKey( - 'Credential', - related_name='%(class)ss', - blank=True, - null=True, - default=None, - on_delete=models.SET_NULL, - ) - vault_credential = models.ForeignKey( - 'Credential', - related_name='%(class)ss_as_vault_credential+', - blank=True, - null=True, - default=None, - on_delete=models.SET_NULL, - ) - extra_credentials = models.ManyToManyField( - 'Credential', - related_name='%(class)ss_as_extra_credential+', - ) forks = models.PositiveIntegerField( blank=True, default=0, @@ -184,22 +164,31 @@ class JobOptions(BaseModel): ) return cred - @property - def all_credentials(self): - credentials = list(self.extra_credentials.all()) - if self.vault_credential: - credentials.insert(0, self.vault_credential) - if self.credential: - credentials.insert(0, self.credential) - return credentials - @property def network_credentials(self): - return [cred for cred in self.extra_credentials.all() if cred.credential_type.kind == 'net'] + return [cred for cred in self.credentials.all() if cred.credential_type.kind == 'net'] @property def cloud_credentials(self): - return [cred for cred in self.extra_credentials.all() if cred.credential_type.kind == 'cloud'] + return [cred for cred in self.credentials.all() if cred.credential_type.kind == 'cloud'] + + @property + def credential(self): + cred = self.get_deprecated_credential('ssh') + if cred is not None: + return cred.pk + + @property + def vault_credential(self): + cred = self.get_deprecated_credential('vault') + if cred is not None: + return cred.pk + + def get_deprecated_credential(self, kind): + try: + return [cred for cred in self.credentials.all() if cred.credential_type.kind == kind][0] + except IndexError: + return None # TODO: remove when API v1 is removed @property @@ -221,10 +210,8 @@ class JobOptions(BaseModel): def passwords_needed_to_start(self): '''Return list of password field names needed to start the job.''' needed = [] - if self.credential: - needed.extend(self.credential.passwords_needed) - if self.vault_credential: - needed.extend(self.vault_credential.passwords_needed) + for cred in self.credentials.all(): + needed.extend(cred.passwords_needed) return needed @@ -234,6 +221,7 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, Resour playbook) to an inventory source with a given credential. ''' SOFT_UNIQUE_TOGETHER = [('polymorphic_ctype', 'name')] + PASSWORD_FIELDS = ('credential', 'vault_credential') class Meta: app_label = 'main' @@ -298,12 +286,12 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, Resour @classmethod def _get_unified_job_field_names(cls): return ['name', 'description', 'job_type', 'inventory', 'project', - 'playbook', 'credential', 'vault_credential', - 'extra_credentials', 'forks', 'schedule', 'limit', 'verbosity', - 'job_tags', 'extra_vars', 'launch_type', 'force_handlers', - 'skip_tags', 'start_at_task', 'become_enabled', 'labels', - 'survey_passwords', 'allow_simultaneous', 'timeout', - 'use_fact_cache', 'diff_mode',] + 'playbook', 'credentials', 'forks', 'schedule', 'limit', + 'verbosity', 'job_tags', 'extra_vars', 'launch_type', + 'force_handlers', 'skip_tags', 'start_at_task', + 'become_enabled', 'labels', 'survey_passwords', + 'allow_simultaneous', 'timeout', 'use_fact_cache', + 'diff_mode',] def resource_validation_data(self): ''' @@ -317,10 +305,6 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, Resour resources_needed_to_start.append('inventory') if not self.ask_inventory_on_launch: validation_errors['inventory'] = [_("Job Template must provide 'inventory' or allow prompting for it."),] - if self.credential is None and self.vault_credential is None: - resources_needed_to_start.append('credential') - if not self.ask_credential_on_launch: - validation_errors['credential'] = [_("Job Template must provide 'credential' or allow prompting for it."),] # Job type dependent checks if self.project is None: @@ -379,9 +363,7 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, Resour job_type=self.ask_job_type_on_launch, verbosity=self.ask_verbosity_on_launch, inventory=self.ask_inventory_on_launch, - credential=self.ask_credential_on_launch, - vault_credential=self.ask_credential_on_launch, - extra_credentials=self.ask_credential_on_launch, + credentials=self.ask_credential_on_launch, ) def _accept_or_ignore_job_kwargs(self, **kwargs): @@ -715,17 +697,6 @@ class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskMana return self.global_instance_groups return selected_groups - # Job Credential required - @property - def can_start(self): - if not super(Job, self).can_start: - return False - - if not (self.credential) and not (self.vault_credential): - return False - - return True - ''' JobNotificationMixin ''' diff --git a/awx/main/models/unified_jobs.py b/awx/main/models/unified_jobs.py index 5f9c773c27..3e3a88246d 100644 --- a/awx/main/models/unified_jobs.py +++ b/awx/main/models/unified_jobs.py @@ -149,6 +149,10 @@ class UnifiedJobTemplate(PolymorphicModel, CommonModelNameNotUnique, Notificatio default='ok', editable=False, ) + credentials = models.ManyToManyField( + 'Credential', + related_name='%(class)ss', + ) labels = models.ManyToManyField( "Label", blank=True, @@ -579,6 +583,10 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique on_delete=models.SET_NULL, help_text=_('The Rampart/Instance group the job was run under'), ) + credentials = models.ManyToManyField( + 'Credential', + related_name='%(class)ss', + ) def get_absolute_url(self, request=None): real_instance = self.get_real_instance() diff --git a/awx/main/tasks.py b/awx/main/tasks.py index ce75f775dc..a0963c73ce 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -791,12 +791,15 @@ class BaseTask(LogErrorsTask): safe_env = self.build_safe_env(env, **kwargs) # handle custom injectors specified on the CredentialType - if hasattr(instance, 'all_credentials'): - credentials = instance.all_credentials + credentials = [] + if isinstance(instance, Job): + credentials = instance.credentials.all() elif hasattr(instance, 'credential'): + # once other UnifiedJobs (project updates, inventory updates) + # move from a .credential -> .credentials model, we can + # lose this block credentials = [instance.credential] - else: - credentials = [] + for credential in credentials: if credential: credential.credential_type.inject_credential( @@ -927,7 +930,7 @@ class RunJob(BaseTask): } ''' private_data = {'credentials': {}} - for credential in job.all_credentials: + for credential in job.credentials.all(): # If we were sent SSH credentials, decrypt them and send them # back (they will be written to a temporary file). if credential.ssh_key_data not in (None, ''): @@ -957,11 +960,11 @@ class RunJob(BaseTask): and ansible-vault. ''' passwords = super(RunJob, self).build_passwords(job, **kwargs) - for cred, fields in { - 'credential': ('ssh_key_unlock', 'ssh_password', 'become_password'), - 'vault_credential': ('vault_password',) + for kind, fields in { + 'ssh': ('ssh_key_unlock', 'ssh_password', 'become_password'), + 'vault': ('vault_password',) }.items(): - cred = getattr(job, cred, None) + cred = job.get_deprecated_credential(kind) if cred: for field in fields: if field == 'ssh_password': @@ -1072,7 +1075,8 @@ class RunJob(BaseTask): Build command line argument list for running ansible-playbook, optionally using ssh-agent for public/private key authentication. ''' - creds = job.credential + creds = job.get_deprecated_credential('ssh') + ssh_username, become_username, become_method = '', '', '' if creds: ssh_username = kwargs.get('username', creds.username) diff --git a/awx/main/tests/factories/fixtures.py b/awx/main/tests/factories/fixtures.py index 4916fc2d09..eee545336d 100644 --- a/awx/main/tests/factories/fixtures.py +++ b/awx/main/tests/factories/fixtures.py @@ -141,11 +141,11 @@ def mk_job(job_type='run', status='new', job_template=None, inventory=None, job.job_template = job_template job.inventory = inventory - job.credential = credential - job.project = project - if persisted: job.save() + job.credentials.add(credential) + job.project = project + return job @@ -164,9 +164,11 @@ def mk_job_template(name, job_type='run', if jt.inventory is None: jt.ask_inventory_on_launch = True - jt.credential = credential - if jt.credential is None: - jt.ask_credential_on_launch = True + if persisted and credential: + jt.save() + jt.credentials.add(credential) + if jt.credential is None: + jt.ask_credential_on_launch = True jt.project = project @@ -178,10 +180,10 @@ def mk_job_template(name, job_type='run', jt.save() if cloud_credential: cloud_credential.save() - jt.extra_credentials.add(cloud_credential) + jt.credentials.add(cloud_credential) if network_credential: network_credential.save() - jt.extra_credentials.add(network_credential) + jt.credentials.add(network_credential) jt.save() return jt diff --git a/awx/main/tests/functional/api/test_deprecated_credential_assignment.py b/awx/main/tests/functional/api/test_deprecated_credential_assignment.py new file mode 100644 index 0000000000..1527100c1c --- /dev/null +++ b/awx/main/tests/functional/api/test_deprecated_credential_assignment.py @@ -0,0 +1,340 @@ +import mock +import pytest + +from awx.main.models import Credential, Job +from awx.api.versioning import reverse + + +@pytest.fixture +def job_template(job_template, project, inventory): + job_template.playbook = 'helloworld.yml' + job_template.project = project + job_template.inventory = inventory + job_template.ask_credential_on_launch = True + job_template.save() + return job_template + + +@pytest.mark.django_db +@pytest.mark.parametrize('key', ('credential', 'vault_credential')) +def test_credential_access_empty(get, job_template, admin, key): + url = reverse('api:job_template_detail', kwargs={'pk': job_template.pk}) + resp = get(url, admin) + assert resp.data[key] is None + assert key not in resp.data['summary_fields'] + + +@pytest.mark.django_db +def test_ssh_credential_access(get, job_template, admin, machine_credential): + job_template.credentials.add(machine_credential) + url = reverse('api:job_template_detail', kwargs={'pk': job_template.pk}) + resp = get(url, admin) + assert resp.data['credential'] == machine_credential.pk + assert resp.data['summary_fields']['credential']['credential_type_id'] == machine_credential.pk + assert resp.data['summary_fields']['credential']['kind'] == 'ssh' + + +@pytest.mark.django_db +def test_ssh_credential_update(get, patch, job_template, admin, machine_credential): + url = reverse('api:job_template_detail', kwargs={'pk': job_template.pk}) + patch(url, {'credential': machine_credential.pk}, admin, expect=200) + resp = get(url, admin) + assert resp.data['credential'] == machine_credential.pk + + +@pytest.mark.django_db +def test_ssh_credential_update_invalid_kind(get, patch, job_template, admin, vault_credential): + url = reverse('api:job_template_detail', kwargs={'pk': job_template.pk}) + resp = patch(url, {'credential': vault_credential.pk}, admin, expect=400) + assert 'You must provide an SSH credential.' in resp.content + + +@pytest.mark.django_db +def test_vault_credential_access(get, job_template, admin, vault_credential): + job_template.credentials.add(vault_credential) + url = reverse('api:job_template_detail', kwargs={'pk': job_template.pk}) + resp = get(url, admin) + assert resp.data['vault_credential'] == vault_credential.pk + assert resp.data['summary_fields']['vault_credential']['credential_type_id'] == vault_credential.pk # noqa + assert resp.data['summary_fields']['vault_credential']['kind'] == 'vault' + + +@pytest.mark.django_db +def test_vault_credential_update(get, patch, job_template, admin, vault_credential): + url = reverse('api:job_template_detail', kwargs={'pk': job_template.pk}) + patch(url, {'vault_credential': vault_credential.pk}, admin, expect=200) + resp = get(url, admin) + assert resp.data['vault_credential'] == vault_credential.pk + + +@pytest.mark.django_db +def test_vault_credential_update_invalid_kind(get, patch, job_template, admin, + machine_credential): + url = reverse('api:job_template_detail', kwargs={'pk': job_template.pk}) + resp = patch(url, {'vault_credential': machine_credential.pk}, admin, expect=400) + assert 'You must provide a vault credential.' in resp.content + + +@pytest.mark.django_db +def test_extra_credentials_filtering(get, job_template, admin, + machine_credential, vault_credential, credential): + job_template.credentials.add(machine_credential) + job_template.credentials.add(vault_credential) + job_template.credentials.add(credential) + url = reverse( + 'api:job_template_extra_credentials_list', + kwargs={'version': 'v2', 'pk': job_template.pk} + ) + resp = get(url, admin, expect=200) + assert resp.data['count'] == 1 + assert resp.data['results'][0]['id'] == credential.pk + + +@pytest.mark.django_db +def test_extra_credentials_requires_cloud_or_net(get, post, job_template, admin, + machine_credential, vault_credential, credential, + net_credential): + url = reverse( + 'api:job_template_extra_credentials_list', + kwargs={'version': 'v2', 'pk': job_template.pk} + ) + + for cred in (machine_credential, vault_credential): + resp = post(url, {'associate': True, 'id': cred.pk}, admin, expect=400) + assert 'Extra credentials must be network or cloud.' in resp.content + + post(url, {'associate': True, 'id': credential.pk}, admin, expect=204) + assert get(url, admin).data['count'] == 1 + + post(url, {'associate': True, 'id': net_credential.pk}, admin, expect=204) + assert get(url, admin).data['count'] == 2 + + +@pytest.mark.django_db +def test_prevent_multiple_machine_creds(get, post, job_template, admin, machine_credential): + url = reverse( + 'api:job_template_credentials_list', + kwargs={'version': 'v2', 'pk': job_template.pk} + ) + + def _new_cred(name): + return { + 'name': name, + 'credential_type': machine_credential.credential_type.pk, + 'inputs': { + 'username': 'bob', + 'password': 'secret', + } + } + + post(url, _new_cred('First Cred'), admin, expect=201) + assert get(url, admin).data['count'] == 1 + + resp = post(url, _new_cred('Second Cred'), admin, expect=400) + assert 'Cannot assign multiple Machine credentials.' in resp.content + + +@pytest.mark.django_db +def test_prevent_multiple_machine_creds_at_launch(get, post, job_template, admin, machine_credential): + other_cred = Credential(credential_type=machine_credential.credential_type, name="Second", + inputs={'username': 'bob'}) + other_cred.save() + creds = [machine_credential.pk, other_cred.pk] + url = reverse('api:job_template_launch', kwargs={'pk': job_template.pk}) + resp = post(url, {'credentials': creds}, admin) + assert 'Cannot assign multiple Machine credentials.' in resp.content + + +@pytest.mark.django_db +def test_extra_credentials_unique_by_kind(get, post, job_template, admin, + credentialtype_aws): + url = reverse( + 'api:job_template_extra_credentials_list', + kwargs={'version': 'v2', 'pk': job_template.pk} + ) + + def _new_cred(name): + return { + 'name': name, + 'credential_type': credentialtype_aws.pk, + 'inputs': { + 'username': 'bob', + 'password': 'secret', + } + } + + post(url, _new_cred('First Cred'), admin, expect=201) + assert get(url, admin).data['count'] == 1 + + resp = post(url, _new_cred('Second Cred'), admin, expect=400) + assert 'Cannot assign multiple Amazon Web Services credentials.' in resp.content + + +@pytest.mark.django_db +def test_ssh_credential_at_launch(get, post, job_template, admin, machine_credential): + url = reverse('api:job_template_launch', kwargs={'pk': job_template.pk}) + pk = post(url, {'credential': machine_credential.pk}, admin, expect=201).data['job'] + summary_fields = get(reverse('api:job_detail', kwargs={'pk': pk}), admin).data['summary_fields'] + + assert len(summary_fields['credentials']) == 1 + + +@pytest.mark.django_db +def test_vault_credential_at_launch(get, post, job_template, admin, vault_credential): + url = reverse('api:job_template_launch', kwargs={'pk': job_template.pk}) + pk = post(url, {'vault_credential': vault_credential.pk}, admin, expect=201).data['job'] + summary_fields = get(reverse('api:job_detail', kwargs={'pk': pk}), admin).data['summary_fields'] + + assert len(summary_fields['credentials']) == 1 + + +@pytest.mark.django_db +def test_extra_credentials_at_launch(get, post, job_template, admin, credential): + url = reverse('api:job_template_launch', kwargs={'pk': job_template.pk}) + pk = post(url, {'extra_credentials': [credential.pk]}, admin, expect=201).data['job'] + summary_fields = get(reverse('api:job_detail', kwargs={'pk': pk}), admin).data['summary_fields'] + + assert len(summary_fields['credentials']) == 1 + + +@pytest.mark.django_db +def test_modify_ssh_credential_at_launch(get, post, job_template, admin, + machine_credential, vault_credential, credential): + job_template.credentials.add(vault_credential) + job_template.credentials.add(credential) + job_template.save() + url = reverse('api:job_template_launch', kwargs={'pk': job_template.pk}) + pk = post(url, {'credential': machine_credential.pk}, admin, expect=201).data['job'] + + summary_fields = get(reverse('api:job_detail', kwargs={'pk': pk}), admin).data['summary_fields'] + assert len(summary_fields['credentials']) == 3 + + +@pytest.mark.django_db +def test_modify_vault_credential_at_launch(get, post, job_template, admin, + machine_credential, vault_credential, credential): + job_template.credentials.add(machine_credential) + job_template.credentials.add(credential) + job_template.save() + url = reverse('api:job_template_launch', kwargs={'pk': job_template.pk}) + pk = post(url, {'vault_credential': vault_credential.pk}, admin, expect=201).data['job'] + + summary_fields = get(reverse('api:job_detail', kwargs={'pk': pk}), admin).data['summary_fields'] + assert len(summary_fields['credentials']) == 3 + + +@pytest.mark.django_db +def test_modify_extra_credentials_at_launch(get, post, job_template, admin, + machine_credential, vault_credential, credential): + job_template.credentials.add(machine_credential) + job_template.credentials.add(vault_credential) + job_template.save() + url = reverse('api:job_template_launch', kwargs={'pk': job_template.pk}) + pk = post(url, {'extra_credentials': [credential.pk]}, admin, expect=201).data['job'] + + summary_fields = get(reverse('api:job_detail', kwargs={'pk': pk}), admin).data['summary_fields'] + assert len(summary_fields['credentials']) == 3 + + +@pytest.mark.django_db +def test_overwrite_ssh_credential_at_launch(get, post, job_template, admin, machine_credential): + job_template.credentials.add(machine_credential) + job_template.save() + + new_cred = machine_credential + new_cred.pk = None + new_cred.save() + + url = reverse('api:job_template_launch', kwargs={'pk': job_template.pk}) + pk = post(url, {'credential': new_cred.pk}, admin, expect=201).data['job'] + + summary_fields = get(reverse('api:job_detail', kwargs={'pk': pk}), admin).data['summary_fields'] + assert len(summary_fields['credentials']) == 1 + assert summary_fields['credentials'][0]['id'] == new_cred.pk + + +@pytest.mark.django_db +def test_ssh_password_prompted_at_launch(get, post, job_template, admin, machine_credential): + job_template.credentials.add(machine_credential) + job_template.save() + machine_credential.inputs['password'] = 'ASK' + machine_credential.save() + url = reverse('api:job_template_launch', kwargs={'pk': job_template.pk}) + resp = get(url, admin) + assert 'ssh_password' in resp.data['passwords_needed_to_start'] + + +@pytest.mark.django_db +def test_prompted_credential_removed_on_launch(get, post, job_template, admin, machine_credential): + # If a JT has a credential that needs a password, but the launch POST + # specifies {"credentials": []}, don't require any passwords + job_template.credentials.add(machine_credential) + job_template.save() + machine_credential.inputs['password'] = 'ASK' + machine_credential.save() + url = reverse('api:job_template_launch', kwargs={'pk': job_template.pk}) + resp = post(url, {}, admin, expect=400) + resp = post(url, {'credentials': []}, admin, expect=201) + assert 'job' in resp.data + + +@pytest.mark.django_db +def test_ssh_credential_with_password_at_launch(get, post, job_template, admin, machine_credential): + machine_credential.inputs['password'] = 'ASK' + machine_credential.save() + url = reverse('api:job_template_launch', kwargs={'pk': job_template.pk}) + resp = post(url, {'credentials': [machine_credential.pk]}, admin, expect=400) + assert resp.data['passwords_needed_to_start'] == ['ssh_password'] + + with mock.patch.object(Job, 'signal_start') as signal_start: + resp = post(url, { + 'credentials': [machine_credential.pk], + 'ssh_password': 'testing123' + }, admin, expect=201) + signal_start.assert_called_with(ssh_password='testing123') + + +@pytest.mark.django_db +def test_vault_password_prompted_at_launch(get, post, job_template, admin, vault_credential): + job_template.credentials.add(vault_credential) + job_template.save() + vault_credential.inputs['vault_password'] = 'ASK' + vault_credential.save() + url = reverse('api:job_template_launch', kwargs={'pk': job_template.pk}) + resp = get(url, admin) + assert 'vault_password' in resp.data['passwords_needed_to_start'] + + +@pytest.mark.django_db +def test_vault_credential_with_password_at_launch(get, post, job_template, admin, vault_credential): + vault_credential.inputs['vault_password'] = 'ASK' + vault_credential.save() + url = reverse('api:job_template_launch', kwargs={'pk': job_template.pk}) + + resp = post(url, {'credentials': [vault_credential.pk]}, admin, expect=400) + assert resp.data['passwords_needed_to_start'] == ['vault_password'] + + with mock.patch.object(Job, 'signal_start') as signal_start: + resp = post(url, { + 'credentials': [vault_credential.pk], + 'vault_password': 'testing123' + }, admin, expect=201) + signal_start.assert_called_with(vault_password='testing123') + + +@pytest.mark.django_db +def test_extra_creds_prompted_at_launch(get, post, job_template, admin, net_credential): + url = reverse('api:job_template_launch', kwargs={'pk': job_template.pk}) + resp = post(url, {'extra_credentials': [net_credential.pk]}, admin, expect=201) + + summary_fields = get( + reverse('api:job_detail', kwargs={'pk': resp.data['job']}), + admin + ).data['summary_fields'] + assert len(summary_fields['credentials']) == 1 + + +@pytest.mark.django_db +def test_invalid_mixed_credentials_specification(get, post, job_template, admin, net_credential): + url = reverse('api:job_template_launch', kwargs={'pk': job_template.pk}) + post(url, {'credentials': [net_credential.pk], 'extra_credentials': [net_credential.pk]}, admin, expect=400) diff --git a/awx/main/tests/functional/api/test_job.py b/awx/main/tests/functional/api/test_job.py index 614f04f930..6ba5f1057c 100644 --- a/awx/main/tests/functional/api/test_job.py +++ b/awx/main/tests/functional/api/test_job.py @@ -10,7 +10,7 @@ def test_extra_credentials(get, organization_factory, job_template_factory, cred objs = organization_factory("org", superusers=['admin']) jt = job_template_factory("jt", organization=objs.organization, inventory='test_inv', project='test_proj').job_template - jt.extra_credentials.add(credential) + jt.credentials.add(credential) jt.save() job = jt.create_unified_job() @@ -22,8 +22,8 @@ def test_extra_credentials(get, organization_factory, job_template_factory, cred @pytest.mark.django_db def test_job_relaunch_permission_denied_response( post, get, inventory, project, credential, net_credential, machine_credential): - jt = JobTemplate.objects.create(name='testjt', inventory=inventory, project=project, - credential=machine_credential) + jt = JobTemplate.objects.create(name='testjt', inventory=inventory, project=project) + jt.credentials.add(machine_credential) jt_user = User.objects.create(username='jobtemplateuser') jt.execute_role.members.add(jt_user) job = jt.create_unified_job() @@ -33,7 +33,7 @@ def test_job_relaunch_permission_denied_response( assert r.data['summary_fields']['user_capabilities']['start'] # Job has prompted extra_credential, launch denied w/ message - job.extra_credentials.add(net_credential) + job.credentials.add(net_credential) r = post(reverse('api:job_relaunch', kwargs={'pk':job.pk}), {}, jt_user, expect=403) assert 'launched with prompted fields' in r.data['detail'] assert 'do not have permission' in r.data['detail'] @@ -50,8 +50,9 @@ def test_job_relaunch_on_failed_hosts(post, inventory, project, machine_credenti h3 = inventory.hosts.create(name='host3') # failed host jt = JobTemplate.objects.create( name='testjt', inventory=inventory, - project=project, credential=machine_credential + project=project ) + jt.credentials.add(machine_credential) job = jt.create_unified_job(_eager_fields={'status': 'failed', 'limit': 'host1,host2,host3'}) job.job_events.create(event='playbook_on_stats') job.job_host_summaries.create(host=h1, failed=False, ok=1, changed=0, failures=0, host_name=h1.name) diff --git a/awx/main/tests/functional/api/test_job_runtime_params.py b/awx/main/tests/functional/api/test_job_runtime_params.py index be6f0b56fa..6214db94a8 100644 --- a/awx/main/tests/functional/api/test_job_runtime_params.py +++ b/awx/main/tests/functional/api/test_job_runtime_params.py @@ -28,7 +28,7 @@ def runtime_data(organization, credentialtype_ssh): job_tags='provision', skip_tags='restart', inventory=inv_obj.pk, - credential=cred_obj.pk, + credentials=[cred_obj.pk], ) @@ -40,11 +40,10 @@ def job_with_links(machine_credential, inventory): @pytest.fixture def job_template_prompts(project, inventory, machine_credential): def rf(on_off): - return JobTemplate.objects.create( + jt = JobTemplate.objects.create( job_type='run', project=project, inventory=inventory, - credential=machine_credential, name='deploy-job-template', ask_variables_on_launch=on_off, ask_tags_on_launch=on_off, @@ -55,6 +54,8 @@ def job_template_prompts(project, inventory, machine_credential): ask_credential_on_launch=on_off, ask_verbosity_on_launch=on_off, ) + jt.credentials.add(machine_credential) + return jt return rf @@ -64,7 +65,6 @@ def job_template_prompts_null(project): job_type='run', project=project, inventory=None, - credential=None, name='deploy-job-template', ask_variables_on_launch=True, ask_tags_on_launch=True, @@ -87,7 +87,7 @@ def test_job_ignore_unprompted_vars(runtime_data, job_template_prompts, post, ad with mocker.patch.object(JobTemplate, 'create_unified_job', return_value=mock_job): with mocker.patch('awx.api.serializers.JobSerializer.to_representation'): - response = post(reverse('api:job_template_launch', kwargs={'pk':job_template.pk}), + response = post(reverse('api:job_template_launch', kwargs={'pk': job_template.pk}), runtime_data, admin_user, expect=201) assert JobTemplate.create_unified_job.called assert JobTemplate.create_unified_job.call_args == ({'extra_vars':{}},) @@ -104,7 +104,7 @@ def test_job_ignore_unprompted_vars(runtime_data, job_template_prompts, post, ad assert 'job_type' in response.data['ignored_fields'] assert 'limit' in response.data['ignored_fields'] assert 'inventory' in response.data['ignored_fields'] - assert 'credential' in response.data['ignored_fields'] + assert 'credentials' in response.data['ignored_fields'] assert 'job_tags' in response.data['ignored_fields'] assert 'skip_tags' in response.data['ignored_fields'] @@ -155,7 +155,7 @@ def test_job_accept_prompted_vars_null(runtime_data, job_template_prompts_null, job_template.execute_role.members.add(rando) # Give user permission to use inventory and credential at runtime - credential = Credential.objects.get(pk=runtime_data['credential']) + credential = Credential.objects.get(pk=runtime_data['credentials'][0]) credential.use_role.members.add(rando) inventory = Inventory.objects.get(pk=runtime_data['inventory']) inventory.use_role.members.add(rando) @@ -182,11 +182,11 @@ def test_job_reject_invalid_prompted_vars(runtime_data, job_template_prompts, po response = post( reverse('api:job_template_launch', kwargs={'pk':job_template.pk}), dict(job_type='foobicate', # foobicate is not a valid job type - inventory=87865, credential=48474), admin_user, expect=400) + inventory=87865, credentials=[48474]), admin_user, expect=400) assert response.data['job_type'] == [u'"foobicate" is not a valid choice.'] assert response.data['inventory'] == [u'Invalid pk "87865" - object does not exist.'] - assert response.data['credential'] == [u'Invalid pk "48474" - object does not exist.'] + assert response.data['credentials'] == [u'Invalid pk "48474" - object does not exist.'] @pytest.mark.django_db @@ -235,7 +235,7 @@ def test_job_launch_fails_without_credential_access(job_template_prompts, runtim # Assure that giving a credential without access blocks the launch response = post(reverse('api:job_template_launch', kwargs={'pk':job_template.pk}), - dict(credential=runtime_data['credential']), rando, expect=403) + dict(credentials=runtime_data['credentials']), rando, expect=403) assert response.data['detail'] == u'You do not have permission to perform this action.' @@ -258,7 +258,7 @@ def test_job_launch_JT_with_validation(machine_credential, deploy_jobtemplate): deploy_jobtemplate.ask_credential_on_launch = True deploy_jobtemplate.save() - kv = dict(extra_vars={"job_launch_var": 4}, credential=machine_credential.id) + kv = dict(extra_vars={"job_launch_var": 4}, credentials=[machine_credential.id]) serializer = JobLaunchSerializer( instance=deploy_jobtemplate, data=kv, context={'obj': deploy_jobtemplate, 'data': kv, 'passwords': {}}) @@ -270,106 +270,15 @@ def test_job_launch_JT_with_validation(machine_credential, deploy_jobtemplate): final_job_extra_vars = yaml.load(job_obj.extra_vars) assert 'job_template_var' in final_job_extra_vars assert 'job_launch_var' in final_job_extra_vars - assert job_obj.credential.id == machine_credential.id + assert [cred.pk for cred in job_obj.credentials.all()] == [machine_credential.id] @pytest.mark.django_db -@pytest.mark.parametrize('pks, error_msg', [ - ([1], 'must be network or cloud'), - ([999], 'object does not exist'), -]) -def test_job_launch_JT_with_invalid_extra_credentials(machine_credential, deploy_jobtemplate, pks, error_msg): +def test_job_launch_with_default_creds(machine_credential, vault_credential, deploy_jobtemplate): deploy_jobtemplate.ask_credential_on_launch = True - deploy_jobtemplate.save() - - kv = dict(extra_credentials=pks, credential=machine_credential.id) - serializer = JobLaunchSerializer( - instance=deploy_jobtemplate, data=kv, - context={'obj': deploy_jobtemplate, 'data': kv, 'passwords': {}}) - validated = serializer.is_valid() - assert validated is False - - -@pytest.mark.django_db -def test_job_launch_JT_enforces_unique_extra_credential_kinds(machine_credential, credentialtype_aws, deploy_jobtemplate): - """ - JT launching should require that extra_credentials have distinct CredentialTypes - """ - pks = [] - for i in range(2): - aws = Credential.objects.create( - name='cred-%d' % i, - credential_type=credentialtype_aws, - inputs={ - 'username': 'test_user', - 'password': 'pas4word' - } - ) - aws.save() - pks.append(aws.pk) - - kv = dict(extra_credentials=pks, credential=machine_credential.id) - serializer = JobLaunchSerializer( - instance=deploy_jobtemplate, data=kv, - context={'obj': deploy_jobtemplate, 'data': kv, 'passwords': {}}) - validated = serializer.is_valid() - assert validated is False - - -@pytest.mark.django_db -@pytest.mark.parametrize('ask_credential_on_launch', [True, False]) -def test_job_launch_with_no_credentials(deploy_jobtemplate, ask_credential_on_launch): - deploy_jobtemplate.credential = None - deploy_jobtemplate.vault_credential = None - deploy_jobtemplate.ask_credential_on_launch = ask_credential_on_launch - serializer = JobLaunchSerializer( - instance=deploy_jobtemplate, data={}, - context={'obj': deploy_jobtemplate, 'data': {}, 'passwords': {}}) - validated = serializer.is_valid() - assert validated is False - assert serializer.errors['credential'] == ["Job Template 'credential' is missing or undefined."] - - -@pytest.mark.django_db -def test_job_launch_with_only_vault_credential(vault_credential, deploy_jobtemplate): - deploy_jobtemplate.credential = None - deploy_jobtemplate.vault_credential = vault_credential - serializer = JobLaunchSerializer( - instance=deploy_jobtemplate, data={}, - context={'obj': deploy_jobtemplate, 'data': {}, 'passwords': {}}) - validated = serializer.is_valid() - assert validated - - prompted_fields, ignored_fields = deploy_jobtemplate._accept_or_ignore_job_kwargs(**{}) - job_obj = deploy_jobtemplate.create_unified_job(**prompted_fields) - - assert job_obj.vault_credential.pk == vault_credential.pk - - -@pytest.mark.django_db -def test_job_launch_with_vault_credential_ask_for_machine(vault_credential, deploy_jobtemplate): - deploy_jobtemplate.credential = None - deploy_jobtemplate.ask_credential_on_launch = True - deploy_jobtemplate.vault_credential = vault_credential - serializer = JobLaunchSerializer( - instance=deploy_jobtemplate, data={}, - context={'obj': deploy_jobtemplate, 'data': {}, 'passwords': {}}) - validated = serializer.is_valid() - assert validated - - prompted_fields, ignored_fields = deploy_jobtemplate._accept_or_ignore_job_kwargs(**{}) - job_obj = deploy_jobtemplate.create_unified_job(**prompted_fields) - assert job_obj.credential is None - assert job_obj.vault_credential.pk == vault_credential.pk - - -@pytest.mark.django_db -def test_job_launch_with_vault_credential_and_prompted_machine_cred(machine_credential, vault_credential, - deploy_jobtemplate): - deploy_jobtemplate.credential = None - deploy_jobtemplate.ask_credential_on_launch = True - deploy_jobtemplate.vault_credential = vault_credential - kv = dict(credential=machine_credential.id) + deploy_jobtemplate.credentials.add(machine_credential) + deploy_jobtemplate.credentials.add(vault_credential) + kv = dict() serializer = JobLaunchSerializer( instance=deploy_jobtemplate, data=kv, context={'obj': deploy_jobtemplate, 'data': kv, 'passwords': {}}) @@ -378,24 +287,26 @@ def test_job_launch_with_vault_credential_and_prompted_machine_cred(machine_cred prompted_fields, ignored_fields = deploy_jobtemplate._accept_or_ignore_job_kwargs(**kv) job_obj = deploy_jobtemplate.create_unified_job(**prompted_fields) - assert job_obj.credential.pk == machine_credential.pk - assert job_obj.vault_credential.pk == vault_credential.pk + assert job_obj.credential == machine_credential.pk + assert job_obj.vault_credential == vault_credential.pk @pytest.mark.django_db -def test_job_launch_JT_with_default_vault_credential(machine_credential, vault_credential, deploy_jobtemplate): - deploy_jobtemplate.credential = machine_credential - deploy_jobtemplate.vault_credential = vault_credential +def test_job_launch_with_empty_creds(machine_credential, vault_credential, deploy_jobtemplate): + deploy_jobtemplate.ask_credential_on_launch = True + deploy_jobtemplate.credentials.add(machine_credential) + deploy_jobtemplate.credentials.add(vault_credential) + kv = dict(credentials=[]) serializer = JobLaunchSerializer( - instance=deploy_jobtemplate, data={}, - context={'obj': deploy_jobtemplate, 'data': {}, 'passwords': {}}) + instance=deploy_jobtemplate, data=kv, + context={'obj': deploy_jobtemplate, 'data': kv, 'passwords': {}}) validated = serializer.is_valid() assert validated - prompted_fields, ignored_fields = deploy_jobtemplate._accept_or_ignore_job_kwargs(**{}) + prompted_fields, ignored_fields = deploy_jobtemplate._accept_or_ignore_job_kwargs(**kv) job_obj = deploy_jobtemplate.create_unified_job(**prompted_fields) - - assert job_obj.vault_credential.pk == vault_credential.pk + assert job_obj.credential is None + assert job_obj.vault_credential is None @pytest.mark.django_db @@ -403,8 +314,7 @@ def test_job_launch_fails_with_missing_vault_password(machine_credential, vault_ deploy_jobtemplate, post, rando): vault_credential.vault_password = 'ASK' vault_credential.save() - deploy_jobtemplate.credential = machine_credential - deploy_jobtemplate.vault_credential = vault_credential + deploy_jobtemplate.credentials.add(vault_credential) deploy_jobtemplate.execute_role.members.add(rando) deploy_jobtemplate.save() @@ -421,7 +331,7 @@ def test_job_launch_fails_with_missing_ssh_password(machine_credential, deploy_j rando): machine_credential.password = 'ASK' machine_credential.save() - deploy_jobtemplate.credential = machine_credential + deploy_jobtemplate.credentials.add(machine_credential) deploy_jobtemplate.execute_role.members.add(rando) deploy_jobtemplate.save() @@ -440,8 +350,8 @@ def test_job_launch_fails_with_missing_vault_and_ssh_password(machine_credential vault_credential.save() machine_credential.password = 'ASK' machine_credential.save() - deploy_jobtemplate.credential = machine_credential - deploy_jobtemplate.vault_credential = vault_credential + deploy_jobtemplate.credentials.add(machine_credential) + deploy_jobtemplate.credentials.add(vault_credential) deploy_jobtemplate.execute_role.members.add(rando) deploy_jobtemplate.save() @@ -458,8 +368,8 @@ def test_job_launch_pass_with_prompted_vault_password(machine_credential, vault_ deploy_jobtemplate, post, rando): vault_credential.vault_password = 'ASK' vault_credential.save() - deploy_jobtemplate.credential = machine_credential - deploy_jobtemplate.vault_credential = vault_credential + deploy_jobtemplate.credentials.add(machine_credential) + deploy_jobtemplate.credentials.add(vault_credential) deploy_jobtemplate.execute_role.members.add(rando) deploy_jobtemplate.save() @@ -473,27 +383,6 @@ def test_job_launch_pass_with_prompted_vault_password(machine_credential, vault_ signal_start.assert_called_with(vault_password='vault-me') -@pytest.mark.django_db -def test_job_launch_JT_with_extra_credentials(machine_credential, credential, net_credential, deploy_jobtemplate): - deploy_jobtemplate.ask_credential_on_launch = True - deploy_jobtemplate.save() - - kv = dict(extra_credentials=[credential.pk, net_credential.pk], credential=machine_credential.id) - serializer = JobLaunchSerializer( - instance=deploy_jobtemplate, data=kv, - context={'obj': deploy_jobtemplate, 'data': kv, 'passwords': {}}) - validated = serializer.is_valid() - assert validated - - prompted_fields, ignored_fields = deploy_jobtemplate._accept_or_ignore_job_kwargs(**kv) - job_obj = deploy_jobtemplate.create_unified_job(**prompted_fields) - - extra_creds = job_obj.extra_credentials.all() - assert len(extra_creds) == 2 - assert credential in extra_creds - assert net_credential in extra_creds - - @pytest.mark.django_db @pytest.mark.job_runtime_vars def test_job_launch_unprompted_vars_with_survey(mocker, survey_spec_factory, job_template_prompts, post, admin_user): diff --git a/awx/main/tests/functional/api/test_job_template.py b/awx/main/tests/functional/api/test_job_template.py index e3898b2900..476b4f5945 100644 --- a/awx/main/tests/functional/api/test_job_template.py +++ b/awx/main/tests/functional/api/test_job_template.py @@ -30,7 +30,7 @@ def test_create(post, project, machine_credential, inventory, alice, grant_proje post(reverse('api:job_template_list'), { 'name': 'Some name', 'project': project.id, - 'credential': machine_credential.id, + 'credentials': [machine_credential.id], 'inventory': inventory.id, 'playbook': 'helloworld.yml', }, alice, expect=expect) @@ -184,7 +184,7 @@ def test_detach_extra_credential(get, post, organization_factory, job_template_f objs = organization_factory("org", superusers=['admin']) jt = job_template_factory("jt", organization=objs.organization, inventory='test_inv', project='test_proj').job_template - jt.extra_credentials.add(credential) + jt.credentials.add(credential) jt.save() url = reverse('api:job_template_extra_credentials_list', kwargs={'version': 'v2', 'pk': jt.pk}) @@ -222,8 +222,8 @@ def test_v1_extra_credentials_detail(get, organization_factory, job_template_fac objs = organization_factory("org", superusers=['admin']) jt = job_template_factory("jt", organization=objs.organization, inventory='test_inv', project='test_proj').job_template - jt.extra_credentials.add(credential) - jt.extra_credentials.add(net_credential) + jt.credentials.add(credential) + jt.credentials.add(net_credential) jt.save() url = reverse('api:job_template_detail', kwargs={'version': 'v1', 'pk': jt.pk}) @@ -272,8 +272,8 @@ def test_filter_by_v1(get, organization_factory, job_template_factory, credentia objs = organization_factory("org", superusers=['admin']) jt = job_template_factory("jt", organization=objs.organization, inventory='test_inv', project='test_proj').job_template - jt.extra_credentials.add(credential) - jt.extra_credentials.add(net_credential) + jt.credentials.add(credential) + jt.credentials.add(net_credential) jt.save() for query in ( @@ -312,7 +312,7 @@ def test_edit_sensitive_fields(patch, job_template_factory, alice, grant_project patch(reverse('api:job_template_detail', kwargs={'pk': objs.job_template.id}), { 'name': 'Some name', 'project': objs.project.id, - 'credential': objs.credential.id, + 'credentials': [objs.credential.id], 'inventory': objs.inventory.id, 'playbook': 'alt-helloworld.yml', }, alice, expect=expect) @@ -459,8 +459,7 @@ def test_launch_with_extra_credentials(get, post, organization_factory, resp = post( reverse('api:job_template_launch', kwargs={'pk': jt.pk}), dict( - credential=machine_credential.pk, - extra_credentials=[credential.pk, net_credential.pk] + credentials=[machine_credential.pk, credential.pk, net_credential.pk] ), objs.superusers.admin, expect=201 ) @@ -480,83 +479,24 @@ def test_launch_with_extra_credentials_not_allowed(get, post, organization_facto objs = organization_factory("org", superusers=['admin']) jt = job_template_factory("jt", organization=objs.organization, inventory='test_inv', project='test_proj').job_template - jt.credential = machine_credential + jt.credentials.add(machine_credential) jt.ask_credential_on_launch = False jt.save() resp = post( reverse('api:job_template_launch', kwargs={'pk': jt.pk}), dict( - credential=machine_credential.pk, - extra_credentials=[credential.pk, net_credential.pk] + credentials=[machine_credential.pk, credential.pk, net_credential.pk] ), objs.superusers.admin ) - assert 'credential' in resp.data['ignored_fields'].keys() - assert 'extra_credentials' in resp.data['ignored_fields'].keys() + assert 'credentials' in resp.data['ignored_fields'].keys() job_pk = resp.data.get('id') resp = get(reverse('api:job_extra_credentials_list', kwargs={'pk': job_pk}), objs.superusers.admin) assert resp.data.get('count') == 0 -@pytest.mark.django_db -def test_launch_with_extra_credentials_from_jt(get, post, organization_factory, - job_template_factory, machine_credential, - credential, net_credential): - objs = organization_factory("org", superusers=['admin']) - jt = job_template_factory("jt", organization=objs.organization, - inventory='test_inv', project='test_proj').job_template - jt.ask_credential_on_launch = True - jt.extra_credentials.add(credential) - jt.extra_credentials.add(net_credential) - jt.save() - - resp = post( - reverse('api:job_template_launch', kwargs={'pk': jt.pk}), - dict( - credential=machine_credential.pk - ), - objs.superusers.admin, expect=201 - ) - job_pk = resp.data.get('id') - - resp = get(reverse('api:job_extra_credentials_list', kwargs={'pk': job_pk}), objs.superusers.admin) - assert resp.data.get('count') == 2 - - resp = get(reverse('api:job_template_extra_credentials_list', kwargs={'pk': jt.pk}), objs.superusers.admin) - assert resp.data.get('count') == 2 - - -@pytest.mark.django_db -def test_launch_with_empty_extra_credentials(get, post, organization_factory, - job_template_factory, machine_credential, - credential, net_credential): - objs = organization_factory("org", superusers=['admin']) - jt = job_template_factory("jt", organization=objs.organization, - inventory='test_inv', project='test_proj').job_template - jt.ask_credential_on_launch = True - jt.extra_credentials.add(credential) - jt.extra_credentials.add(net_credential) - jt.save() - - resp = post( - reverse('api:job_template_launch', kwargs={'pk': jt.pk}), - dict( - credential=machine_credential.pk, - extra_credentials=[], - ), - objs.superusers.admin, expect=201 - ) - job_pk = resp.data.get('id') - - resp = get(reverse('api:job_extra_credentials_list', kwargs={'pk': job_pk}), objs.superusers.admin) - assert resp.data.get('count') == 0 - - resp = get(reverse('api:job_template_extra_credentials_list', kwargs={'pk': jt.pk}), objs.superusers.admin) - assert resp.data.get('count') == 2 - - @pytest.mark.django_db def test_v1_launch_with_extra_credentials(get, post, organization_factory, job_template_factory, machine_credential, diff --git a/awx/main/tests/functional/api/test_rbac_displays.py b/awx/main/tests/functional/api/test_rbac_displays.py index de16354d47..fd03710ec9 100644 --- a/awx/main/tests/functional/api/test_rbac_displays.py +++ b/awx/main/tests/functional/api/test_rbac_displays.py @@ -89,8 +89,7 @@ class TestJobTemplateCopyEdit: job_type='run', project=project, inventory=None, ask_inventory_on_launch=False, # not allowed - credential=None, ask_credential_on_launch=True, - name='deploy-job-template' + ask_credential_on_launch=True, name='deploy-job-template' ) serializer = JobTemplateSerializer(jt_res, context=self.fake_context(admin_user)) response = serializer.to_representation(jt_res) @@ -111,22 +110,6 @@ class TestJobTemplateCopyEdit: assert response['summary_fields']['user_capabilities']['copy'] assert response['summary_fields']['user_capabilities']['edit'] - def test_org_admin_foreign_cred_no_copy_edit(self, jt_copy_edit, org_admin, machine_credential): - """ - Organization admins without access to the 3 related resources: - SHOULD NOT be able to copy JT - SHOULD be able to edit that job template, for nonsensitive changes - """ - - # Attach credential to JT that org admin cannot use - jt_copy_edit.credential = machine_credential - jt_copy_edit.save() - - serializer = JobTemplateSerializer(jt_copy_edit, context=self.fake_context(org_admin)) - response = serializer.to_representation(jt_copy_edit) - assert not response['summary_fields']['user_capabilities']['copy'] - assert response['summary_fields']['user_capabilities']['edit'] - def test_jt_admin_copy_edit(self, jt_copy_edit, rando): """ JT admins wihout access to associated resources SHOULD NOT be able to copy @@ -302,27 +285,22 @@ def test_prefetch_group_capabilities(group, rando): @pytest.mark.django_db -def test_prefetch_jt_copy_capability(job_template, project, inventory, - machine_credential, vault_credential, rando): +def test_prefetch_jt_copy_capability(job_template, project, inventory, rando): job_template.project = project job_template.inventory = inventory - job_template.credential = machine_credential - job_template.vault_credential = vault_credential job_template.save() qs = JobTemplate.objects.all() cache_list_capabilities(qs, [{'copy': [ - 'project.use', 'inventory.use', 'credential.use', 'vault_credential.use' + 'project.use', 'inventory.use', ]}], JobTemplate, rando) assert qs[0].capabilities_cache == {'copy': False} project.use_role.members.add(rando) inventory.use_role.members.add(rando) - machine_credential.use_role.members.add(rando) - vault_credential.use_role.members.add(rando) cache_list_capabilities(qs, [{'copy': [ - 'project.use', 'inventory.use', 'credential.use', 'vault_credential.use' + 'project.use', 'inventory.use', ]}], JobTemplate, rando) assert qs[0].capabilities_cache == {'copy': True} diff --git a/awx/main/tests/functional/conftest.py b/awx/main/tests/functional/conftest.py index 81c9ab7767..a39df01a06 100644 --- a/awx/main/tests/functional/conftest.py +++ b/awx/main/tests/functional/conftest.py @@ -81,26 +81,26 @@ def user(): @pytest.fixture def check_jobtemplate(project, inventory, credential): - return \ - JobTemplate.objects.create( - job_type='check', - project=project, - inventory=inventory, - credential=credential, - name='check-job-template' - ) + jt = JobTemplate.objects.create( + job_type='check', + project=project, + inventory=inventory, + name='check-job-template' + ) + jt.credentials.add(credential) + return jt @pytest.fixture def deploy_jobtemplate(project, inventory, credential): - return \ - JobTemplate.objects.create( - job_type='run', - project=project, - inventory=inventory, - credential=credential, - name='deploy-job-template' - ) + jt = JobTemplate.objects.create( + job_type='run', + project=project, + inventory=inventory, + name='deploy-job-template' + ) + jt.credentials.add(credential) + return jt @pytest.fixture diff --git a/awx/main/tests/functional/models/test_activity_stream.py b/awx/main/tests/functional/models/test_activity_stream.py index fa5a4e7b52..7253999fb7 100644 --- a/awx/main/tests/functional/models/test_activity_stream.py +++ b/awx/main/tests/functional/models/test_activity_stream.py @@ -54,12 +54,11 @@ class TestImplicitRolesOmitted: assert qs[1].operation == 'delete' @pytest.mark.django_db - def test_activity_stream_create_JT(self, project, inventory, credential): + def test_activity_stream_create_JT(self, project, inventory): JobTemplate.objects.create( name='test-jt', project=project, inventory=inventory, - credential=credential ) qs = ActivityStream.objects.filter(job_template__isnull=False) assert qs.count() == 1 diff --git a/awx/main/tests/functional/models/test_job_options.py b/awx/main/tests/functional/models/test_job_options.py index c601413a5d..97b277c5b5 100644 --- a/awx/main/tests/functional/models/test_job_options.py +++ b/awx/main/tests/functional/models/test_job_options.py @@ -1,6 +1,5 @@ import pytest -from django.core.exceptions import ValidationError from awx.main.models import Credential @@ -12,23 +11,10 @@ def test_clean_credential_with_ssh_type(credentialtype_ssh, job_template): ) credential.save() - job_template.credential = credential + job_template.credentials.add(credential) job_template.full_clean() -@pytest.mark.django_db -def test_clean_credential_with_invalid_type_xfail(credentialtype_aws, job_template): - credential = Credential( - name='My Credential', - credential_type=credentialtype_aws - ) - credential.save() - - with pytest.raises(ValidationError): - job_template.credential = credential - job_template.full_clean() - - @pytest.mark.django_db def test_clean_credential_with_custom_types(credentialtype_aws, credentialtype_net, job_template): aws = Credential( @@ -42,6 +28,6 @@ def test_clean_credential_with_custom_types(credentialtype_aws, credentialtype_n ) net.save() - job_template.extra_credentials.add(aws) - job_template.extra_credentials.add(net) + job_template.credentials.add(aws) + job_template.credentials.add(net) job_template.full_clean() diff --git a/awx/main/tests/functional/models/test_unified_job.py b/awx/main/tests/functional/models/test_unified_job.py index 3c2823c0d1..13ff92877b 100644 --- a/awx/main/tests/functional/models/test_unified_job.py +++ b/awx/main/tests/functional/models/test_unified_job.py @@ -50,14 +50,15 @@ class TestCreateUnifiedJob: mocked.all.assert_called_with() ''' - Ensure that extra_credentials m2m field is copied to new relaunched job + Ensure that credentials m2m field is copied to new relaunched job ''' def test_job_relaunch_copy_vars(self, machine_credential, inventory, deploy_jobtemplate, post, mocker, net_credential): - job_with_links = Job.objects.create(name='existing-job', credential=machine_credential, inventory=inventory) + job_with_links = Job.objects.create(name='existing-job', inventory=inventory) job_with_links.job_template = deploy_jobtemplate job_with_links.limit = "my_server" - job_with_links.extra_credentials.add(net_credential) + job_with_links.credentials.add(machine_credential) + job_with_links.credentials.add(net_credential) with mocker.patch('awx.main.models.unified_jobs.UnifiedJobTemplate._get_unified_job_field_names', return_value=['inventory', 'credential', 'limit']): second_job = job_with_links.copy_unified_job() @@ -66,7 +67,7 @@ class TestCreateUnifiedJob: assert second_job.credential == job_with_links.credential assert second_job.inventory == job_with_links.inventory assert second_job.limit == 'my_server' - assert net_credential in second_job.extra_credentials.all() + assert net_credential in second_job.credentials.all() @pytest.mark.django_db diff --git a/awx/main/tests/functional/test_credential_migration.py b/awx/main/tests/functional/test_credential_migration.py deleted file mode 100644 index 3494545501..0000000000 --- a/awx/main/tests/functional/test_credential_migration.py +++ /dev/null @@ -1,331 +0,0 @@ -import mock -import pytest -from contextlib import contextmanager -from copy import deepcopy - -from django.apps import apps - -from awx.main.models import Credential, CredentialType -from awx.main.migrations._credentialtypes import migrate_to_v2_credentials -from awx.main.utils import decrypt_field -from awx.main.migrations._credentialtypes import _disassociate_non_insights_projects - -EXAMPLE_PRIVATE_KEY = '-----BEGIN PRIVATE KEY-----\nxyz==\n-----END PRIVATE KEY-----' - -# TODO: remove this set of tests when API v1 is removed - - -@contextmanager -def migrate(credential, kind, is_insights=False): - with mock.patch.object(Credential, 'kind', kind), \ - mock.patch.object(Credential, 'objects', mock.Mock( - get=lambda **kw: deepcopy(credential), - all=lambda: [credential], - )), mock.patch('awx.main.migrations._credentialtypes._is_insights_scm', return_value=is_insights): - class Apps(apps.__class__): - def get_model(self, app, model): - if model == 'Credential': - return Credential - return apps.get_model(app, model) - yield - migrate_to_v2_credentials(Apps(), None) - - -@pytest.mark.django_db -def test_ssh_migration(): - cred = Credential(name='My Credential') - with migrate(cred, 'ssh'): - cred.__dict__.update({ - 'username': 'bob', - 'password': 'secret', - 'ssh_key_data': EXAMPLE_PRIVATE_KEY, - 'ssh_key_unlock': 'keypass', - 'become_method': 'sudo', - 'become_username': 'superuser', - 'become_password': 'superpassword', - }) - - assert cred.credential_type.name == 'Machine' - assert cred.inputs['username'] == 'bob' - assert cred.inputs['password'].startswith('$encrypted$') - assert decrypt_field(cred, 'password') == 'secret' - assert cred.inputs['ssh_key_data'].startswith('$encrypted$') - assert decrypt_field(cred, 'ssh_key_data') == EXAMPLE_PRIVATE_KEY - assert cred.inputs['ssh_key_unlock'].startswith('$encrypted$') - assert decrypt_field(cred, 'ssh_key_unlock') == 'keypass' - assert cred.inputs['become_method'] == 'sudo' - assert cred.inputs['become_username'] == 'superuser' - assert cred.inputs['become_password'].startswith('$encrypted$') - assert decrypt_field(cred, 'become_password') == 'superpassword' - assert Credential.objects.count() == 1 - - -@pytest.mark.django_db -def test_scm_migration(): - cred = Credential(name='My Credential') - with migrate(cred, 'scm'): - cred.__dict__.update({ - 'username': 'bob', - 'password': 'secret', - 'ssh_key_data': EXAMPLE_PRIVATE_KEY, - 'ssh_key_unlock': 'keypass', - }) - - assert cred.credential_type.name == 'Source Control' - assert cred.inputs['username'] == 'bob' - assert cred.inputs['password'].startswith('$encrypted$') - assert decrypt_field(cred, 'password') == 'secret' - assert cred.inputs['ssh_key_data'].startswith('$encrypted$') - assert decrypt_field(cred, 'ssh_key_data') == EXAMPLE_PRIVATE_KEY - assert cred.inputs['ssh_key_unlock'].startswith('$encrypted$') - assert decrypt_field(cred, 'ssh_key_unlock') == 'keypass' - assert Credential.objects.count() == 1 - - -@pytest.mark.django_db -def test_vault_only_migration(): - cred = Credential(name='My Credential') - with migrate(cred, 'ssh'): - cred.__dict__.update({ - 'vault_password': 'vault', - }) - - assert cred.credential_type.name == 'Vault' - assert cred.inputs['vault_password'].startswith('$encrypted$') - assert decrypt_field(cred, 'vault_password') == 'vault' - assert Credential.objects.count() == 1 - - -@pytest.mark.django_db -def test_vault_with_ssh_migration(): - cred = Credential(name='My Credential') - with migrate(cred, 'ssh'): - cred.__dict__.update({ - 'vault_password': 'vault', - 'username': 'bob', - 'password': 'secret', - 'ssh_key_data': EXAMPLE_PRIVATE_KEY, - 'ssh_key_unlock': 'keypass', - 'become_method': 'sudo', - 'become_username': 'superuser', - 'become_password': 'superpassword', - }) - assert Credential.objects.count() == 2 - - assert Credential.objects.filter(credential_type__name='Vault').get() == cred - assert cred.inputs.keys() == ['vault_password'] - assert cred.inputs['vault_password'].startswith('$encrypted$') - assert decrypt_field(cred, 'vault_password') == 'vault' - - ssh_cred = Credential.objects.filter(credential_type__name='Machine').get() - assert sorted(ssh_cred.inputs.keys()) == sorted(CredentialType.from_v1_kind('ssh').defined_fields) - assert ssh_cred.credential_type.name == 'Machine' - assert ssh_cred.inputs['username'] == 'bob' - assert ssh_cred.inputs['password'].startswith('$encrypted$') - assert decrypt_field(ssh_cred, 'password') == 'secret' - assert ssh_cred.inputs['ssh_key_data'].startswith('$encrypted$') - assert decrypt_field(ssh_cred, 'ssh_key_data') == EXAMPLE_PRIVATE_KEY - assert ssh_cred.inputs['ssh_key_unlock'].startswith('$encrypted$') - assert decrypt_field(ssh_cred, 'ssh_key_unlock') == 'keypass' - assert ssh_cred.inputs['become_method'] == 'sudo' - assert ssh_cred.inputs['become_username'] == 'superuser' - assert ssh_cred.inputs['become_password'].startswith('$encrypted$') - assert decrypt_field(ssh_cred, 'become_password') == 'superpassword' - - -@pytest.mark.django_db -def test_net_migration(): - cred = Credential(name='My Credential') - with migrate(cred, 'net'): - cred.__dict__.update({ - 'username': 'bob', - 'password': 'secret', - 'ssh_key_data': EXAMPLE_PRIVATE_KEY, - 'ssh_key_unlock': 'keypass', - 'authorize_password': 'authorize-secret', - }) - - assert cred.credential_type.name == 'Network' - assert cred.inputs['username'] == 'bob' - assert cred.inputs['password'].startswith('$encrypted$') - assert decrypt_field(cred, 'password') == 'secret' - assert cred.inputs['ssh_key_data'].startswith('$encrypted$') - assert decrypt_field(cred, 'ssh_key_data') == EXAMPLE_PRIVATE_KEY - assert cred.inputs['ssh_key_unlock'].startswith('$encrypted$') - assert decrypt_field(cred, 'ssh_key_unlock') == 'keypass' - assert cred.inputs['authorize_password'].startswith('$encrypted$') - assert decrypt_field(cred, 'authorize_password') == 'authorize-secret' - assert Credential.objects.count() == 1 - - -@pytest.mark.django_db -def test_aws_migration(): - cred = Credential(name='My Credential') - with migrate(cred, 'aws'): - cred.__dict__.update({ - 'username': 'bob', - 'password': 'secret', - 'security_token': 'secret-token' - }) - - assert cred.credential_type.name == 'Amazon Web Services' - assert cred.inputs['username'] == 'bob' - assert cred.inputs['password'].startswith('$encrypted$') - assert decrypt_field(cred, 'password') == 'secret' - assert cred.inputs['security_token'].startswith('$encrypted$') - assert decrypt_field(cred, 'security_token') == 'secret-token' - assert Credential.objects.count() == 1 - - -@pytest.mark.django_db -def test_openstack_migration(): - cred = Credential(name='My Credential') - with migrate(cred, 'openstack'): - cred.__dict__.update({ - 'username': 'bob', - 'password': 'secret', - 'host': 'https://keystone.example.org/', - 'project': 'TENANT_ID', - }) - - assert cred.credential_type.name == 'OpenStack' - assert cred.inputs['username'] == 'bob' - assert cred.inputs['password'].startswith('$encrypted$') - assert decrypt_field(cred, 'password') == 'secret' - assert cred.inputs['host'] == 'https://keystone.example.org/' - assert cred.inputs['project'] == 'TENANT_ID' - assert Credential.objects.count() == 1 - - -@pytest.mark.django_db -def test_vmware_migration(): - cred = Credential(name='My Credential') - with migrate(cred, 'vmware'): - cred.__dict__.update({ - 'username': 'bob', - 'password': 'secret', - 'host': 'https://example.org/', - }) - - assert cred.credential_type.name == 'VMware vCenter' - assert cred.inputs['username'] == 'bob' - assert cred.inputs['password'].startswith('$encrypted$') - assert decrypt_field(cred, 'password') == 'secret' - assert cred.inputs['host'] == 'https://example.org/' - assert Credential.objects.count() == 1 - - -@pytest.mark.django_db -def test_satellite6_migration(): - cred = Credential(name='My Credential') - with migrate(cred, 'satellite6'): - cred.__dict__.update({ - 'username': 'bob', - 'password': 'secret', - 'host': 'https://example.org/', - }) - - assert cred.credential_type.name == 'Red Hat Satellite 6' - assert cred.inputs['username'] == 'bob' - assert cred.inputs['password'].startswith('$encrypted$') - assert decrypt_field(cred, 'password') == 'secret' - assert cred.inputs['host'] == 'https://example.org/' - assert Credential.objects.count() == 1 - - -@pytest.mark.django_db -def test_cloudforms_migration(): - cred = Credential(name='My Credential') - with migrate(cred, 'cloudforms'): - cred.__dict__.update({ - 'username': 'bob', - 'password': 'secret', - 'host': 'https://example.org/', - }) - - assert cred.credential_type.name == 'Red Hat CloudForms' - assert cred.inputs['username'] == 'bob' - assert cred.inputs['password'].startswith('$encrypted$') - assert decrypt_field(cred, 'password') == 'secret' - assert cred.inputs['host'] == 'https://example.org/' - assert Credential.objects.count() == 1 - - -@pytest.mark.django_db -def test_gce_migration(): - cred = Credential(name='My Credential') - with migrate(cred, 'gce'): - cred.__dict__.update({ - 'username': 'bob', - 'project': 'PROJECT-123', - 'ssh_key_data': EXAMPLE_PRIVATE_KEY - }) - - assert cred.credential_type.name == 'Google Compute Engine' - assert cred.inputs['username'] == 'bob' - assert cred.inputs['project'] == 'PROJECT-123' - assert cred.inputs['ssh_key_data'].startswith('$encrypted$') - assert decrypt_field(cred, 'ssh_key_data') == EXAMPLE_PRIVATE_KEY - assert Credential.objects.count() == 1 - - -@pytest.mark.django_db -def test_azure_rm_migration(): - cred = Credential(name='My Credential') - with migrate(cred, 'azure_rm'): - cred.__dict__.update({ - 'subscription': 'some-subscription', - 'username': 'bob', - 'password': 'some-password', - 'client': 'some-client', - 'secret': 'some-secret', - 'tenant': 'some-tenant', - }) - - assert cred.credential_type.name == 'Microsoft Azure Resource Manager' - assert cred.inputs['subscription'] == 'some-subscription' - assert cred.inputs['username'] == 'bob' - assert cred.inputs['password'].startswith('$encrypted$') - assert decrypt_field(cred, 'password') == 'some-password' - assert cred.inputs['client'] == 'some-client' - assert cred.inputs['secret'].startswith('$encrypted$') - assert decrypt_field(cred, 'secret') == 'some-secret' - assert cred.inputs['tenant'] == 'some-tenant' - assert Credential.objects.count() == 1 - - -@pytest.mark.django_db -def test_insights_migration(): - cred = Credential(name='My Credential') - - with migrate(cred, 'scm', is_insights=True): - cred.__dict__.update({ - 'username': 'bob', - 'password': 'some-password', - }) - - assert cred.credential_type.name == 'Insights' - assert cred.inputs['username'] == 'bob' - assert cred.inputs['password'].startswith('$encrypted$') - - -@pytest.mark.skip(reason="Need some more mocking here or something.") -@pytest.mark.django_db -def test_insights_project_migration(): - cred1 = apps.get_model('main', 'Credential').objects.create(name='My Credential') - cred2 = apps.get_model('main', 'Credential').objects.create(name='My Credential') - projA1 = apps.get_model('main', 'Project').objects.create(name='Insights Project A1', scm_type='insights', credential=cred1) - - projB1 = apps.get_model('main', 'Project').objects.create(name='Git Project B1', scm_type='git', credential=cred1) - projB2 = apps.get_model('main', 'Project').objects.create(name='Git Project B2', scm_type='git', credential=cred1) - - projC1 = apps.get_model('main', 'Project').objects.create(name='Git Project C1', scm_type='git', credential=cred2) - - _disassociate_non_insights_projects(apps, cred1) - _disassociate_non_insights_projects(apps, cred2) - - assert apps.get_model('main', 'Project').objects.get(pk=projA1).credential is None - assert apps.get_model('main', 'Project').objects.get(pk=projB1).credential is None - assert apps.get_model('main', 'Project').objects.get(pk=projB2).credential is None - assert apps.get_model('main', 'Project').objects.get(pk=projC1).credential == cred2 - diff --git a/awx/main/tests/functional/test_rbac_job.py b/awx/main/tests/functional/test_rbac_job.py index c2a87063d7..b9c1118f96 100644 --- a/awx/main/tests/functional/test_rbac_job.py +++ b/awx/main/tests/functional/test_rbac_job.py @@ -138,28 +138,29 @@ def test_project_org_admin_delete_allowed(normal_job, org_admin): class TestJobRelaunchAccess: def test_job_relaunch_normal_resource_access(self, user, inventory, machine_credential): - job_with_links = Job.objects.create(name='existing-job', credential=machine_credential, inventory=inventory) + job_with_links = Job.objects.create(name='existing-job', inventory=inventory) + job_with_links.credentials.add(machine_credential) inventory_user = user('user1', False) credential_user = user('user2', False) both_user = user('user3', False) # Confirm that a user with inventory & credential access can launch - job_with_links.credential.use_role.members.add(both_user) + machine_credential.use_role.members.add(both_user) job_with_links.inventory.use_role.members.add(both_user) assert both_user.can_access(Job, 'start', job_with_links, validate_license=False) # Confirm that a user with credential access alone cannot launch - job_with_links.credential.use_role.members.add(credential_user) + machine_credential.use_role.members.add(credential_user) assert not credential_user.can_access(Job, 'start', job_with_links, validate_license=False) # Confirm that a user with inventory access alone cannot launch job_with_links.inventory.use_role.members.add(inventory_user) assert not inventory_user.can_access(Job, 'start', job_with_links, validate_license=False) - def test_job_relaunch_extra_credential_access( + def test_job_relaunch_credential_access( self, inventory, project, credential, net_credential): jt = JobTemplate.objects.create(name='testjt', inventory=inventory, project=project) - jt.extra_credentials.add(credential) + jt.credentials.add(credential) job = jt.create_unified_job() # Job is unchanged from JT, user has ability to launch @@ -168,11 +169,11 @@ class TestJobRelaunchAccess: assert jt_user in job.job_template.execute_role assert jt_user.can_access(Job, 'start', job, validate_license=False) - # Job has prompted extra_credential, launch denied w/ message - job.extra_credentials.add(net_credential) + # Job has prompted net credential, launch denied w/ message + job.credentials.add(net_credential) assert not jt_user.can_access(Job, 'start', job, validate_license=False) - def test_prompted_extra_credential_relaunch_denied( + def test_prompted_credential_relaunch_denied( self, inventory, project, net_credential, rando): jt = JobTemplate.objects.create( name='testjt', inventory=inventory, project=project, @@ -180,11 +181,11 @@ class TestJobRelaunchAccess: job = jt.create_unified_job() jt.execute_role.members.add(rando) - # Job has prompted extra_credential, rando lacks permission to use it - job.extra_credentials.add(net_credential) + # Job has prompted net credential, rando lacks permission to use it + job.credentials.add(net_credential) assert not rando.can_access(Job, 'start', job, validate_license=False) - def test_prompted_extra_credential_relaunch_allowed( + def test_prompted_credential_relaunch_allowed( self, inventory, project, net_credential, rando): jt = JobTemplate.objects.create( name='testjt', inventory=inventory, project=project, @@ -192,23 +193,24 @@ class TestJobRelaunchAccess: job = jt.create_unified_job() jt.execute_role.members.add(rando) - # Job has prompted extra_credential, but rando can use it + # Job has prompted net credential, but rando can use it net_credential.use_role.members.add(rando) - job.extra_credentials.add(net_credential) + job.credentials.add(net_credential) assert rando.can_access(Job, 'start', job, validate_license=False) - def test_extra_credential_relaunch_recreation_permission( + def test_credential_relaunch_recreation_permission( self, inventory, project, net_credential, credential, rando): jt = JobTemplate.objects.create( name='testjt', inventory=inventory, project=project, - credential=credential, ask_credential_on_launch=True) + ask_credential_on_launch=True) job = jt.create_unified_job() project.admin_role.members.add(rando) inventory.admin_role.members.add(rando) credential.admin_role.members.add(rando) - # Relaunch blocked by the extra credential - job.extra_credentials.add(net_credential) + # Relaunch blocked by the net credential + job.credentials.add(credential) + job.credentials.add(net_credential) assert not rando.can_access(Job, 'start', job, validate_license=False) diff --git a/awx/main/tests/functional/test_rbac_job_start.py b/awx/main/tests/functional/test_rbac_job_start.py index 7c84240542..27dec9d282 100644 --- a/awx/main/tests/functional/test_rbac_job_start.py +++ b/awx/main/tests/functional/test_rbac_job_start.py @@ -47,16 +47,18 @@ def test_inventory_use_access(inventory, user): class TestJobRelaunchAccess: @pytest.fixture def job_no_prompts(self, machine_credential, inventory): - jt = JobTemplate.objects.create(name='test-job_template', credential=machine_credential, inventory=inventory) + jt = JobTemplate.objects.create(name='test-job_template', inventory=inventory) + jt.credentials.add(machine_credential) return jt.create_unified_job() @pytest.fixture def job_with_prompts(self, machine_credential, inventory, organization, credentialtype_ssh): jt = JobTemplate.objects.create( - name='test-job-template-prompts', credential=machine_credential, inventory=inventory, + name='test-job-template-prompts', inventory=inventory, ask_tags_on_launch=True, ask_variables_on_launch=True, ask_skip_tags_on_launch=True, ask_limit_on_launch=True, ask_job_type_on_launch=True, ask_verbosity_on_launch=True, ask_inventory_on_launch=True, ask_credential_on_launch=True) + jt.credentials.add(machine_credential) new_cred = Credential.objects.create( name='new-cred', credential_type=credentialtype_ssh, @@ -65,8 +67,9 @@ class TestJobRelaunchAccess: 'password': 'pas4word' } ) + new_cred.save() new_inv = Inventory.objects.create(name='new-inv', organization=organization) - return jt.create_unified_job(credential=new_cred, inventory=new_inv) + return jt.create_unified_job(credentials=[new_cred.pk], inventory=new_inv) def test_normal_relaunch_via_job_template(self, job_no_prompts, rando): "Has JT execute_role, job unchanged relative to JT" @@ -81,7 +84,8 @@ class TestJobRelaunchAccess: def test_can_relaunch_with_prompted_fields_access(self, job_with_prompts, rando): "Has use_role on the prompted inventory & credential - allow relaunch" job_with_prompts.job_template.execute_role.members.add(rando) - job_with_prompts.credential.use_role.members.add(rando) + for cred in job_with_prompts.credentials.all(): + cred.use_role.members.add(rando) job_with_prompts.inventory.use_role.members.add(rando) assert rando.can_access(Job, 'start', job_with_prompts) diff --git a/awx/main/tests/functional/test_rbac_job_templates.py b/awx/main/tests/functional/test_rbac_job_templates.py index cad2ad816b..308587cd02 100644 --- a/awx/main/tests/functional/test_rbac_job_templates.py +++ b/awx/main/tests/functional/test_rbac_job_templates.py @@ -21,11 +21,11 @@ def jt_linked(job_template_factory, credential, net_credential, vault_credential 'testJT', organization='org1', project='proj1', inventory='inventory1', credential='cred1') jt = objects.job_template - jt.vault_credential = vault_credential + jt.credentials.add(vault_credential) jt.save() # Add AWS cloud credential and network credential - jt.extra_credentials.add(credential) - jt.extra_credentials.add(net_credential) + jt.credentials.add(credential) + jt.credentials.add(net_credential) return jt @@ -47,15 +47,15 @@ def test_job_template_access_read_level(jt_linked, rando): access = JobTemplateAccess(rando) jt_linked.project.read_role.members.add(rando) jt_linked.inventory.read_role.members.add(rando) - jt_linked.credential.read_role.members.add(rando) + jt_linked.get_deprecated_credential('ssh').read_role.members.add(rando) proj_pk = jt_linked.project.pk assert not access.can_add(dict(inventory=jt_linked.inventory.pk, project=proj_pk)) - assert not access.can_add(dict(credential=jt_linked.credential.pk, project=proj_pk)) - assert not access.can_add(dict(vault_credential=jt_linked.vault_credential.pk, project=proj_pk)) + assert not access.can_add(dict(credential=jt_linked.credential, project=proj_pk)) + assert not access.can_add(dict(vault_credential=jt_linked.vault_credential, project=proj_pk)) - for cred in jt_linked.extra_credentials.all(): - assert not access.can_unattach(jt_linked, cred, 'extra_credentials', {}) + for cred in jt_linked.credentials.all(): + assert not access.can_unattach(jt_linked, cred, 'credentials', {}) @pytest.mark.django_db @@ -64,16 +64,16 @@ def test_job_template_access_use_level(jt_linked, rando): access = JobTemplateAccess(rando) jt_linked.project.use_role.members.add(rando) jt_linked.inventory.use_role.members.add(rando) - jt_linked.credential.use_role.members.add(rando) - jt_linked.vault_credential.use_role.members.add(rando) + jt_linked.get_deprecated_credential('ssh').use_role.members.add(rando) + jt_linked.get_deprecated_credential('vault').use_role.members.add(rando) proj_pk = jt_linked.project.pk assert access.can_add(dict(inventory=jt_linked.inventory.pk, project=proj_pk)) - assert access.can_add(dict(credential=jt_linked.credential.pk, project=proj_pk)) - assert access.can_add(dict(vault_credential=jt_linked.vault_credential.pk, project=proj_pk)) + assert access.can_add(dict(credential=jt_linked.credential, project=proj_pk)) + assert access.can_add(dict(vault_credential=jt_linked.vault_credential, project=proj_pk)) - for cred in jt_linked.extra_credentials.all(): - assert not access.can_unattach(jt_linked, cred, 'extra_credentials', {}) + for cred in jt_linked.credentials.all(): + assert not access.can_unattach(jt_linked, cred, 'credentials', {}) @pytest.mark.django_db @@ -83,14 +83,14 @@ def test_job_template_access_org_admin(jt_linked, rando): jt_linked.inventory.organization.admin_role.members.add(rando) # Assign organization permission in the same way the create view does organization = jt_linked.inventory.organization - jt_linked.credential.admin_role.parents.add(organization.admin_role) + jt_linked.get_deprecated_credential('ssh').admin_role.parents.add(organization.admin_role) proj_pk = jt_linked.project.pk assert access.can_add(dict(inventory=jt_linked.inventory.pk, project=proj_pk)) - assert access.can_add(dict(credential=jt_linked.credential.pk, project=proj_pk)) + assert access.can_add(dict(credential=jt_linked.credential, project=proj_pk)) - for cred in jt_linked.extra_credentials.all(): - assert access.can_unattach(jt_linked, cred, 'extra_credentials', {}) + for cred in jt_linked.credentials.all(): + assert access.can_unattach(jt_linked, cred, 'credentials', {}) assert access.can_read(jt_linked) assert access.can_delete(jt_linked) @@ -104,9 +104,9 @@ def test_job_template_extra_credentials_prompts_access( project = project, playbook = 'helloworld.yml', inventory = inventory, - credential = machine_credential, ask_credential_on_launch = True ) + jt.credentials.add(machine_credential) jt.execute_role.members.add(rando) r = post( reverse('api:job_template_launch', kwargs={'version': 'v2', 'pk': jt.id}), @@ -123,24 +123,24 @@ class TestJobTemplateCredentials: credential.read_role.members.add(rando) # without permission to credential, user can not attach it assert not JobTemplateAccess(rando).can_attach( - job_template, credential, 'extra_credentials', {}) + job_template, credential, 'credentials', {}) def test_job_template_can_add_extra_credentials(self, job_template, credential, rando): job_template.admin_role.members.add(rando) credential.use_role.members.add(rando) # user has permission to apply credential assert JobTemplateAccess(rando).can_attach( - job_template, credential, 'extra_credentials', {}) + job_template, credential, 'credentials', {}) def test_job_template_vault_cred_check(self, job_template, vault_credential, rando): job_template.admin_role.members.add(rando) # not allowed to use the vault cred assert not JobTemplateAccess(rando).can_change( - job_template, {'vault_credential': vault_credential}) + job_template, {'credentials': [vault_credential.pk]}) def test_new_jt_with_vault(self, vault_credential, project, rando): project.admin_role.members.add(rando) - assert not JobTemplateAccess(rando).can_add({'vault_credential': vault_credential, 'project': project.pk}) + assert not JobTemplateAccess(rando).can_add({'credentials': [vault_credential.pk], 'project': project.pk}) @pytest.mark.django_db diff --git a/awx/main/tests/unit/api/serializers/test_job_template_serializers.py b/awx/main/tests/unit/api/serializers/test_job_template_serializers.py index ab69aeb375..9ec8d5918b 100644 --- a/awx/main/tests/unit/api/serializers/test_job_template_serializers.py +++ b/awx/main/tests/unit/api/serializers/test_job_template_serializers.py @@ -114,7 +114,8 @@ class TestJobTemplateSerializerGetSummaryFields(): with mocker.patch("awx.api.serializers.role_summary_fields_generator", return_value='Can eat pie'): with mocker.patch("awx.main.access.JobTemplateAccess.can_change", return_value='foobar'): with mocker.patch("awx.main.access.JobTemplateAccess.can_add", return_value='foo'): - response = serializer.get_summary_fields(jt_obj) + with mock.patch.object(jt_obj.__class__, 'get_deprecated_credential', return_value=None): + response = serializer.get_summary_fields(jt_obj) assert response['user_capabilities']['copy'] == 'foo' assert response['user_capabilities']['edit'] == 'foobar' diff --git a/awx/main/tests/unit/api/test_generics.py b/awx/main/tests/unit/api/test_generics.py index b6fd51f929..5ec2f8980c 100644 --- a/awx/main/tests/unit/api/test_generics.py +++ b/awx/main/tests/unit/api/test_generics.py @@ -272,4 +272,4 @@ class TestResourceAccessList: def test_related_search_reverse_FK_field(): view = ListAPIView() view.model = Credential - assert 'jobtemplates__search' in view.related_search_fields + assert 'unifiedjobtemplates__search' in view.related_search_fields diff --git a/awx/main/tests/unit/models/test_job_template_unit.py b/awx/main/tests/unit/models/test_job_template_unit.py index 2a4d48e71d..2a157a4ad8 100644 --- a/awx/main/tests/unit/models/test_job_template_unit.py +++ b/awx/main/tests/unit/models/test_job_template_unit.py @@ -1,13 +1,14 @@ import pytest import json +import mock + def test_missing_project_error(job_template_factory): objects = job_template_factory( 'missing-project-jt', organization='org1', inventory='inventory1', - credential='cred1', persisted=False) obj = objects.job_template assert 'project' in obj.resources_needed_to_start @@ -15,27 +16,24 @@ def test_missing_project_error(job_template_factory): assert 'project' in validation_errors -def test_inventory_credential_need_to_start(job_template_factory): +def test_inventory_need_to_start(job_template_factory): objects = job_template_factory( 'job-template-few-resources', project='project1', persisted=False) obj = objects.job_template assert 'inventory' in obj.resources_needed_to_start - assert 'credential' in obj.resources_needed_to_start -def test_inventory_credential_contradictions(job_template_factory): +def test_inventory_contradictions(job_template_factory): objects = job_template_factory( 'job-template-paradox', project='project1', persisted=False) obj = objects.job_template obj.ask_inventory_on_launch = False - obj.ask_credential_on_launch = False validation_errors, resources_needed_to_start = obj.resource_validation_data() assert 'inventory' in validation_errors - assert 'credential' in validation_errors def test_survey_answers_as_string(job_template_factory): @@ -142,4 +140,5 @@ def test_job_template_can_start_with_callback_extra_vars_provided(job_template_f ) obj = objects.job_template obj.ask_variables_on_launch = True - assert obj.can_start_without_user_input(callback_extra_vars='{"foo": "bar"}') is True + with mock.patch.object(obj.__class__, 'passwords_needed_to_start', []): + assert obj.can_start_without_user_input(callback_extra_vars='{"foo": "bar"}') is True diff --git a/awx/main/tests/unit/models/test_workflow_unit.py b/awx/main/tests/unit/models/test_workflow_unit.py index 129f18d78c..7b37fbc53b 100644 --- a/awx/main/tests/unit/models/test_workflow_unit.py +++ b/awx/main/tests/unit/models/test_workflow_unit.py @@ -194,18 +194,18 @@ class TestWorkflowJobNodeJobKWARGS: extra_vars={'a': 84}, **self.kwargs_base) def test_char_prompts_and_res_node_prompts(self, job_node_with_prompts): + # TBD: properly handle multicred credential assignment expect_kwargs = dict( inventory=job_node_with_prompts.inventory.pk, - credential=job_node_with_prompts.credential.pk, **example_prompts) expect_kwargs.update(self.kwargs_base) assert job_node_with_prompts.get_job_kwargs() == expect_kwargs def test_reject_some_node_prompts(self, job_node_with_prompts): + # TBD: properly handle multicred credential assignment job_node_with_prompts.unified_job_template.ask_inventory_on_launch = False job_node_with_prompts.unified_job_template.ask_job_type_on_launch = False expect_kwargs = dict(inventory=job_node_with_prompts.inventory.pk, - credential=job_node_with_prompts.credential.pk, **example_prompts) expect_kwargs.update(self.kwargs_base) expect_kwargs.pop('inventory') @@ -239,6 +239,5 @@ class TestWorkflowWarnings: job_node_with_prompts.unified_job_template.ask_job_type_on_launch = False assert 'ignored' in job_node_with_prompts.get_prompts_warnings() assert 'job_type' in job_node_with_prompts.get_prompts_warnings()['ignored'] - assert 'credential' in job_node_with_prompts.get_prompts_warnings()['ignored'] - assert len(job_node_with_prompts.get_prompts_warnings()['ignored']) == 2 + assert len(job_node_with_prompts.get_prompts_warnings()['ignored']) == 1 diff --git a/awx/main/tests/unit/test_access.py b/awx/main/tests/unit/test_access.py index 961a1d4039..0692ba0490 100644 --- a/awx/main/tests/unit/test_access.py +++ b/awx/main/tests/unit/test_access.py @@ -128,9 +128,6 @@ def job_template_with_ids(job_template_factory): cloud_type = CredentialType(kind='aws') cloud_cred = Credential(id=3, pk=3, name='testcloudcred', credential_type=cloud_type) - vault_type = CredentialType(kind='vault') - vault_cred = Credential(id=4, pk=4, name='testnetcred', credential_type=vault_type) - inv = Inventory(id=11, pk=11, name='testinv') proj = Project(id=14, pk=14, name='testproj') @@ -138,7 +135,6 @@ def job_template_with_ids(job_template_factory): 'testJT', project=proj, inventory=inv, credential=credential, cloud_credential=cloud_cred, network_credential=net_cred, persisted=False) - jt_objects.job_template.vault_credential = vault_cred return jt_objects.job_template @@ -184,9 +180,7 @@ def test_change_jt_sensitive_data(job_template_with_ids, mocker, user_unit): mock_add.assert_called_once_with({ 'inventory': data['inventory'], - 'project': job_template_with_ids.project.id, - 'credential': job_template_with_ids.credential.id, - 'vault_credential': job_template_with_ids.vault_credential.id + 'project': job_template_with_ids.project.id }) diff --git a/awx/main/tests/unit/test_tasks.py b/awx/main/tests/unit/test_tasks.py index 2e76b69065..3dbc8399fb 100644 --- a/awx/main/tests/unit/test_tasks.py +++ b/awx/main/tests/unit/test_tasks.py @@ -243,12 +243,13 @@ class TestJobExecution: verbosity=3 ) - # mock the job.extra_credentials M2M relation so we can avoid DB access - job._extra_credentials = [] - patch = mock.patch.object(Job, 'extra_credentials', mock.Mock( - all=lambda: job._extra_credentials, - add=job._extra_credentials.append, - spec_set=['all', 'add'] + # mock the job.credentials M2M relation so we can avoid DB access + job._credentials = [] + patch = mock.patch.object(UnifiedJob, 'credentials', mock.Mock( + all=lambda: job._credentials, + add=job._credentials.append, + filter=mock.Mock(return_value=job._credentials), + spec_set=['all', 'add', 'filter'] )) self.patches.append(patch) patch.start() @@ -328,7 +329,7 @@ class TestIsolatedExecution(TestJobExecution): } ) credential.inputs['password'] = encrypt_field(credential, 'password') - self.instance.credential = credential + self.instance.credentials.add(credential) private_data = tempfile.mkdtemp(prefix='awx_') self.task.build_private_data_dir = mock.Mock(return_value=private_data) @@ -370,7 +371,7 @@ class TestIsolatedExecution(TestJobExecution): credential_type=ssh, inputs = {'username': 'bob',} ) - self.instance.credential = credential + self.instance.credentials.add(credential) private_data = tempfile.mkdtemp(prefix='awx_') self.task.build_private_data_dir = mock.Mock(return_value=private_data) @@ -414,7 +415,7 @@ class TestJobCredentials(TestJobExecution): inputs = {'username': 'bob', field: 'secret'} ) credential.inputs[field] = encrypt_field(credential, field) - self.instance.credential = credential + self.instance.credentials.add(credential) self.task.run(self.pk) assert self.run_pexpect.call_count == 1 @@ -434,7 +435,7 @@ class TestJobCredentials(TestJobExecution): inputs={'vault_password': 'vault-me'} ) credential.inputs['vault_password'] = encrypt_field(credential, 'vault_password') - self.instance.vault_credential = credential + self.instance.credentials.add(credential) self.task.run(self.pk) assert self.run_pexpect.call_count == 1 @@ -457,7 +458,7 @@ class TestJobCredentials(TestJobExecution): } ) credential.inputs['ssh_key_data'] = encrypt_field(credential, 'ssh_key_data') - self.instance.credential = credential + self.instance.credentials.add(credential) def run_pexpect_side_effect(private_data, *args, **kwargs): args, cwd, env, stdout = args @@ -485,7 +486,7 @@ class TestJobCredentials(TestJobExecution): inputs = {'username': 'bob', 'password': 'secret'} ) credential.inputs['password'] = encrypt_field(credential, 'password') - self.instance.extra_credentials.add(credential) + self.instance.credentials.add(credential) self.task.run(self.pk) assert self.run_pexpect.call_count == 1 @@ -505,7 +506,7 @@ class TestJobCredentials(TestJobExecution): ) for key in ('password', 'security_token'): credential.inputs[key] = encrypt_field(credential, key) - self.instance.extra_credentials.add(credential) + self.instance.credentials.add(credential) self.task.run(self.pk) assert self.run_pexpect.call_count == 1 @@ -528,7 +529,7 @@ class TestJobCredentials(TestJobExecution): } ) credential.inputs['ssh_key_data'] = encrypt_field(credential, 'ssh_key_data') - self.instance.extra_credentials.add(credential) + self.instance.credentials.add(credential) def run_pexpect_side_effect(*args, **kwargs): args, cwd, env, stdout = args @@ -554,7 +555,7 @@ class TestJobCredentials(TestJobExecution): } ) credential.inputs['secret'] = encrypt_field(credential, 'secret') - self.instance.extra_credentials.add(credential) + self.instance.credentials.add(credential) self.task.run(self.pk) @@ -579,7 +580,7 @@ class TestJobCredentials(TestJobExecution): } ) credential.inputs['password'] = encrypt_field(credential, 'password') - self.instance.extra_credentials.add(credential) + self.instance.credentials.add(credential) self.task.run(self.pk) @@ -599,7 +600,7 @@ class TestJobCredentials(TestJobExecution): inputs = {'username': 'bob', 'password': 'secret', 'host': 'https://example.org'} ) credential.inputs['password'] = encrypt_field(credential, 'password') - self.instance.extra_credentials.add(credential) + self.instance.credentials.add(credential) self.task.run(self.pk) assert self.run_pexpect.call_count == 1 @@ -623,7 +624,7 @@ class TestJobCredentials(TestJobExecution): } ) credential.inputs['password'] = encrypt_field(credential, 'password') - self.instance.extra_credentials.add(credential) + self.instance.credentials.add(credential) def run_pexpect_side_effect(*args, **kwargs): args, cwd, env, stdout = args @@ -658,7 +659,7 @@ class TestJobCredentials(TestJobExecution): ) for field in ('password', 'ssh_key_data', 'authorize_password'): credential.inputs[field] = encrypt_field(credential, field) - self.instance.extra_credentials.add(credential) + self.instance.credentials.add(credential) def run_pexpect_side_effect(*args, **kwargs): args, cwd, env, stdout = args @@ -695,7 +696,7 @@ class TestJobCredentials(TestJobExecution): credential_type=some_cloud, inputs = {'api_token': 'ABC123'} ) - self.instance.extra_credentials.add(credential) + self.instance.credentials.add(credential) with pytest.raises(Exception): self.task.run(self.pk) @@ -722,7 +723,7 @@ class TestJobCredentials(TestJobExecution): credential_type=some_cloud, inputs = {'api_token': 'ABC123'} ) - self.instance.extra_credentials.add(credential) + self.instance.credentials.add(credential) self.task.run(self.pk) assert self.run_pexpect.call_count == 1 @@ -754,7 +755,7 @@ class TestJobCredentials(TestJobExecution): credential_type=some_cloud, inputs={'turbo_button': True} ) - self.instance.extra_credentials.add(credential) + self.instance.credentials.add(credential) self.task.run(self.pk) assert self.run_pexpect.call_count == 1 @@ -785,7 +786,7 @@ class TestJobCredentials(TestJobExecution): credential_type=some_cloud, inputs = {'api_token': 'ABC123'} ) - self.instance.extra_credentials.add(credential) + self.instance.credentials.add(credential) self.task.run(self.pk) assert self.run_pexpect.call_count == 1 @@ -819,7 +820,7 @@ class TestJobCredentials(TestJobExecution): inputs = {'password': 'SUPER-SECRET-123'} ) credential.inputs['password'] = encrypt_field(credential, 'password') - self.instance.extra_credentials.add(credential) + self.instance.credentials.add(credential) self.task.run(self.pk) assert self.run_pexpect.call_count == 1 @@ -852,7 +853,7 @@ class TestJobCredentials(TestJobExecution): credential_type=some_cloud, inputs = {'api_token': 'ABC123'} ) - self.instance.extra_credentials.add(credential) + self.instance.credentials.add(credential) self.task.run(self.pk) assert self.run_pexpect.call_count == 1 @@ -884,7 +885,7 @@ class TestJobCredentials(TestJobExecution): credential_type=some_cloud, inputs={'turbo_button': True} ) - self.instance.extra_credentials.add(credential) + self.instance.credentials.add(credential) self.task.run(self.pk) assert self.run_pexpect.call_count == 1 @@ -915,7 +916,7 @@ class TestJobCredentials(TestJobExecution): credential_type=some_cloud, inputs={'turbo_button': True} ) - self.instance.extra_credentials.add(credential) + self.instance.credentials.add(credential) self.task.run(self.pk) assert self.run_pexpect.call_count == 1 @@ -951,7 +952,7 @@ class TestJobCredentials(TestJobExecution): inputs = {'password': 'SUPER-SECRET-123'} ) credential.inputs['password'] = encrypt_field(credential, 'password') - self.instance.extra_credentials.add(credential) + self.instance.credentials.add(credential) self.task.run(self.pk) assert self.run_pexpect.call_count == 1 @@ -987,7 +988,7 @@ class TestJobCredentials(TestJobExecution): credential_type=some_cloud, inputs = {'api_token': 'ABC123'} ) - self.instance.extra_credentials.add(credential) + self.instance.credentials.add(credential) self.task.run(self.pk) def run_pexpect_side_effect(*args, **kwargs): @@ -1010,7 +1011,7 @@ class TestJobCredentials(TestJobExecution): } ) gce_credential.inputs['ssh_key_data'] = encrypt_field(gce_credential, 'ssh_key_data') - self.instance.extra_credentials.add(gce_credential) + self.instance.credentials.add(gce_credential) azure_rm = CredentialType.defaults['azure_rm']() azure_rm_credential = Credential( @@ -1023,7 +1024,7 @@ class TestJobCredentials(TestJobExecution): } ) azure_rm_credential.inputs['secret'] = encrypt_field(azure_rm_credential, 'secret') - self.instance.extra_credentials.add(azure_rm_credential) + self.instance.credentials.add(azure_rm_credential) def run_pexpect_side_effect(*args, **kwargs): args, cwd, env, stdout = args diff --git a/docs/multi_credential_assignment.md b/docs/multi_credential_assignment.md new file mode 100644 index 0000000000..62b1ad73d7 --- /dev/null +++ b/docs/multi_credential_assignment.md @@ -0,0 +1,163 @@ +Multi-Credential Assignment +=========================== + +awx has added support for assigning zero or more credentials to +a JobTemplate via a singular, unified interface. + +Background +---------- + +Prior to awx (Tower 3.2), Job Templates had a certain set of requirements +surrounding their relation to Credentials: + +* All Job Templates (and Jobs) were required to have exactly *one* Machine/SSH + or Vault credential (or one of both). +* All Job Templates (and Jobs) could have zero or more "extra" Credentials. +* These extra Credentials represented "Cloud" and "Network" credentials that +* could be used to provide authentication to external services via environment +* variables (e.g., AWS_ACCESS_KEY_ID). + +This model required a variety of disjoint interfaces for specifying Credentials +on a JobTemplate. For example, to modify assignment of Machine/SSH and Vault +credentials, you would change the Credential key itself: + +`PATCH /api/v2/job_templates/N/ {'credential': X, 'vault_credential': Y}` + +Modifying `extra_credentials` was accomplished on a separate API endpoint +via association/disassociation actions: + +``` +POST /api/v2/job_templates/N/extra_credentials {'associate': true, 'id': Z} +POST /api/v2/job_templates/N/extra_credentials {'disassociate': true, 'id': Z} +``` + +This model lacked the ability associate multiple Vault credentials with +a playbook run, a use case supported by Ansible core from Ansible 2.4 onwards. + +This model also was a stumbling block for certain playbook execution workflows. +For example, some users wanted to run playbooks with `connection:local` that +only interacted with some cloud service via a cloud Credential. In this +scenario, users often generated a "dummy" Machine/SSH Credential to attach to +the Job Template simply to satisfy the requirement on the model. + +Important Changes +----------------- + +JobTemplates now have a single interface for Credential assignment: + +`GET /api/v2/job_templates/N/credentials/` + +Users can associate and disassociate credentials using `POST` requests to this +interface, similar to the behavior in the now-deprecated `extra_credentials` +endpoint: + +``` +POST /api/v2/job_templates/N/credentials/ {'associate': true, 'id': X'} +POST /api/v2/job_templates/N/credentials/ {'disassociate': true, 'id': Y'} +``` + +Under this model, a JobTemplate is considered valid even when it has _zero_ +Credentials assigned to it. + +Launch Time Considerations +-------------------------- + +Prior to this change, JobTemplates had a configurable attribute, +`ask_credential_on_launch`. This value was used at launch time to determine +which missing credential values were necessary for launch - this was primarily +used as a mechanism for users to specify an SSH (or Vault) credential to satisfy +the minimum Credential requirement. + +Under the new unified Credential list model, this attribute still exists, but it +is no longer bound to a notion of "requiring" a Credential. Now when +`ask_credential_on_launch` is `True`, it signifies that users may (if they +wish) specify a list of credentials at launch time to override those defined on +the JobTemplate: + +`POST /api/v2/job_templates/N/launch/ {'credentials': [A, B, C]}` + +If `ask_credential_on_launch` is `False`, it signifies that custom `credentials` +provided in the payload to `POST /api/v2/job_templates/N/launch/` will be +ignored. + +Under this model, the only purpose for `ask_credential_on_launch` is to signal +that API clients should prompt the user for (optional) changes at launch time. + +Backwards Compatability Concerns +-------------------------------- +A variety of API clients rely on now-deprecated mechanisms for Credential +retrieval and assignment, and those are still supported in a backwards +compatible way under this new API change. Requests to update +`JobTemplate.credential` and `JobTemplate.vault_credential` will still behave +as they did before: + +`PATCH /api/v2/job_templates/N/ {'credential': X, 'vault_credential': Y}` + +Under this model, when a JobTemplate with multiple vault Credentials is updated +in this way, the new underlying list will _only_ contain the single Vault +Credential specified in the deprecated request. + +`GET` requests to `/api/v2/job_templates/N/` and `/api/v2/jobs/N/` +have traditionally included a variety of metadata in the response via +`related_fields`: + +``` +{ + "related": { + ... + "credential": "/api/v2/credentials/1/", + "vault_credential": "/api/v2/credentials/3/", + "extra_credentials": "/api/v2/job_templates/5/extra_credentials/", + } +} +``` + +...and `summary_fields`: + +``` +{ + "summary_fields": { + "credential": { + "description": "", + "credential_type_id": 1, + "id": 1, + "kind": "ssh", + "name": "Demo Credential" + }, + "vault_credential": { + "description": "", + "credential_type_id": 3, + "id": 3, + "kind": "vault", + "name": "some-vault" + }, + "extra_credentials": [ + { + "description": "", + "credential_type_id": 5, + "id": 2, + "kind": "aws", + "name": "some-aws" + }, + { + "description": "", + "credential_type_id": 10, + "id": 4, + "kind": "gce", + "name": "some-gce" + } + ], + } +} +``` + +These metadata will continue to exist and function in a backwards-compatible way. + +The `/api/v2/job_templates/N/extra_credentials` endpoint has been deprecated, but +will also continue to exist and function in the same manner for multiple releases. + +The `/api/v2/job_templates/N/launch/` endpoint also provides +deprecated,backwards compatible support for specifying credentials at launch time +via the `credential`, `vault_credential`, and `extra_credentials` fields: + +`POST /api/v2/job_templates/N/launch/ {'credential': A, 'vault_credential': B, 'extra_credentials': [C, D]}` diff --git a/tools/data_generators/rbac_dummy_data_generator.py b/tools/data_generators/rbac_dummy_data_generator.py index c752dee264..5467b1cbb9 100755 --- a/tools/data_generators/rbac_dummy_data_generator.py +++ b/tools/data_generators/rbac_dummy_data_generator.py @@ -490,16 +490,16 @@ def make_the_data(): defaults=dict( inventory=inventory, project=project, - credential=next(credential_gen), created_by=next(creator_gen), modified_by=next(modifier_gen), playbook="debug.yml", **extra_kwargs) ) + job_template.credentials.add(next(credential_gen)) if ids['job_template'] % 7 == 0: - job_template.extra_credentials.add(next(credential_gen)) + job_template.credentials.add(next(credential_gen)) if ids['job_template'] % 5 == 0: # formerly cloud credential - job_template.extra_credentials.add(next(credential_gen)) + job_template.credentials.add(next(credential_gen)) job_template._is_new = _ job_templates.append(job_template) inv_idx += 1 @@ -649,10 +649,9 @@ def make_the_data(): job_template=job_template, status=job_stat, name="%s-%d" % (job_template.name, job_i), project=job_template.project, inventory=job_template.inventory, - credential=job_template.credential, ) - for ec in job_template.extra_credentials.all(): - job.extra_credentials.add(ec) + for ec in job_template.credentials.all(): + job.credentials.add(ec) job._is_new = _ jobs.append(job) job_i += 1