From 9c4d89f512093cf89ba9058c55f97b913b437149 Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Wed, 14 Feb 2018 12:23:53 -0500 Subject: [PATCH 1/2] use the m2m field for inventory source creds --- awx/api/serializers.py | 96 +++++- awx/api/urls/inventory_source.py | 2 + awx/api/urls/inventory_update.py | 2 + awx/api/views.py | 33 ++ awx/main/access.py | 20 +- .../0023_v330_inventory_multicred.py | 32 ++ awx/main/migrations/_multi_cred.py | 22 ++ awx/main/models/inventory.py | 60 ++-- awx/main/tasks.py | 19 +- .../tests/functional/api/test_credential.py | 2 +- awx/main/tests/unit/test_tasks.py | 290 ++++++++++-------- 11 files changed, 409 insertions(+), 169 deletions(-) create mode 100644 awx/main/migrations/0023_v330_inventory_multicred.py diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 97c369c051..0eadc1cb8a 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -1540,6 +1540,9 @@ class CustomInventoryScriptSerializer(BaseSerializer): class InventorySourceOptionsSerializer(BaseSerializer): + credential = models.PositiveIntegerField( + blank=True, null=True, default=None, + help_text='This resource has been deprecated and will be removed in a future release') class Meta: fields = ('*', 'source', 'source_path', 'source_script', 'source_vars', 'credential', @@ -1548,9 +1551,9 @@ class InventorySourceOptionsSerializer(BaseSerializer): def get_related(self, obj): res = super(InventorySourceOptionsSerializer, self).get_related(obj) - if obj.credential: + if obj.credential: # TODO: remove when 'credential' field is removed res['credential'] = self.reverse('api:credential_detail', - kwargs={'pk': obj.credential.pk}) + kwargs={'pk': obj.credential}) if obj.source_script: res['source_script'] = self.reverse('api:inventory_script_detail', kwargs={'pk': obj.source_script.pk}) return res @@ -1590,13 +1593,19 @@ class InventorySourceOptionsSerializer(BaseSerializer): return super(InventorySourceOptionsSerializer, self).validate(attrs) - def to_representation(self, obj): - ret = super(InventorySourceOptionsSerializer, self).to_representation(obj) - if obj is None: - return ret - if 'credential' in ret and not obj.credential: - ret['credential'] = None - return ret + # TODO: remove when old 'credential' fields are removed + def get_summary_fields(self, obj): + summary_fields = super(InventorySourceOptionsSerializer, self).get_summary_fields(obj) + if 'credential' in summary_fields: + cred = obj.get_cloud_credential() + if cred: + summary_fields['credential'] = { + 'id': cred.id, 'name': cred.name, 'description': cred.description, + 'kind': cred.kind, 'cloud': True, 'credential_type_id': cred.credential_type_id + } + else: + summary_fields.pop('credential') + return summary_fields class InventorySourceSerializer(UnifiedJobTemplateSerializer, InventorySourceOptionsSerializer): @@ -1620,6 +1629,7 @@ class InventorySourceSerializer(UnifiedJobTemplateSerializer, InventorySourceOpt update = self.reverse('api:inventory_source_update_view', kwargs={'pk': obj.pk}), inventory_updates = self.reverse('api:inventory_source_updates_list', kwargs={'pk': obj.pk}), schedules = self.reverse('api:inventory_source_schedules_list', kwargs={'pk': obj.pk}), + credentials = self.reverse('api:inventory_source_credentials_list', kwargs={'pk': obj.pk}), activity_stream = self.reverse('api:inventory_source_activity_stream_list', kwargs={'pk': obj.pk}), hosts = self.reverse('api:inventory_source_hosts_list', kwargs={'pk': obj.pk}), groups = self.reverse('api:inventory_source_groups_list', kwargs={'pk': obj.pk}), @@ -1673,6 +1683,14 @@ class InventorySourceSerializer(UnifiedJobTemplateSerializer, InventorySourceOpt field_kwargs.pop('queryset', None) return field_class, field_kwargs + # TODO: remove when old 'credential' fields are removed + def build_field(self, field_name, info, model_class, nested_depth): + # have to special-case the field so that DRF will not automagically make it + # read-only because it's a property on the model. + if field_name == 'credential': + return self.build_standard_field(field_name, self.credential) + return super(InventorySourceOptionsSerializer, self).build_field(field_name, info, model_class, nested_depth) + def to_representation(self, obj): ret = super(InventorySourceSerializer, self).to_representation(obj) if obj is None: @@ -1702,7 +1720,44 @@ class InventorySourceSerializer(UnifiedJobTemplateSerializer, InventorySourceOpt raise serializers.ValidationError({"detail": _("Cannot create Inventory Source for Smart Inventory")}) return value + # TODO: remove when old 'credential' fields are removed + def create(self, validated_data): + deprecated_fields = {} + if 'credential' in validated_data: + deprecated_fields['credential'] = validated_data.pop('credential') + obj = super(InventorySourceSerializer, self).create(validated_data) + if deprecated_fields: + self._update_deprecated_fields(deprecated_fields, obj) + return obj + + # TODO: remove when old 'credential' fields are removed + def update(self, obj, validated_data): + deprecated_fields = {} + if 'credential' in validated_data: + deprecated_fields['credential'] = validated_data.pop('credential') + obj = super(InventorySourceSerializer, self).update(obj, validated_data) + if deprecated_fields: + self._update_deprecated_fields(deprecated_fields, obj) + return obj + + # TODO: remove when old 'credential' fields are removed + def _update_deprecated_fields(self, fields, obj): + if 'credential' in fields: + new_cred = fields['credential'] + existing_creds = obj.credentials.exclude(credential_type__kind='vault') + for cred in existing_creds: + # Remove all other cloud credentials + if cred != new_cred: + obj.credentials.remove(cred) + if new_cred: + # Add new credential + obj.credentials.add(new_cred) + def validate(self, attrs): + deprecated_fields = {} + if 'credential' in attrs: # TODO: remove when 'credential' field removed + deprecated_fields['credential'] = attrs.pop('credential') + def get_field_from_model_or_attrs(fd): return attrs.get(fd, self.instance and getattr(self.instance, fd) or None) @@ -1716,7 +1771,25 @@ class InventorySourceSerializer(UnifiedJobTemplateSerializer, InventorySourceOpt {"detail": _("Cannot set %s if not SCM type." % ' '.join(redundant_scm_fields))} ) - return super(InventorySourceSerializer, self).validate(attrs) + attrs = super(InventorySourceSerializer, self).validate(attrs) + + # Check type consistency of source and cloud credential, if provided + if 'credential' in deprecated_fields: # TODO: remove when v2 API is deprecated + cred = deprecated_fields['credential'] + attrs['credential'] = cred + if cred is not None: + cred = Credential.objects.get(pk=cred) + view = self.context.get('view', None) + if (not view) or (not view.request) or (view.request.user not in cred.use_role): + raise PermissionDenied() + cred_error = InventorySource.cloud_credential_validation( + get_field_from_model_or_attrs('source'), + cred + ) + if cred_error: + raise serializers.ValidationError({"detail": cred_error}) + + return attrs class InventorySourceUpdateSerializer(InventorySourceSerializer): @@ -1746,6 +1819,7 @@ class InventoryUpdateSerializer(UnifiedJobSerializer, InventorySourceOptionsSeri res.update(dict( cancel = self.reverse('api:inventory_update_cancel', kwargs={'pk': obj.pk}), notifications = self.reverse('api:inventory_update_notifications_list', kwargs={'pk': obj.pk}), + credentials = self.reverse('api:inventory_update_credentials_list', kwargs={'pk': obj.pk}), events = self.reverse('api:inventory_update_events_list', kwargs={'pk': obj.pk}), )) if obj.source_project_update_id: @@ -2183,8 +2257,6 @@ class CredentialSerializer(BaseSerializer): for rel in ( 'ad_hoc_commands', 'insights_inventories', - 'inventorysources', - 'inventoryupdates', 'unifiedjobs', 'unifiedjobtemplates', 'projects', diff --git a/awx/api/urls/inventory_source.py b/awx/api/urls/inventory_source.py index b1f928f973..7a03b91f76 100644 --- a/awx/api/urls/inventory_source.py +++ b/awx/api/urls/inventory_source.py @@ -10,6 +10,7 @@ from awx.api.views import ( InventorySourceUpdatesList, InventorySourceActivityStreamList, InventorySourceSchedulesList, + InventorySourceCredentialsList, InventorySourceGroupsList, InventorySourceHostsList, InventorySourceNotificationTemplatesAnyList, @@ -25,6 +26,7 @@ urls = [ url(r'^(?P[0-9]+)/inventory_updates/$', InventorySourceUpdatesList.as_view(), name='inventory_source_updates_list'), url(r'^(?P[0-9]+)/activity_stream/$', InventorySourceActivityStreamList.as_view(), name='inventory_source_activity_stream_list'), url(r'^(?P[0-9]+)/schedules/$', InventorySourceSchedulesList.as_view(), name='inventory_source_schedules_list'), + url(r'^(?P[0-9]+)/credentials/$', InventorySourceCredentialsList.as_view(), name='inventory_source_credentials_list'), url(r'^(?P[0-9]+)/groups/$', InventorySourceGroupsList.as_view(), name='inventory_source_groups_list'), url(r'^(?P[0-9]+)/hosts/$', InventorySourceHostsList.as_view(), name='inventory_source_hosts_list'), url(r'^(?P[0-9]+)/notification_templates_any/$', InventorySourceNotificationTemplatesAnyList.as_view(), diff --git a/awx/api/urls/inventory_update.py b/awx/api/urls/inventory_update.py index 5d68831d7b..0279f8c915 100644 --- a/awx/api/urls/inventory_update.py +++ b/awx/api/urls/inventory_update.py @@ -9,6 +9,7 @@ from awx.api.views import ( InventoryUpdateCancel, InventoryUpdateStdout, InventoryUpdateNotificationsList, + InventoryUpdateCredentialsList, InventoryUpdateEventsList, ) @@ -19,6 +20,7 @@ urls = [ url(r'^(?P[0-9]+)/cancel/$', InventoryUpdateCancel.as_view(), name='inventory_update_cancel'), url(r'^(?P[0-9]+)/stdout/$', InventoryUpdateStdout.as_view(), name='inventory_update_stdout'), url(r'^(?P[0-9]+)/notifications/$', InventoryUpdateNotificationsList.as_view(), name='inventory_update_notifications_list'), + url(r'^(?P[0-9]+)/credentials/$', InventoryUpdateCredentialsList.as_view(), name='inventory_update_credentials_list'), url(r'^(?P[0-9]+)/events/$', InventoryUpdateEventsList.as_view(), name='inventory_update_events_list'), ] diff --git a/awx/api/views.py b/awx/api/views.py index 5a6aebebc1..0358d43e8e 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -2723,6 +2723,31 @@ class InventorySourceUpdatesList(SubListAPIView): relationship = 'inventory_updates' +class InventorySourceCredentialsList(SubListAttachDetachAPIView): + + parent_model = InventorySource + model = Credential + serializer_class = CredentialSerializer + relationship = 'credentials' + + def is_valid_relation(self, parent, sub, created=False): + error = InventorySource.cloud_credential_validation(parent.source, sub) + if error: + return {'msg': error} + if sub.credential_type == 'vault': + # Vault credentials are only exclusive with others of same ID + if sub.unique_hash() in [cred.unique_hash() for cred in parent.credentials.all()]: + return {"msg": _( + "A credential of type {credential_type} is already assigned to this inventory source." + ).format(credential_type=sub.unique_hash(display=True))} + else: + # Cloud credentials are exclusive with all other cloud credentials + cloud_cred_qs = parent.credentials.exclude(credential_type__kind='vault') + if cloud_cred_qs.exists(): + return {'msg': _("Source already has cloud credential assigned.")} + return None + + class InventorySourceUpdateView(RetrieveAPIView): model = InventorySource @@ -2757,6 +2782,14 @@ class InventoryUpdateDetail(UnifiedJobDeletionMixin, RetrieveDestroyAPIView): serializer_class = InventoryUpdateSerializer +class InventoryUpdateCredentialsList(SubListAPIView): + + parent_model = InventoryUpdate + model = Credential + serializer_class = CredentialSerializer + relationship = 'credentials' + + class InventoryUpdateCancel(RetrieveAPIView): model = InventoryUpdate diff --git a/awx/main/access.py b/awx/main/access.py index 0d66c7209b..485af5a7ef 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -830,7 +830,8 @@ class InventorySourceAccess(BaseAccess): ''' model = InventorySource - select_related = ('created_by', 'modified_by', 'inventory',) + select_related = ('created_by', 'modified_by', 'inventory') + prefetch_related = ('credentials',) def filtered_queryset(self): return self.model.objects.filter(inventory__in=Inventory.accessible_pk_qs(self.user, 'read_role')) @@ -878,6 +879,21 @@ class InventorySourceAccess(BaseAccess): return self.user in obj.inventory.update_role return False + @check_superuser + def can_attach(self, obj, sub_obj, relationship, data, skip_sub_obj_read_check=False): + if relationship == 'credentials' and isinstance(sub_obj, Credential): + return ( + obj and obj.inventory and self.user in obj.inventory.admin_role and + self.user in sub_obj.use_role) + return super(InventorySourceAccess, self).can_attach( + obj, sub_obj, relationship, data, skip_sub_obj_read_check=skip_sub_obj_read_check) + + @check_superuser + def can_unattach(self, obj, sub_obj, relationship, *args, **kwargs): + if relationship == 'credentials' and isinstance(sub_obj, Credential): + return obj and obj.inventory and self.user in obj.inventory.admin_role + return super(InventorySourceAccess, self).can_attach(obj, sub_obj, relationship, *args, **kwargs) + class InventoryUpdateAccess(BaseAccess): ''' @@ -888,7 +904,7 @@ class InventoryUpdateAccess(BaseAccess): model = InventoryUpdate select_related = ('created_by', 'modified_by', 'inventory_source__inventory',) - prefetch_related = ('unified_job_template', 'instance_group',) + prefetch_related = ('unified_job_template', 'instance_group', 'credentials',) def filtered_queryset(self): return self.model.objects.filter(inventory_source__inventory__in=Inventory.accessible_pk_qs(self.user, 'read_role')) diff --git a/awx/main/migrations/0023_v330_inventory_multicred.py b/awx/main/migrations/0023_v330_inventory_multicred.py new file mode 100644 index 0000000000..b184abc296 --- /dev/null +++ b/awx/main/migrations/0023_v330_inventory_multicred.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.7 on 2018-02-14 16:14 +from __future__ import unicode_literals + +from django.db import migrations + +from awx.main.migrations import _migration_utils as migration_utils +from awx.main.migrations._multi_cred import ( + migrate_inventory_source_cred, + migrate_inventory_source_cred_reverse +) + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0022_v330_create_new_rbac_roles'), + ] + + operations = [ + # Run data migration before removing the old credential field + migrations.RunPython(migration_utils.set_current_apps_for_migrations, migrations.RunPython.noop), + migrations.RunPython(migrate_inventory_source_cred, migrate_inventory_source_cred_reverse), + migrations.RemoveField( + model_name='inventorysource', + name='credential', + ), + migrations.RemoveField( + model_name='inventoryupdate', + name='credential', + ), + ] diff --git a/awx/main/migrations/_multi_cred.py b/awx/main/migrations/_multi_cred.py index dd363e8685..5e4ed5ff4b 100644 --- a/awx/main/migrations/_multi_cred.py +++ b/awx/main/migrations/_multi_cred.py @@ -32,3 +32,25 @@ def migrate_workflow_cred_reverse(app, schema_editor): if cred: node.credential = cred node.save() + + +def migrate_inventory_source_cred(app, schema_editor): + InventoryUpdate = app.get_model('main', 'InventoryUpdate') + InventorySource = app.get_model('main', 'InventorySource') + + for cls in (InventoryUpdate, InventorySource): + for obj in cls.objects.iterator(): + if obj.credential: + obj.credentials.add(obj.credential) + + +def migrate_inventory_source_cred_reverse(app, schema_editor): + InventoryUpdate = app.get_model('main', 'InventoryUpdate') + InventorySource = app.get_model('main', 'InventorySource') + + for cls in (InventoryUpdate, InventorySource): + for obj in cls.objects.iterator(): + cred = obj.credentials.first() + if cred: + obj.credential = cred + obj.save() diff --git a/awx/main/models/inventory.py b/awx/main/models/inventory.py index 68adf6daf0..210c5d5de2 100644 --- a/awx/main/models/inventory.py +++ b/awx/main/models/inventory.py @@ -1084,14 +1084,6 @@ class InventorySourceOptions(BaseModel): default='', help_text=_('Inventory source variables in YAML or JSON format.'), ) - credential = models.ForeignKey( - 'Credential', - related_name='%(class)ss', - null=True, - default=None, - blank=True, - on_delete=models.SET_NULL, - ) source_regions = models.CharField( max_length=1024, blank=True, @@ -1223,30 +1215,48 @@ class InventorySourceOptions(BaseModel): """No region supprt""" return [('all', 'All')] - def clean_credential(self): - if not self.source: + @staticmethod + def cloud_credential_validation(source, cred): + if not source: return None - cred = self.credential - if cred and self.source not in ('custom', 'scm'): + if cred and source not in ('custom', 'scm'): # If a credential was provided, it's important that it matches # the actual inventory source being used (Amazon requires Amazon # credentials; Rackspace requires Rackspace credentials; etc...) - if self.source.replace('ec2', 'aws') != cred.kind: - raise ValidationError( - _('Cloud-based inventory sources (such as %s) require ' - 'credentials for the matching cloud service.') % self.source - ) + if source.replace('ec2', 'aws') != cred.kind: + return _('Cloud-based inventory sources (such as %s) require ' + 'credentials for the matching cloud service.') % source # Allow an EC2 source to omit the credential. If Tower is running on # an EC2 instance with an IAM Role assigned, boto will use credentials # from the instance metadata instead of those explicitly provided. - elif self.source in CLOUD_PROVIDERS and self.source != 'ec2': - raise ValidationError(_('Credential is required for a cloud source.')) - elif self.source == 'custom' and cred and cred.credential_type.kind in ('scm', 'ssh', 'insights', 'vault'): - raise ValidationError(_( + elif source in CLOUD_PROVIDERS and source != 'ec2': + return _('Credential is required for a cloud source.') + elif source == 'custom' and cred and cred.credential_type.kind in ('scm', 'ssh', 'insights', 'vault'): + return _( 'Credentials of type machine, source control, insights and vault are ' 'disallowed for custom inventory sources.' - )) - return cred + ) + return None + + def get_deprecated_credential(self, kind): + for cred in self.credentials.all(): + if cred.credential_type.kind == kind: + return cred + else: + return None + + def get_cloud_credential(self): + credential = None + for cred in self.credentials.all(): + if cred.credential_type.kind != 'vault': + credential = cred + return credential + + @property + def credential(self): + cred = self.get_cloud_credential() + if cred is not None: + return cred.pk def clean_source_regions(self): regions = self.source_regions @@ -1376,7 +1386,7 @@ class InventorySource(UnifiedJobTemplate, InventorySourceOptions): @classmethod def _get_unified_job_field_names(cls): return set(f.name for f in InventorySourceOptions._meta.fields) | set( - ['name', 'description', 'schedule'] + ['name', 'description', 'schedule', 'credentials'] ) def save(self, *args, **kwargs): @@ -1621,7 +1631,7 @@ class InventoryUpdate(UnifiedJob, InventorySourceOptions, JobNotificationMixin, return False if (self.source not in ('custom', 'ec2', 'scm') and - not (self.credential)): + not (self.get_cloud_credential())): return False elif self.source == 'scm' and not self.inventory_source.source_project: return False diff --git a/awx/main/tasks.py b/awx/main/tasks.py index 9aae7e6dbf..e2e6f5a827 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -924,8 +924,11 @@ class BaseTask(LogErrorsTask): credentials = [] if isinstance(instance, Job): credentials = instance.credentials.all() + elif isinstance(instance, InventoryUpdate): + # TODO: allow multiple custom creds for inv updates + credentials = [instance.get_cloud_credential()] elif hasattr(instance, 'credential'): - # once other UnifiedJobs (project updates, inventory updates) + # once other UnifiedJobs (project updates) # move from a .credential -> .credentials model, we can # lose this block credentials = [instance.credential] @@ -1719,14 +1722,13 @@ class RunInventoryUpdate(BaseTask): If no private data is needed, return None. """ private_data = {'credentials': {}} + credential = inventory_update.get_cloud_credential() # If this is GCE, return the RSA key if inventory_update.source == 'gce': - credential = inventory_update.credential private_data['credentials'][credential] = decrypt_field(credential, 'ssh_key_data') return private_data if inventory_update.source == 'openstack': - credential = inventory_update.credential openstack_auth = dict(auth_url=credential.host, username=credential.username, password=decrypt_field(credential, "password"), @@ -1803,7 +1805,6 @@ class RunInventoryUpdate(BaseTask): cp.set(section, k, six.text_type(v)) # Allow custom options to vmware inventory script. elif inventory_update.source == 'vmware': - credential = inventory_update.credential section = 'vmware' cp.add_section(section) @@ -1838,7 +1839,6 @@ class RunInventoryUpdate(BaseTask): else: cp.set(section, k, six.text_type(v)) - credential = inventory_update.credential if credential: cp.set(section, 'url', credential.host) cp.set(section, 'user', credential.username) @@ -1859,7 +1859,6 @@ class RunInventoryUpdate(BaseTask): section = 'cloudforms' cp.add_section(section) - credential = inventory_update.credential if credential: cp.set(section, 'url', credential.host) cp.set(section, 'username', credential.username) @@ -1897,7 +1896,7 @@ class RunInventoryUpdate(BaseTask): if cp.sections(): f = cStringIO.StringIO() cp.write(f) - private_data['credentials'][inventory_update.credential] = f.getvalue() + private_data['credentials'][credential] = f.getvalue() return private_data def build_passwords(self, inventory_update, **kwargs): @@ -1912,7 +1911,7 @@ class RunInventoryUpdate(BaseTask): # Take key fields from the credential in use and add them to the # passwords dictionary. - credential = inventory_update.credential + credential = inventory_update.get_cloud_credential() if credential: for subkey in ('username', 'host', 'project', 'client', 'tenant', 'subscription'): passwords['source_%s' % subkey] = getattr(credential, subkey) @@ -1957,7 +1956,9 @@ class RunInventoryUpdate(BaseTask): } if inventory_update.source in ini_mapping: cred_data = kwargs.get('private_data_files', {}).get('credentials', '') - env[ini_mapping[inventory_update.source]] = cred_data.get(inventory_update.credential, '') + env[ini_mapping[inventory_update.source]] = cred_data.get( + inventory_update.get_cloud_credential(), '' + ) if inventory_update.source == 'gce': env['GCE_ZONE'] = inventory_update.source_regions if inventory_update.source_regions != 'all' else '' # noqa diff --git a/awx/main/tests/functional/api/test_credential.py b/awx/main/tests/functional/api/test_credential.py index 486ba7cacc..2800a70be3 100644 --- a/awx/main/tests/functional/api/test_credential.py +++ b/awx/main/tests/functional/api/test_credential.py @@ -1426,9 +1426,9 @@ def test_field_removal(put, organization, admin, credentialtype_ssh, version, pa @pytest.mark.parametrize('relation, related_obj', [ ['ad_hoc_commands', AdHocCommand()], ['insights_inventories', Inventory()], - ['inventorysources', InventorySource()], ['unifiedjobs', Job()], ['unifiedjobtemplates', JobTemplate()], + ['unifiedjobtemplates', InventorySource()], ['projects', Project()], ['workflowjobnodes', WorkflowJobNode()], ]) diff --git a/awx/main/tests/unit/test_tasks.py b/awx/main/tests/unit/test_tasks.py index 1d00fdbb94..6265098c20 100644 --- a/awx/main/tests/unit/test_tasks.py +++ b/awx/main/tests/unit/test_tasks.py @@ -103,18 +103,22 @@ def test_safe_env_returns_new_copy(): def test_openstack_client_config_generation(mocker): update = tasks.RunInventoryUpdate() + credential = mocker.Mock(**{ + 'host': 'https://keystone.openstack.example.org', + 'username': 'demo', + 'password': 'secrete', + 'project': 'demo-project', + 'domain': 'my-demo-domain', + }) + cred_method = mocker.Mock(return_value=credential) inventory_update = mocker.Mock(**{ 'source': 'openstack', - 'credential.host': 'https://keystone.openstack.example.org', - 'credential.username': 'demo', - 'credential.password': 'secrete', - 'credential.project': 'demo-project', - 'credential.domain': 'my-demo-domain', - 'source_vars_dict': {} + 'source_vars_dict': {}, + 'get_cloud_credential': cred_method }) cloud_config = update.build_private_data(inventory_update) cloud_credential = yaml.load( - cloud_config.get('credentials')[inventory_update.credential] + cloud_config.get('credentials')[credential] ) assert cloud_credential['clouds'] == { 'devstack': { @@ -135,18 +139,22 @@ def test_openstack_client_config_generation(mocker): ]) def test_openstack_client_config_generation_with_private_source_vars(mocker, source, expected): update = tasks.RunInventoryUpdate() + credential = mocker.Mock(**{ + 'host': 'https://keystone.openstack.example.org', + 'username': 'demo', + 'password': 'secrete', + 'project': 'demo-project', + 'domain': None, + }) + cred_method = mocker.Mock(return_value=credential) inventory_update = mocker.Mock(**{ 'source': 'openstack', - 'credential.host': 'https://keystone.openstack.example.org', - 'credential.username': 'demo', - 'credential.password': 'secrete', - 'credential.project': 'demo-project', - 'credential.domain': None, - 'source_vars_dict': {'private': source} + 'source_vars_dict': {'private': source}, + 'get_cloud_credential': cred_method }) cloud_config = update.build_private_data(inventory_update) cloud_credential = yaml.load( - cloud_config.get('credentials')[inventory_update.credential] + cloud_config.get('credentials')[credential] ) assert cloud_credential['clouds'] == { 'devstack': { @@ -1519,8 +1527,9 @@ class TestInventoryUpdateCredentials(TestJobExecution): ) ) - def test_source_without_credential(self): + def test_source_without_credential(self, mocker): self.instance.source = 'ec2' + self.instance.get_cloud_credential = mocker.Mock(return_value=None) def run_pexpect_side_effect(*args, **kwargs): args, cwd, env, stdout = args @@ -1538,7 +1547,7 @@ class TestInventoryUpdateCredentials(TestJobExecution): self.task.run(self.pk) @pytest.mark.parametrize('with_credential', [True, False]) - def test_custom_source(self, with_credential): + def test_custom_source(self, with_credential, mocker): self.instance.source = 'custom' self.instance.source_vars = '{"FOO": "BAR"}' patch = mock.patch.object(InventoryUpdate, 'source_script', mock.Mock( @@ -1549,16 +1558,22 @@ class TestInventoryUpdateCredentials(TestJobExecution): if with_credential: azure_rm = CredentialType.defaults['azure_rm']() - self.instance.credential = Credential( - pk=1, - credential_type=azure_rm, - inputs = { - 'client': 'some-client', - 'secret': 'some-secret', - 'tenant': 'some-tenant', - 'subscription': 'some-subscription', - } - ) + + def get_cred(): + cred = Credential( + pk=1, + credential_type=azure_rm, + inputs = { + 'client': 'some-client', + 'secret': 'some-secret', + 'tenant': 'some-tenant', + 'subscription': 'some-subscription', + } + ) + return cred + self.instance.get_cloud_credential = get_cred + else: + self.instance.get_cloud_credential = mocker.Mock(return_value=None) def run_pexpect_side_effect(*args, **kwargs): args, cwd, env, stdout = args @@ -1580,14 +1595,16 @@ class TestInventoryUpdateCredentials(TestJobExecution): def test_ec2_source(self): aws = CredentialType.defaults['aws']() self.instance.source = 'ec2' - self.instance.credential = Credential( - pk=1, - credential_type=aws, - inputs = {'username': 'bob', 'password': 'secret'} - ) - self.instance.credential.inputs['password'] = encrypt_field( - self.instance.credential, 'password' - ) + + def get_cred(): + cred = Credential( + pk=1, + credential_type=aws, + inputs = {'username': 'bob', 'password': 'secret'} + ) + cred.inputs['password'] = encrypt_field(cred, 'password') + return cred + self.instance.get_cloud_credential = get_cred def run_pexpect_side_effect(*args, **kwargs): args, cwd, env, stdout = args @@ -1608,14 +1625,16 @@ class TestInventoryUpdateCredentials(TestJobExecution): def test_vmware_source(self): vmware = CredentialType.defaults['vmware']() self.instance.source = 'vmware' - self.instance.credential = Credential( - pk=1, - credential_type=vmware, - inputs = {'username': 'bob', 'password': 'secret', 'host': 'https://example.org'} - ) - self.instance.credential.inputs['password'] = encrypt_field( - self.instance.credential, 'password' - ) + + def get_cred(): + cred = Credential( + pk=1, + credential_type=vmware, + inputs = {'username': 'bob', 'password': 'secret', 'host': 'https://example.org'} + ) + cred.inputs['password'] = encrypt_field(cred, 'password') + return cred + self.instance.get_cloud_credential = get_cred def run_pexpect_side_effect(*args, **kwargs): args, cwd, env, stdout = args @@ -1634,17 +1653,21 @@ class TestInventoryUpdateCredentials(TestJobExecution): azure_rm = CredentialType.defaults['azure_rm']() self.instance.source = 'azure_rm' self.instance.source_regions = 'north, south, east, west' - self.instance.credential = Credential( - pk=1, - credential_type=azure_rm, - inputs = { - 'client': 'some-client', - 'secret': 'some-secret', - 'tenant': 'some-tenant', - 'subscription': 'some-subscription', - 'cloud_environment': 'foobar' - } - ) + + def get_cred(): + cred = Credential( + pk=1, + credential_type=azure_rm, + inputs = { + 'client': 'some-client', + 'secret': 'some-secret', + 'tenant': 'some-tenant', + 'subscription': 'some-subscription', + 'cloud_environment': 'foobar' + } + ) + return cred + self.instance.get_cloud_credential = get_cred def run_pexpect_side_effect(*args, **kwargs): args, cwd, env, stdout = args @@ -1671,16 +1694,20 @@ class TestInventoryUpdateCredentials(TestJobExecution): azure_rm = CredentialType.defaults['azure_rm']() self.instance.source = 'azure_rm' self.instance.source_regions = 'all' - self.instance.credential = Credential( - pk=1, - credential_type=azure_rm, - inputs = { - 'subscription': 'some-subscription', - 'username': 'bob', - 'password': 'secret', - 'cloud_environment': 'foobar' - } - ) + + def get_cred(): + cred = Credential( + pk=1, + credential_type=azure_rm, + inputs = { + 'subscription': 'some-subscription', + 'username': 'bob', + 'password': 'secret', + 'cloud_environment': 'foobar' + } + ) + return cred + self.instance.get_cloud_credential = get_cred def run_pexpect_side_effect(*args, **kwargs): args, cwd, env, stdout = args @@ -1706,18 +1733,23 @@ class TestInventoryUpdateCredentials(TestJobExecution): gce = CredentialType.defaults['gce']() self.instance.source = 'gce' self.instance.source_regions = 'all' - self.instance.credential = Credential( - pk=1, - credential_type=gce, - inputs = { - 'username': 'bob', - 'project': 'some-project', - 'ssh_key_data': self.EXAMPLE_PRIVATE_KEY - } - ) - self.instance.credential.inputs['ssh_key_data'] = encrypt_field( - self.instance.credential, 'ssh_key_data' - ) + + def get_cred(): + cred = Credential( + pk=1, + credential_type=gce, + inputs = { + 'username': 'bob', + 'project': 'some-project', + 'ssh_key_data': self.EXAMPLE_PRIVATE_KEY + } + ) + cred.inputs['ssh_key_data'] = encrypt_field( + cred, 'ssh_key_data' + ) + return cred + self.instance.get_cloud_credential = get_cred + expected_gce_zone = '' def run_pexpect_side_effect(*args, **kwargs): @@ -1745,19 +1777,23 @@ class TestInventoryUpdateCredentials(TestJobExecution): def test_openstack_source(self): openstack = CredentialType.defaults['openstack']() self.instance.source = 'openstack' - self.instance.credential = Credential( - pk=1, - credential_type=openstack, - inputs = { - 'username': 'bob', - 'password': 'secret', - 'project': 'tenant-name', - 'host': 'https://keystone.example.org' - } - ) - self.instance.credential.inputs['ssh_key_data'] = encrypt_field( - self.instance.credential, 'ssh_key_data' - ) + + def get_cred(): + cred = Credential( + pk=1, + credential_type=openstack, + inputs = { + 'username': 'bob', + 'password': 'secret', + 'project': 'tenant-name', + 'host': 'https://keystone.example.org' + } + ) + cred.inputs['ssh_key_data'] = encrypt_field( + cred, 'ssh_key_data' + ) + return cred + self.instance.get_cloud_credential = get_cred def run_pexpect_side_effect(*args, **kwargs): args, cwd, env, stdout = args @@ -1780,18 +1816,23 @@ class TestInventoryUpdateCredentials(TestJobExecution): def test_satellite6_source(self): satellite6 = CredentialType.defaults['satellite6']() self.instance.source = 'satellite6' - self.instance.credential = Credential( - pk=1, - credential_type=satellite6, - inputs = { - 'username': 'bob', - 'password': 'secret', - 'host': 'https://example.org' - } - ) - self.instance.credential.inputs['password'] = encrypt_field( - self.instance.credential, 'password' - ) + + def get_cred(): + cred = Credential( + pk=1, + credential_type=satellite6, + inputs = { + 'username': 'bob', + 'password': 'secret', + 'host': 'https://example.org' + } + ) + cred.inputs['password'] = encrypt_field( + cred, 'password' + ) + return cred + self.instance.get_cloud_credential = get_cred + self.instance.source_vars = '{"satellite6_group_patterns": "[a,b,c]", "satellite6_group_prefix": "hey_"}' def run_pexpect_side_effect(*args, **kwargs): @@ -1811,18 +1852,22 @@ class TestInventoryUpdateCredentials(TestJobExecution): def test_cloudforms_source(self): cloudforms = CredentialType.defaults['cloudforms']() self.instance.source = 'cloudforms' - self.instance.credential = Credential( - pk=1, - credential_type=cloudforms, - inputs = { - 'username': 'bob', - 'password': 'secret', - 'host': 'https://example.org' - } - ) - self.instance.credential.inputs['password'] = encrypt_field( - self.instance.credential, 'password' - ) + + def get_cred(): + cred = Credential( + pk=1, + credential_type=cloudforms, + inputs = { + 'username': 'bob', + 'password': 'secret', + 'host': 'https://example.org' + } + ) + cred.inputs['password'] = encrypt_field( + cred, 'password' + ) + return cred + self.instance.get_cloud_credential = get_cred self.instance.source_vars = '{"prefer_ipv4": True}' @@ -1847,14 +1892,19 @@ class TestInventoryUpdateCredentials(TestJobExecution): def test_awx_task_env(self): gce = CredentialType.defaults['gce']() self.instance.source = 'gce' - self.instance.credential = Credential( - pk=1, - credential_type=gce, - inputs = { - 'username': 'bob', - 'project': 'some-project', - } - ) + + def get_cred(): + cred = Credential( + pk=1, + credential_type=gce, + inputs = { + 'username': 'bob', + 'project': 'some-project', + } + ) + return cred + self.instance.get_cloud_credential = get_cred + patch = mock.patch('awx.main.tasks.settings.AWX_TASK_ENV', {'FOO': 'BAR'}) patch.start() From bb6032cff64a13c35f6c4f04b7820c7de0c854cb Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Mon, 19 Feb 2018 14:46:39 -0500 Subject: [PATCH 2/2] docs and review change for IS multivault Mention inventory sources /credentials/ endpoint in docs Also change means of identifying projects for the purose of injecting custom credentials --- awx/api/views.py | 7 ++----- awx/main/tasks.py | 4 ++-- docs/multi_credential_assignment.md | 15 ++++++++++++++- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/awx/api/views.py b/awx/api/views.py index 0358d43e8e..c22cfd9154 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -2735,11 +2735,8 @@ class InventorySourceCredentialsList(SubListAttachDetachAPIView): if error: return {'msg': error} if sub.credential_type == 'vault': - # Vault credentials are only exclusive with others of same ID - if sub.unique_hash() in [cred.unique_hash() for cred in parent.credentials.all()]: - return {"msg": _( - "A credential of type {credential_type} is already assigned to this inventory source." - ).format(credential_type=sub.unique_hash(display=True))} + # TODO: support this + return {"msg": _("Vault credentials are not yet supported for inventory sources.")} else: # Cloud credentials are exclusive with all other cloud credentials cloud_cred_qs = parent.credentials.exclude(credential_type__kind='vault') diff --git a/awx/main/tasks.py b/awx/main/tasks.py index e2e6f5a827..42a7db56fc 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -927,8 +927,8 @@ class BaseTask(LogErrorsTask): elif isinstance(instance, InventoryUpdate): # TODO: allow multiple custom creds for inv updates credentials = [instance.get_cloud_credential()] - elif hasattr(instance, 'credential'): - # once other UnifiedJobs (project updates) + elif isinstance(instance, Project): + # once (or if) project updates # move from a .credential -> .credentials model, we can # lose this block credentials = [instance.credential] diff --git a/docs/multi_credential_assignment.md b/docs/multi_credential_assignment.md index ca7bfa2353..f32203860c 100644 --- a/docs/multi_credential_assignment.md +++ b/docs/multi_credential_assignment.md @@ -2,7 +2,7 @@ Multi-Credential Assignment =========================== awx has added support for assigning zero or more credentials to -a JobTemplate via a singular, unified interface. +JobTemplates and InventoryUpdates via a singular, unified interface. Background ---------- @@ -220,3 +220,16 @@ POST /api/v2/job_templates/N/launch/ } } ``` + +Inventory Source Credentials +---------------------------- + +Inventory sources and inventory updates that they spawn also use the same +relationship. The new endpoints for this are + - `/api/v2/inventory_sources/N/credentials/` and + - `/api/v2/inventory_updates/N/credentials/` + +Most cloud sources will continue to adhere to the constraint that they +must have a single credential that corresponds to their cloud type. +However, this relationship allows users to associate multiple vault +credentials of different ids to inventory sources.