From b8e0d087e5d4a8bbd97132f2e26fc511d0e761dd Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Tue, 4 Aug 2020 09:28:57 -0400 Subject: [PATCH 01/14] add model support, an API, and a migration for Org -> Galaxy credentials see: https://github.com/ansible/awx/issues/7813 --- awx/api/serializers.py | 1 + awx/api/urls/organization.py | 2 + awx/api/views/__init__.py | 1 + awx/api/views/organization.py | 21 ++- awx/main/conf.py | 162 ------------------ awx/main/constants.py | 4 - .../migrations/0118_galaxy_credentials.py | 34 ++++ awx/main/models/credential/__init__.py | 33 ++++ awx/main/models/organization.py | 23 +++ awx/main/redact.py | 14 +- awx/main/tasks.py | 49 ++---- .../functional/api/test_credential_type.py | 2 +- .../functional/api/test_organizations.py | 89 +++++++++- .../tests/functional/models/test_project.py | 30 +++- awx/main/tests/functional/test_credential.py | 1 + awx/main/tests/unit/test_tasks.py | 95 +++++++++- awx/settings/defaults.py | 19 -- .../jobs-form/configuration-jobs.form.js | 25 --- docs/collections.md | 28 ++- 19 files changed, 362 insertions(+), 271 deletions(-) create mode 100644 awx/main/migrations/0118_galaxy_credentials.py diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 83575025e7..cd47bcc4c1 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -1269,6 +1269,7 @@ class OrganizationSerializer(BaseSerializer): object_roles = self.reverse('api:organization_object_roles_list', kwargs={'pk': obj.pk}), access_list = self.reverse('api:organization_access_list', kwargs={'pk': obj.pk}), instance_groups = self.reverse('api:organization_instance_groups_list', kwargs={'pk': obj.pk}), + galaxy_credentials = self.reverse('api:organization_galaxy_credentials_list', kwargs={'pk': obj.pk}), )) return res diff --git a/awx/api/urls/organization.py b/awx/api/urls/organization.py index 3d172f1360..12b2807905 100644 --- a/awx/api/urls/organization.py +++ b/awx/api/urls/organization.py @@ -21,6 +21,7 @@ from awx.api.views import ( OrganizationNotificationTemplatesSuccessList, OrganizationNotificationTemplatesApprovalList, OrganizationInstanceGroupsList, + OrganizationGalaxyCredentialsList, OrganizationObjectRolesList, OrganizationAccessList, OrganizationApplicationList, @@ -49,6 +50,7 @@ urls = [ url(r'^(?P[0-9]+)/notification_templates_approvals/$', OrganizationNotificationTemplatesApprovalList.as_view(), name='organization_notification_templates_approvals_list'), url(r'^(?P[0-9]+)/instance_groups/$', OrganizationInstanceGroupsList.as_view(), name='organization_instance_groups_list'), + url(r'^(?P[0-9]+)/galaxy_credentials/$', OrganizationGalaxyCredentialsList.as_view(), name='organization_galaxy_credentials_list'), url(r'^(?P[0-9]+)/object_roles/$', OrganizationObjectRolesList.as_view(), name='organization_object_roles_list'), url(r'^(?P[0-9]+)/access_list/$', OrganizationAccessList.as_view(), name='organization_access_list'), url(r'^(?P[0-9]+)/applications/$', OrganizationApplicationList.as_view(), name='organization_applications_list'), diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index c5b22d105a..0bb709fce3 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -124,6 +124,7 @@ from awx.api.views.organization import ( # noqa OrganizationNotificationTemplatesSuccessList, OrganizationNotificationTemplatesApprovalList, OrganizationInstanceGroupsList, + OrganizationGalaxyCredentialsList, OrganizationAccessList, OrganizationObjectRolesList, ) diff --git a/awx/api/views/organization.py b/awx/api/views/organization.py index cb929ec5b5..06172af79f 100644 --- a/awx/api/views/organization.py +++ b/awx/api/views/organization.py @@ -7,6 +7,7 @@ import logging # Django from django.db.models import Count from django.contrib.contenttypes.models import ContentType +from django.utils.translation import ugettext_lazy as _ # AWX from awx.main.models import ( @@ -20,7 +21,8 @@ from awx.main.models import ( Role, User, Team, - InstanceGroup + InstanceGroup, + Credential ) from awx.api.generics import ( ListCreateAPIView, @@ -42,7 +44,8 @@ from awx.api.serializers import ( RoleSerializer, NotificationTemplateSerializer, InstanceGroupSerializer, - ProjectSerializer, JobTemplateSerializer, WorkflowJobTemplateSerializer + ProjectSerializer, JobTemplateSerializer, WorkflowJobTemplateSerializer, + CredentialSerializer ) from awx.api.views.mixin import ( RelatedJobsPreventDeleteMixin, @@ -214,6 +217,20 @@ class OrganizationInstanceGroupsList(SubListAttachDetachAPIView): relationship = 'instance_groups' +class OrganizationGalaxyCredentialsList(SubListAttachDetachAPIView): + + model = Credential + serializer_class = CredentialSerializer + parent_model = Organization + relationship = 'galaxy_credentials' + + def is_valid_relation(self, parent, sub, created=False): + if sub.kind != 'galaxy_api_token': + return {'msg': _( + f"Credential must be a Galaxy credential, not {sub.credential_type.name}." + )} + + class OrganizationAccessList(ResourceAccessList): model = User # needs to be User for AccessLists's diff --git a/awx/main/conf.py b/awx/main/conf.py index 8d091894d6..ae3ad4533d 100644 --- a/awx/main/conf.py +++ b/awx/main/conf.py @@ -2,7 +2,6 @@ import json import logging import os -from distutils.version import LooseVersion as Version # Django from django.utils.translation import ugettext_lazy as _ @@ -436,87 +435,6 @@ register( category_slug='jobs', ) -register( - 'PRIMARY_GALAXY_URL', - field_class=fields.URLField, - required=False, - allow_blank=True, - label=_('Primary Galaxy Server URL'), - help_text=_( - 'For organizations that run their own Galaxy service, this gives the option to specify a ' - 'host as the primary galaxy server. Requirements will be downloaded from the primary if the ' - 'specific role or collection is available there. If the content is not avilable in the primary, ' - 'or if this field is left blank, it will default to galaxy.ansible.com.' - ), - category=_('Jobs'), - category_slug='jobs' -) - -register( - 'PRIMARY_GALAXY_USERNAME', - field_class=fields.CharField, - required=False, - allow_blank=True, - label=_('Primary Galaxy Server Username'), - help_text=_('(This setting is deprecated and will be removed in a future release) ' - 'For using a galaxy server at higher precedence than the public Ansible Galaxy. ' - 'The username to use for basic authentication against the Galaxy instance, ' - 'this is mutually exclusive with PRIMARY_GALAXY_TOKEN.'), - category=_('Jobs'), - category_slug='jobs' -) - -register( - 'PRIMARY_GALAXY_PASSWORD', - field_class=fields.CharField, - encrypted=True, - required=False, - allow_blank=True, - label=_('Primary Galaxy Server Password'), - help_text=_('(This setting is deprecated and will be removed in a future release) ' - 'For using a galaxy server at higher precedence than the public Ansible Galaxy. ' - 'The password to use for basic authentication against the Galaxy instance, ' - 'this is mutually exclusive with PRIMARY_GALAXY_TOKEN.'), - category=_('Jobs'), - category_slug='jobs' -) - -register( - 'PRIMARY_GALAXY_TOKEN', - field_class=fields.CharField, - encrypted=True, - required=False, - allow_blank=True, - label=_('Primary Galaxy Server Token'), - help_text=_('For using a galaxy server at higher precedence than the public Ansible Galaxy. ' - 'The token to use for connecting with the Galaxy instance, ' - 'this is mutually exclusive with corresponding username and password settings.'), - category=_('Jobs'), - category_slug='jobs' -) - -register( - 'PRIMARY_GALAXY_AUTH_URL', - field_class=fields.CharField, - required=False, - allow_blank=True, - label=_('Primary Galaxy Authentication URL'), - help_text=_('For using a galaxy server at higher precedence than the public Ansible Galaxy. ' - 'The token_endpoint of a Keycloak server.'), - category=_('Jobs'), - category_slug='jobs' -) - -register( - 'PUBLIC_GALAXY_ENABLED', - field_class=fields.BooleanField, - default=True, - label=_('Allow Access to Public Galaxy'), - help_text=_('Allow or deny access to the public Ansible Galaxy during project updates.'), - category=_('Jobs'), - category_slug='jobs' -) - register( 'GALAXY_IGNORE_CERTS', field_class=fields.BooleanField, @@ -856,84 +774,4 @@ def logging_validate(serializer, attrs): return attrs -def galaxy_validate(serializer, attrs): - """Ansible Galaxy config options have mutual exclusivity rules, these rules - are enforced here on serializer validation so that users will not be able - to save settings which obviously break all project updates. - """ - prefix = 'PRIMARY_GALAXY_' - errors = {} - - def _new_value(setting_name): - if setting_name in attrs: - return attrs[setting_name] - elif not serializer.instance: - return '' - return getattr(serializer.instance, setting_name, '') - - if not _new_value('PRIMARY_GALAXY_URL'): - if _new_value('PUBLIC_GALAXY_ENABLED') is False: - msg = _('A URL for Primary Galaxy must be defined before disabling public Galaxy.') - # put error in both keys because UI has trouble with errors in toggles - for key in ('PRIMARY_GALAXY_URL', 'PUBLIC_GALAXY_ENABLED'): - errors.setdefault(key, []) - errors[key].append(msg) - raise serializers.ValidationError(errors) - - from awx.main.constants import GALAXY_SERVER_FIELDS - if not any('{}{}'.format(prefix, subfield.upper()) in attrs for subfield in GALAXY_SERVER_FIELDS): - return attrs - - galaxy_data = {} - for subfield in GALAXY_SERVER_FIELDS: - galaxy_data[subfield] = _new_value('{}{}'.format(prefix, subfield.upper())) - if not galaxy_data['url']: - for k, v in galaxy_data.items(): - if v: - setting_name = '{}{}'.format(prefix, k.upper()) - errors.setdefault(setting_name, []) - errors[setting_name].append(_( - 'Cannot provide field if PRIMARY_GALAXY_URL is not set.' - )) - for k in GALAXY_SERVER_FIELDS: - if galaxy_data[k]: - setting_name = '{}{}'.format(prefix, k.upper()) - if (not serializer.instance) or (not getattr(serializer.instance, setting_name, '')): - # new auth is applied, so check if compatible with version - from awx.main.utils import get_ansible_version - current_version = get_ansible_version() - min_version = '2.9' - if Version(current_version) < Version(min_version): - errors.setdefault(setting_name, []) - errors[setting_name].append(_( - 'Galaxy server settings are not available until Ansible {min_version}, ' - 'you are running {current_version}.' - ).format(min_version=min_version, current_version=current_version)) - if (galaxy_data['password'] or galaxy_data['username']) and (galaxy_data['token'] or galaxy_data['auth_url']): - for k in ('password', 'username', 'token', 'auth_url'): - setting_name = '{}{}'.format(prefix, k.upper()) - if setting_name in attrs: - errors.setdefault(setting_name, []) - errors[setting_name].append(_( - 'Setting Galaxy token and authentication URL is mutually exclusive with username and password.' - )) - if bool(galaxy_data['username']) != bool(galaxy_data['password']): - msg = _('If authenticating via username and password, both must be provided.') - for k in ('username', 'password'): - setting_name = '{}{}'.format(prefix, k.upper()) - errors.setdefault(setting_name, []) - errors[setting_name].append(msg) - if bool(galaxy_data['token']) != bool(galaxy_data['auth_url']): - msg = _('If authenticating via token, both token and authentication URL must be provided.') - for k in ('token', 'auth_url'): - setting_name = '{}{}'.format(prefix, k.upper()) - errors.setdefault(setting_name, []) - errors[setting_name].append(msg) - - if errors: - raise serializers.ValidationError(errors) - return attrs - - register_validate('logging', logging_validate) -register_validate('jobs', galaxy_validate) diff --git a/awx/main/constants.py b/awx/main/constants.py index c0382c7504..323f61f311 100644 --- a/awx/main/constants.py +++ b/awx/main/constants.py @@ -50,7 +50,3 @@ LOGGER_BLOCKLIST = ( # loggers that may be called getting logging settings 'awx.conf' ) - -# these correspond to both AWX and Ansible settings to keep naming consistent -# for instance, settings.PRIMARY_GALAXY_AUTH_URL vs env var ANSIBLE_GALAXY_SERVER_FOO_AUTH_URL -GALAXY_SERVER_FIELDS = ('url', 'username', 'password', 'token', 'auth_url') diff --git a/awx/main/migrations/0118_galaxy_credentials.py b/awx/main/migrations/0118_galaxy_credentials.py new file mode 100644 index 0000000000..f61434d7d1 --- /dev/null +++ b/awx/main/migrations/0118_galaxy_credentials.py @@ -0,0 +1,34 @@ +# Generated by Django 2.2.11 on 2020-08-04 15:19 + +import awx.main.fields +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0117_v400_remove_cloudforms_inventory'), + ] + + operations = [ + migrations.AlterField( + model_name='credentialtype', + name='kind', + field=models.CharField(choices=[('ssh', 'Machine'), ('vault', 'Vault'), ('net', 'Network'), ('scm', 'Source Control'), ('cloud', 'Cloud'), ('token', 'Personal Access Token'), ('insights', 'Insights'), ('external', 'External'), ('kubernetes', 'Kubernetes'), ('galaxy', 'Galaxy/Automation Hub')], max_length=32), + ), + migrations.CreateModel( + name='OrganizationGalaxyCredentialMembership', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('position', models.PositiveIntegerField(db_index=True, default=None, null=True)), + ('credential', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='main.Credential')), + ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='main.Organization')), + ], + ), + migrations.AddField( + model_name='organization', + name='galaxy_credentials', + field=awx.main.fields.OrderedManyToManyField(blank=True, related_name='organization_galaxy_credentials', through='main.OrganizationGalaxyCredentialMembership', to='main.Credential'), + ), + ] diff --git a/awx/main/models/credential/__init__.py b/awx/main/models/credential/__init__.py index 36bb2684ea..be4a21a99d 100644 --- a/awx/main/models/credential/__init__.py +++ b/awx/main/models/credential/__init__.py @@ -331,6 +331,7 @@ class CredentialType(CommonModelNameNotUnique): ('insights', _('Insights')), ('external', _('External')), ('kubernetes', _('Kubernetes')), + ('galaxy', _('Galaxy/Automation Hub')), ) kind = models.CharField( @@ -1173,6 +1174,38 @@ ManagedCredentialType( ) +ManagedCredentialType( + namespace='galaxy_api_token', + kind='galaxy', + name=ugettext_noop('Ansible Galaxy Automation Hub API Token'), + inputs={ + 'fields': [{ + 'id': 'url', + 'label': ugettext_noop('Galaxy Server URL'), + 'type': 'string', + 'help_text': ugettext_noop('The URL of the galaxy instance to connect to.') + },{ + 'id': 'auth_url', + 'label': ugettext_noop('Auth Server URL'), + 'type': 'string', + 'help_text': ugettext_noop( + 'The URL of a Keycloak server token_endpoint, if using ' + 'SSO auth.' + ) + },{ + 'id': 'token', + 'label': ugettext_noop('API Token'), + 'type': 'string', + 'secret': True, + 'help_text': ugettext_noop( + 'A token to use for authentication against the Galaxy instance.' + ) + }], + 'required': ['url'], + } +) + + class CredentialInputSource(PrimordialModel): class Meta: diff --git a/awx/main/models/organization.py b/awx/main/models/organization.py index 23ce65f5e9..bf2e07d255 100644 --- a/awx/main/models/organization.py +++ b/awx/main/models/organization.py @@ -45,6 +45,12 @@ class Organization(CommonModel, NotificationFieldsModel, ResourceMixin, CustomVi blank=True, through='OrganizationInstanceGroupMembership' ) + galaxy_credentials = OrderedManyToManyField( + 'Credential', + blank=True, + through='OrganizationGalaxyCredentialMembership', + related_name='%(class)s_galaxy_credentials' + ) max_hosts = models.PositiveIntegerField( blank=True, default=0, @@ -108,6 +114,23 @@ class Organization(CommonModel, NotificationFieldsModel, ResourceMixin, CustomVi return UnifiedJob.objects.non_polymorphic().filter(organization=self) +class OrganizationGalaxyCredentialMembership(models.Model): + + organization = models.ForeignKey( + 'Organization', + on_delete=models.CASCADE + ) + credential = models.ForeignKey( + 'Credential', + on_delete=models.CASCADE + ) + position = models.PositiveIntegerField( + null=True, + default=None, + db_index=True, + ) + + class Team(CommonModelNameNotUnique, ResourceMixin): ''' A team is a group of users that work on common projects. diff --git a/awx/main/redact.py b/awx/main/redact.py index 4c286eb9a8..c0e2365941 100644 --- a/awx/main/redact.py +++ b/awx/main/redact.py @@ -1,8 +1,6 @@ import re import urllib.parse as urlparse -from django.conf import settings - REPLACE_STR = '$encrypted$' @@ -13,11 +11,13 @@ class UriCleaner(object): @staticmethod def remove_sensitive(cleartext): # exclude_list contains the items that will _not_ be redacted - exclude_list = [settings.PUBLIC_GALAXY_SERVER['url']] - if settings.PRIMARY_GALAXY_URL: - exclude_list += [settings.PRIMARY_GALAXY_URL] - if settings.FALLBACK_GALAXY_SERVERS: - exclude_list += [server['url'] for server in settings.FALLBACK_GALAXY_SERVERS] + # TODO: replace this with the server URLs from proj.org.credentials + exclude_list = [] + #exclude_list = [settings.PUBLIC_GALAXY_SERVER['url']] + #if settings.PRIMARY_GALAXY_URL: + # exclude_list += [settings.PRIMARY_GALAXY_URL] + #if settings.FALLBACK_GALAXY_SERVERS: + # exclude_list += [server['url'] for server in settings.FALLBACK_GALAXY_SERVERS] redactedtext = cleartext text_index = 0 while True: diff --git a/awx/main/tasks.py b/awx/main/tasks.py index ac1a9dca04..b81bb939d8 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -51,7 +51,7 @@ import ansible_runner # AWX from awx import __version__ as awx_application_version -from awx.main.constants import PRIVILEGE_ESCALATION_METHODS, STANDARD_INVENTORY_UPDATE_ENV, GALAXY_SERVER_FIELDS +from awx.main.constants import PRIVILEGE_ESCALATION_METHODS, STANDARD_INVENTORY_UPDATE_ENV from awx.main.access import access_registry from awx.main.redact import UriCleaner from awx.main.models import ( @@ -2027,35 +2027,24 @@ class RunProjectUpdate(BaseTask): env['PROJECT_UPDATE_ID'] = str(project_update.pk) if settings.GALAXY_IGNORE_CERTS: env['ANSIBLE_GALAXY_IGNORE'] = True - # Set up the public Galaxy server, if enabled - galaxy_configured = False - if settings.PUBLIC_GALAXY_ENABLED: - galaxy_servers = [settings.PUBLIC_GALAXY_SERVER] # static setting - else: - galaxy_configured = True - galaxy_servers = [] - # Set up fallback Galaxy servers, if configured - if settings.FALLBACK_GALAXY_SERVERS: - galaxy_configured = True - galaxy_servers = settings.FALLBACK_GALAXY_SERVERS + galaxy_servers - # Set up the primary Galaxy server, if configured - if settings.PRIMARY_GALAXY_URL: - galaxy_configured = True - galaxy_servers = [{'id': 'primary_galaxy'}] + galaxy_servers - for key in GALAXY_SERVER_FIELDS: - value = getattr(settings, 'PRIMARY_GALAXY_{}'.format(key.upper())) - if value: - galaxy_servers[0][key] = value - if galaxy_configured: - for server in galaxy_servers: - for key in GALAXY_SERVER_FIELDS: - if not server.get(key): - continue - env_key = ('ANSIBLE_GALAXY_SERVER_{}_{}'.format(server.get('id', 'unnamed'), key)).upper() - env[env_key] = server[key] - if galaxy_servers: - # now set the precedence of galaxy servers - env['ANSIBLE_GALAXY_SERVER_LIST'] = ','.join([server.get('id', 'unnamed') for server in galaxy_servers]) + + # build out env vars for Galaxy credentials (in order) + galaxy_server_list = [] + for i, cred in enumerate( + project_update.project.organization.galaxy_credentials.all() + ): + env[f'ANSIBLE_GALAXY_SERVER_SERVER{i}_URL'] = cred.get_input('url') + auth_url = cred.get_input('auth_url', default=None) + token = cred.get_input('token', default=None) + if token: + env[f'ANSIBLE_GALAXY_SERVER_SERVER{i}_TOKEN'] = token + if auth_url: + env[f'ANSIBLE_GALAXY_SERVER_SERVER{i}_AUTH_URL'] = auth_url + galaxy_server_list.append(f'server{i}') + + if galaxy_server_list: + env['ANSIBLE_GALAXY_SERVER_LIST'] = ','.join(galaxy_server_list) + return env def _build_scm_url_extra_vars(self, project_update): diff --git a/awx/main/tests/functional/api/test_credential_type.py b/awx/main/tests/functional/api/test_credential_type.py index c8f87f0c57..bf7aa4ceff 100644 --- a/awx/main/tests/functional/api/test_credential_type.py +++ b/awx/main/tests/functional/api/test_credential_type.py @@ -220,7 +220,7 @@ def test_create_valid_kind(kind, get, post, admin): @pytest.mark.django_db -@pytest.mark.parametrize('kind', ['ssh', 'vault', 'scm', 'insights', 'kubernetes']) +@pytest.mark.parametrize('kind', ['ssh', 'vault', 'scm', 'insights', 'kubernetes', 'galaxy']) def test_create_invalid_kind(kind, get, post, admin): response = post(reverse('api:credential_type_list'), { 'kind': kind, diff --git a/awx/main/tests/functional/api/test_organizations.py b/awx/main/tests/functional/api/test_organizations.py index 0827199676..6c45c0c681 100644 --- a/awx/main/tests/functional/api/test_organizations.py +++ b/awx/main/tests/functional/api/test_organizations.py @@ -9,7 +9,7 @@ from django.conf import settings import pytest # AWX -from awx.main.models import ProjectUpdate +from awx.main.models import ProjectUpdate, CredentialType, Credential from awx.api.versioning import reverse @@ -288,3 +288,90 @@ def test_organization_delete_with_active_jobs(delete, admin, organization, organ assert resp.data['error'] == u"Resource is being used by running jobs." assert resp_sorted == expect_sorted + + +@pytest.mark.django_db +def test_galaxy_credential_association_forbidden(alice, organization, post): + galaxy = CredentialType.defaults['galaxy_api_token']() + galaxy.save() + + cred = Credential.objects.create( + credential_type=galaxy, + name='Public Galaxy', + organization=organization, + inputs={ + 'url': 'https://galaxy.ansible.com/' + } + ) + url = reverse('api:organization_galaxy_credentials_list', kwargs={'pk': organization.id}) + post( + url, + {'associate': True, 'id': cred.pk}, + user=alice, + expect=403 + ) + + +@pytest.mark.django_db +def test_galaxy_credential_type_enforcement(admin, organization, post): + ssh = CredentialType.defaults['ssh']() + ssh.save() + + cred = Credential.objects.create( + credential_type=ssh, + name='SSH Credential', + organization=organization, + ) + url = reverse('api:organization_galaxy_credentials_list', kwargs={'pk': organization.id}) + resp = post( + url, + {'associate': True, 'id': cred.pk}, + user=admin, + expect=400 + ) + assert resp.data['msg'] == 'Credential must be a Galaxy credential, not Machine.' + + +@pytest.mark.django_db +def test_galaxy_credential_association(alice, admin, organization, post, get): + galaxy = CredentialType.defaults['galaxy_api_token']() + galaxy.save() + + for i in range(5): + cred = Credential.objects.create( + credential_type=galaxy, + name=f'Public Galaxy {i + 1}', + organization=organization, + inputs={ + 'url': 'https://galaxy.ansible.com/' + } + ) + url = reverse('api:organization_galaxy_credentials_list', kwargs={'pk': organization.id}) + post( + url, + {'associate': True, 'id': cred.pk}, + user=admin, + expect=204 + ) + resp = get(url, user=admin) + assert [cred['name'] for cred in resp.data['results']] == [ + 'Public Galaxy 1', + 'Public Galaxy 2', + 'Public Galaxy 3', + 'Public Galaxy 4', + 'Public Galaxy 5', + ] + + post( + url, + {'disassociate': True, 'id': Credential.objects.get(name='Public Galaxy 3').pk}, + user=admin, + expect=204 + ) + resp = get(url, user=admin) + assert [cred['name'] for cred in resp.data['results']] == [ + 'Public Galaxy 1', + 'Public Galaxy 2', + 'Public Galaxy 4', + 'Public Galaxy 5', + ] diff --git a/awx/main/tests/functional/models/test_project.py b/awx/main/tests/functional/models/test_project.py index 2cf43c5690..d3c34498b0 100644 --- a/awx/main/tests/functional/models/test_project.py +++ b/awx/main/tests/functional/models/test_project.py @@ -1,7 +1,7 @@ import pytest from unittest import mock -from awx.main.models import Project +from awx.main.models import Project, Credential, CredentialType from awx.main.models.organization import Organization @@ -57,3 +57,31 @@ def test_foreign_key_change_changes_modified_by(project, organization): def test_project_related_jobs(project): update = project.create_unified_job() assert update.id in [u.id for u in project._get_related_jobs()] + + +@pytest.mark.django_db +def test_galaxy_credentials(project): + org = project.organization + galaxy = CredentialType.defaults['galaxy_api_token']() + galaxy.save() + for i in range(5): + cred = Credential.objects.create( + name=f'Ansible Galaxy {i + 1}', + organization=org, + credential_type=galaxy, + inputs={ + 'url': 'https://galaxy.ansible.com/' + } + ) + cred.save() + org.galaxy_credentials.add(cred) + + assert [ + cred.name for cred in org.galaxy_credentials.all() + ] == [ + 'Ansible Galaxy 1', + 'Ansible Galaxy 2', + 'Ansible Galaxy 3', + 'Ansible Galaxy 4', + 'Ansible Galaxy 5', + ] diff --git a/awx/main/tests/functional/test_credential.py b/awx/main/tests/functional/test_credential.py index 721bf5c043..684f9dd5a7 100644 --- a/awx/main/tests/functional/test_credential.py +++ b/awx/main/tests/functional/test_credential.py @@ -81,6 +81,7 @@ def test_default_cred_types(): 'azure_rm', 'cloudforms', 'conjur', + 'galaxy_api_token', 'gce', 'github_token', 'gitlab_token', diff --git a/awx/main/tests/unit/test_tasks.py b/awx/main/tests/unit/test_tasks.py index 5ce64894e6..a110b2a817 100644 --- a/awx/main/tests/unit/test_tasks.py +++ b/awx/main/tests/unit/test_tasks.py @@ -25,6 +25,7 @@ from awx.main.models import ( Job, JobTemplate, Notification, + Organization, Project, ProjectUpdate, UnifiedJob, @@ -59,6 +60,18 @@ def patch_Job(): yield +@pytest.fixture +def patch_Organization(): + _credentials = [] + credentials_mock = mock.Mock(**{ + 'all': lambda: _credentials, + 'add': _credentials.append, + 'spec_set': ['all', 'add'], + }) + with mock.patch.object(Organization, 'galaxy_credentials', credentials_mock): + yield + + @pytest.fixture def job(): return Job( @@ -131,7 +144,6 @@ def test_send_notifications_list(mock_notifications_filter, mock_job_get, mocker ('SECRET_KEY', 'SECRET'), ('VMWARE_PASSWORD', 'SECRET'), ('API_SECRET', 'SECRET'), - ('ANSIBLE_GALAXY_SERVER_PRIMARY_GALAXY_PASSWORD', 'SECRET'), ('ANSIBLE_GALAXY_SERVER_PRIMARY_GALAXY_TOKEN', 'SECRET'), ]) def test_safe_env_filtering(key, value): @@ -1780,10 +1792,89 @@ class TestJobCredentials(TestJobExecution): assert env['FOO'] == 'BAR' +@pytest.mark.usefixtures("patch_Organization") +class TestProjectUpdateGalaxyCredentials(TestJobExecution): + + @pytest.fixture + def project_update(self): + org = Organization(pk=1) + proj = Project(pk=1, organization=org) + project_update = ProjectUpdate(pk=1, project=proj) + project_update.websocket_emit_status = mock.Mock() + return project_update + + parametrize = { + 'test_galaxy_credentials_ignore_certs': [ + dict(ignore=True), + dict(ignore=False), + ], + } + + def test_galaxy_credentials_ignore_certs(self, private_data_dir, project_update, ignore): + settings.GALAXY_IGNORE_CERTS = ignore + task = tasks.RunProjectUpdate() + env = task.build_env(project_update, private_data_dir) + if ignore: + assert env['ANSIBLE_GALAXY_IGNORE'] is True + else: + assert 'ANSIBLE_GALAXY_IGNORE' not in env + + def test_galaxy_credentials_empty(self, private_data_dir, project_update): + task = tasks.RunProjectUpdate() + env = task.build_env(project_update, private_data_dir) + for k in env: + assert not k.startswith('ANSIBLE_GALAXY_SERVER') + + def test_single_public_galaxy(self, private_data_dir, project_update): + credential_type = CredentialType.defaults['galaxy_api_token']() + public_galaxy = Credential(pk=1, credential_type=credential_type, inputs={ + 'url': 'https://galaxy.ansible.com/', + }) + project_update.project.organization.galaxy_credentials.add(public_galaxy) + task = tasks.RunProjectUpdate() + env = task.build_env(project_update, private_data_dir) + assert sorted([ + (k, v) for k, v in env.items() + if k.startswith('ANSIBLE_GALAXY') + ]) == [ + ('ANSIBLE_GALAXY_SERVER_LIST', 'server0'), + ('ANSIBLE_GALAXY_SERVER_SERVER0_URL', 'https://galaxy.ansible.com/'), + ] + + def test_multiple_galaxy_endpoints(self, private_data_dir, project_update): + credential_type = CredentialType.defaults['galaxy_api_token']() + public_galaxy = Credential(pk=1, credential_type=credential_type, inputs={ + 'url': 'https://galaxy.ansible.com/', + }) + rh = Credential(pk=2, credential_type=credential_type, inputs={ + 'url': 'https://cloud.redhat.com/api/automation-hub/', + 'auth_url': 'https://sso.redhat.com/example/openid-connect/token/', + 'token': 'secret123' + }) + project_update.project.organization.galaxy_credentials.add(public_galaxy) + project_update.project.organization.galaxy_credentials.add(rh) + task = tasks.RunProjectUpdate() + env = task.build_env(project_update, private_data_dir) + assert sorted([ + (k, v) for k, v in env.items() + if k.startswith('ANSIBLE_GALAXY') + ]) == [ + ('ANSIBLE_GALAXY_SERVER_LIST', 'server0,server1'), + ('ANSIBLE_GALAXY_SERVER_SERVER0_URL', 'https://galaxy.ansible.com/'), + ('ANSIBLE_GALAXY_SERVER_SERVER1_AUTH_URL', 'https://sso.redhat.com/example/openid-connect/token/'), # noqa + ('ANSIBLE_GALAXY_SERVER_SERVER1_TOKEN', 'secret123'), + ('ANSIBLE_GALAXY_SERVER_SERVER1_URL', 'https://cloud.redhat.com/api/automation-hub/'), + ] + + +@pytest.mark.usefixtures("patch_Organization") class TestProjectUpdateCredentials(TestJobExecution): @pytest.fixture def project_update(self): - project_update = ProjectUpdate(pk=1, project=Project(pk=1)) + project_update = ProjectUpdate( + pk=1, + project=Project(pk=1, organization=Organization(pk=1)), + ) project_update.websocket_emit_status = mock.Mock() return project_update diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index 5aa0b834ea..c854fe9b98 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -567,28 +567,9 @@ AWX_COLLECTIONS_ENABLED = True # Follow symlinks when scanning for playbooks AWX_SHOW_PLAYBOOK_LINKS = False -# Settings for primary galaxy server, should be set in the UI -PRIMARY_GALAXY_URL = '' -PRIMARY_GALAXY_USERNAME = '' -PRIMARY_GALAXY_TOKEN = '' -PRIMARY_GALAXY_PASSWORD = '' -PRIMARY_GALAXY_AUTH_URL = '' - -# Settings for the public galaxy server(s). -PUBLIC_GALAXY_ENABLED = True -PUBLIC_GALAXY_SERVER = { - 'id': 'galaxy', - 'url': 'https://galaxy.ansible.com' -} - # Applies to any galaxy server GALAXY_IGNORE_CERTS = False -# List of dicts of fallback (additional) Galaxy servers. If configured, these -# will be higher precedence than public Galaxy, but lower than primary Galaxy. -# Available options: 'id', 'url', 'username', 'password', 'token', 'auth_url' -FALLBACK_GALAXY_SERVERS = [] - # Enable bubblewrap support for running jobs (playbook runs only). # Note: This setting may be overridden by database settings. AWX_PROOT_ENABLED = True diff --git a/awx/ui/client/src/configuration/forms/jobs-form/configuration-jobs.form.js b/awx/ui/client/src/configuration/forms/jobs-form/configuration-jobs.form.js index faff8e2366..0f40144d4f 100644 --- a/awx/ui/client/src/configuration/forms/jobs-form/configuration-jobs.form.js +++ b/awx/ui/client/src/configuration/forms/jobs-form/configuration-jobs.form.js @@ -74,31 +74,6 @@ export default ['i18n', function(i18n) { AWX_SHOW_PLAYBOOK_LINKS: { type: 'toggleSwitch', }, - PRIMARY_GALAXY_URL: { - type: 'text', - reset: 'PRIMARY_GALAXY_URL', - }, - PRIMARY_GALAXY_USERNAME: { - type: 'text', - reset: 'PRIMARY_GALAXY_USERNAME', - }, - PRIMARY_GALAXY_PASSWORD: { - type: 'sensitive', - hasShowInputButton: true, - reset: 'PRIMARY_GALAXY_PASSWORD', - }, - PRIMARY_GALAXY_TOKEN: { - type: 'sensitive', - hasShowInputButton: true, - reset: 'PRIMARY_GALAXY_TOKEN', - }, - PRIMARY_GALAXY_AUTH_URL: { - type: 'text', - reset: 'PRIMARY_GALAXY_AUTH_URL', - }, - PUBLIC_GALAXY_ENABLED: { - type: 'toggleSwitch', - }, AWX_TASK_ENV: { type: 'textarea', reset: 'AWX_TASK_ENV', diff --git a/docs/collections.md b/docs/collections.md index 68c11950f8..0bb90feaa6 100644 --- a/docs/collections.md +++ b/docs/collections.md @@ -85,28 +85,22 @@ job runs. It will populate the cache id set by the last "check" type update. ### Galaxy Server Selection -Ansible core default settings will download collections from the public -Galaxy server at `https://galaxy.ansible.com`. For details on -how Galaxy servers are configured in Ansible in general see: +For details on how Galaxy servers are configured in Ansible in general see: https://docs.ansible.com/ansible/devel/user_guide/collections_using.html (if "devel" link goes stale in the future, it is for Ansible 2.9) -You can set a different server to be the primary Galaxy server to download -roles and collections from in AWX project updates. -This is done via the setting `PRIMARY_GALAXY_URL` and similar -`PRIMARY_GALAXY_xxxx` settings for authentication. +You can specify a list of zero or more servers to download roles and +collections from for AWX Project Updates. This is done by associating Galaxy +credentials (in sequential order) via the API at +`/api/v2/organizations/N/galaxy_credentials/`. Authentication +via an API token is optional (i.e., https://galaxy.ansible.com/), but other +content sources (such as Red Hat Ansible Automation Hub) require proper +configuration of the Auth URL and Token. -If the `PRIMARY_GALAXY_URL` setting is not blank, then the server list is defined -to be `primary_galaxy,galaxy`. The `primary_galaxy` server definition uses the URL -from those settings, as well as username, password, and/or token and auth_url if applicable. -the `galaxy` server definition uses public Galaxy (`https://galaxy.ansible.com`) -with no authentication. - -This configuration causes requirements to be downloaded from the user-specified -primary galaxy server if they are available there. If a requirement is -not available from the primary galaxy server, then it will fallback to -downloading it from the public Galaxy server. +If no credentials are defined at this endpoint for an Organization, then roles and +collections will *not* be installed based on requirements.yml for Project Updates +in that Organization. Even when these settings are enabled, this can still be bypassed for a specific requirement by using the `source:` option, as described in Ansible documentation. From 130e279012c83ea56012aec640c2328158afdbf3 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Tue, 4 Aug 2020 12:35:32 -0400 Subject: [PATCH 02/14] add a data migration for Galaxy credentials see: https://github.com/ansible/awx/issues/7813 --- .../migrations/0118_galaxy_credentials.py | 81 +++++++++++++++++++ awx/main/models/credential/__init__.py | 4 +- 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/awx/main/migrations/0118_galaxy_credentials.py b/awx/main/migrations/0118_galaxy_credentials.py index f61434d7d1..c46fd962a7 100644 --- a/awx/main/migrations/0118_galaxy_credentials.py +++ b/awx/main/migrations/0118_galaxy_credentials.py @@ -1,9 +1,89 @@ # Generated by Django 2.2.11 on 2020-08-04 15:19 +import logging + import awx.main.fields +from awx.main.utils.encryption import encrypt_field, decrypt_field + from django.db import migrations, models +from django.utils.timezone import now import django.db.models.deletion +from awx.main.models import CredentialType as ModernCredentialType +from awx.main.utils.common import set_current_apps + +logger = logging.getLogger('awx.main.migrations') + + +def migrate_galaxy_settings(apps, schema_editor): + set_current_apps(apps) + ModernCredentialType.setup_tower_managed_defaults() + Organization = apps.get_model('main', 'Organization') + CredentialType = apps.get_model('main', 'CredentialType') + Credential = apps.get_model('main', 'Credential') + Setting = apps.get_model('conf', 'Setting') + + galaxy_type = CredentialType.objects.get(kind='galaxy') + private_galaxy_url = Setting.objects.filter(key='PRIMARY_GALAXY_URL').first() + + # by default, prior versions of AWX/Tower automatically pulled content + # from galaxy.ansible.com + public_galaxy_enabled = True + public_galaxy_setting = Setting.objects.filter(key='PUBLIC_GALAXY_ENABLED').first() + if public_galaxy_setting and public_galaxy_setting is False: + # ...UNLESS this behavior was explicitly disabled via this setting + public_galaxy_enabled = False + + for org in Organization.objects.all(): + if private_galaxy_url and private_galaxy_url.value: + # If a setting exists for a private Galaxy URL, make a credential for it + username = Setting.objects.filter(key='PRIMARY_GALAXY_USERNAME').first() + password = Setting.objects.filter(key='PRIMARY_GALAXY_PASSWORD').first() + if (username and username.value) or (password and password.value): + logger.error( + f'Specifying HTTP basic auth for the Ansible Galaxy API ' + f'({private_galaxy_url.value}) is no longer supported. ' + 'Please provide an API token instead after your upgrade ' + 'has completed', + ) + inputs = { + 'url': private_galaxy_url.value + } + token = Setting.objects.filter(key='PRIMARY_GALAXY_TOKEN').first() + if token and token.value: + inputs['token'] = decrypt_field(token, 'value') + auth_url = Setting.objects.filter(key='PRIMARY_GALAXY_AUTH_URL').first() + if auth_url and auth_url.value: + inputs['auth_url'] = auth_url.value + cred = Credential( + created=now(), + modified=now(), + name=f'Private Galaxy ({private_galaxy_url.value})', + organization=org, + credential_type=galaxy_type, + inputs=inputs + ) + cred.save() + if token and token.value: + # encrypt based on the primary key from the prior save + cred.inputs['token'] = encrypt_field(cred, 'token') + cred.save() + org.galaxy_credentials.add(cred) + if public_galaxy_enabled: + # If public Galaxy was enabled, make a credential for it + cred = Credential( + created=now(), + modified=now(), + name='Ansible Galaxy', + organization=org, + credential_type=galaxy_type, + inputs = { + 'url': 'https://galaxy.ansible.com/' + } + ) + cred.save() + org.galaxy_credentials.add(cred) + class Migration(migrations.Migration): @@ -31,4 +111,5 @@ class Migration(migrations.Migration): name='galaxy_credentials', field=awx.main.fields.OrderedManyToManyField(blank=True, related_name='organization_galaxy_credentials', through='main.OrganizationGalaxyCredentialMembership', to='main.Credential'), ), + migrations.RunPython(migrate_galaxy_settings) ] diff --git a/awx/main/models/credential/__init__.py b/awx/main/models/credential/__init__.py index be4a21a99d..9756fd1639 100644 --- a/awx/main/models/credential/__init__.py +++ b/awx/main/models/credential/__init__.py @@ -1177,13 +1177,13 @@ ManagedCredentialType( ManagedCredentialType( namespace='galaxy_api_token', kind='galaxy', - name=ugettext_noop('Ansible Galaxy Automation Hub API Token'), + name=ugettext_noop('Ansible Galaxy/Automation Hub API Token'), inputs={ 'fields': [{ 'id': 'url', 'label': ugettext_noop('Galaxy Server URL'), 'type': 'string', - 'help_text': ugettext_noop('The URL of the galaxy instance to connect to.') + 'help_text': ugettext_noop('The URL of the Galaxy instance to connect to.') },{ 'id': 'auth_url', 'label': ugettext_noop('Auth Server URL'), From 25a9a9c3ba1332a8f6aea713dd8693c852805a68 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Tue, 4 Aug 2020 14:24:25 -0400 Subject: [PATCH 03/14] remove redaction exclusions for Galaxy URLs (basic auth support is gone) --- .../migrations/0118_galaxy_credentials.py | 73 +--------------- awx/main/migrations/_galaxy.py | 85 +++++++++++++++++++ awx/main/redact.py | 12 --- .../test_galaxy_credential_migration.py | 78 +++++++++++++++++ 4 files changed, 165 insertions(+), 83 deletions(-) create mode 100644 awx/main/migrations/_galaxy.py create mode 100644 awx/main/tests/functional/test_galaxy_credential_migration.py diff --git a/awx/main/migrations/0118_galaxy_credentials.py b/awx/main/migrations/0118_galaxy_credentials.py index c46fd962a7..d47f966fec 100644 --- a/awx/main/migrations/0118_galaxy_credentials.py +++ b/awx/main/migrations/0118_galaxy_credentials.py @@ -9,82 +9,13 @@ from django.db import migrations, models from django.utils.timezone import now import django.db.models.deletion +from awx.main.migrations import _galaxy as galaxy from awx.main.models import CredentialType as ModernCredentialType from awx.main.utils.common import set_current_apps logger = logging.getLogger('awx.main.migrations') -def migrate_galaxy_settings(apps, schema_editor): - set_current_apps(apps) - ModernCredentialType.setup_tower_managed_defaults() - Organization = apps.get_model('main', 'Organization') - CredentialType = apps.get_model('main', 'CredentialType') - Credential = apps.get_model('main', 'Credential') - Setting = apps.get_model('conf', 'Setting') - - galaxy_type = CredentialType.objects.get(kind='galaxy') - private_galaxy_url = Setting.objects.filter(key='PRIMARY_GALAXY_URL').first() - - # by default, prior versions of AWX/Tower automatically pulled content - # from galaxy.ansible.com - public_galaxy_enabled = True - public_galaxy_setting = Setting.objects.filter(key='PUBLIC_GALAXY_ENABLED').first() - if public_galaxy_setting and public_galaxy_setting is False: - # ...UNLESS this behavior was explicitly disabled via this setting - public_galaxy_enabled = False - - for org in Organization.objects.all(): - if private_galaxy_url and private_galaxy_url.value: - # If a setting exists for a private Galaxy URL, make a credential for it - username = Setting.objects.filter(key='PRIMARY_GALAXY_USERNAME').first() - password = Setting.objects.filter(key='PRIMARY_GALAXY_PASSWORD').first() - if (username and username.value) or (password and password.value): - logger.error( - f'Specifying HTTP basic auth for the Ansible Galaxy API ' - f'({private_galaxy_url.value}) is no longer supported. ' - 'Please provide an API token instead after your upgrade ' - 'has completed', - ) - inputs = { - 'url': private_galaxy_url.value - } - token = Setting.objects.filter(key='PRIMARY_GALAXY_TOKEN').first() - if token and token.value: - inputs['token'] = decrypt_field(token, 'value') - auth_url = Setting.objects.filter(key='PRIMARY_GALAXY_AUTH_URL').first() - if auth_url and auth_url.value: - inputs['auth_url'] = auth_url.value - cred = Credential( - created=now(), - modified=now(), - name=f'Private Galaxy ({private_galaxy_url.value})', - organization=org, - credential_type=galaxy_type, - inputs=inputs - ) - cred.save() - if token and token.value: - # encrypt based on the primary key from the prior save - cred.inputs['token'] = encrypt_field(cred, 'token') - cred.save() - org.galaxy_credentials.add(cred) - if public_galaxy_enabled: - # If public Galaxy was enabled, make a credential for it - cred = Credential( - created=now(), - modified=now(), - name='Ansible Galaxy', - organization=org, - credential_type=galaxy_type, - inputs = { - 'url': 'https://galaxy.ansible.com/' - } - ) - cred.save() - org.galaxy_credentials.add(cred) - - class Migration(migrations.Migration): dependencies = [ @@ -111,5 +42,5 @@ class Migration(migrations.Migration): name='galaxy_credentials', field=awx.main.fields.OrderedManyToManyField(blank=True, related_name='organization_galaxy_credentials', through='main.OrganizationGalaxyCredentialMembership', to='main.Credential'), ), - migrations.RunPython(migrate_galaxy_settings) + migrations.RunPython(galaxy.migrate_galaxy_settings) ] diff --git a/awx/main/migrations/_galaxy.py b/awx/main/migrations/_galaxy.py new file mode 100644 index 0000000000..2f6cfc25fd --- /dev/null +++ b/awx/main/migrations/_galaxy.py @@ -0,0 +1,85 @@ +# Generated by Django 2.2.11 on 2020-08-04 15:19 + +import logging + +from awx.main.utils.encryption import encrypt_field, decrypt_field + +from django.utils.timezone import now + +from awx.main.models import CredentialType as ModernCredentialType +from awx.main.utils.common import set_current_apps + +logger = logging.getLogger('awx.main.migrations') + + +def migrate_galaxy_settings(apps, schema_editor): + set_current_apps(apps) + ModernCredentialType.setup_tower_managed_defaults() + Organization = apps.get_model('main', 'Organization') + CredentialType = apps.get_model('main', 'CredentialType') + Credential = apps.get_model('main', 'Credential') + Setting = apps.get_model('conf', 'Setting') + + galaxy_type = CredentialType.objects.get(kind='galaxy') + private_galaxy_url = Setting.objects.filter(key='PRIMARY_GALAXY_URL').first() + + # by default, prior versions of AWX/Tower automatically pulled content + # from galaxy.ansible.com + public_galaxy_enabled = True + public_galaxy_setting = Setting.objects.filter(key='PUBLIC_GALAXY_ENABLED').first() + if public_galaxy_setting and public_galaxy_setting.value is False: + # ...UNLESS this behavior was explicitly disabled via this setting + public_galaxy_enabled = False + + for org in Organization.objects.all(): + if private_galaxy_url and private_galaxy_url.value: + # If a setting exists for a private Galaxy URL, make a credential for it + username = Setting.objects.filter(key='PRIMARY_GALAXY_USERNAME').first() + password = Setting.objects.filter(key='PRIMARY_GALAXY_PASSWORD').first() + if (username and username.value) or (password and password.value): + logger.error( + f'Specifying HTTP basic auth for the Ansible Galaxy API ' + f'({private_galaxy_url.value}) is no longer supported. ' + 'Please provide an API token instead after your upgrade ' + 'has completed', + ) + inputs = { + 'url': private_galaxy_url.value + } + token = Setting.objects.filter(key='PRIMARY_GALAXY_TOKEN').first() + if token and token.value: + inputs['token'] = decrypt_field(token, 'value') + auth_url = Setting.objects.filter(key='PRIMARY_GALAXY_AUTH_URL').first() + if auth_url and auth_url.value: + inputs['auth_url'] = auth_url.value + name = f'Private Galaxy ({private_galaxy_url.value})' + if 'cloud.redhat.com' in inputs['url']: + name = f'Ansible Automation Hub ({private_galaxy_url.value})' + cred = Credential( + created=now(), + modified=now(), + name=name, + organization=org, + credential_type=galaxy_type, + inputs=inputs + ) + cred.save() + if token and token.value: + # encrypt based on the primary key from the prior save + cred.inputs['token'] = encrypt_field(cred, 'token') + cred.save() + org.galaxy_credentials.add(cred) + if public_galaxy_enabled: + # If public Galaxy was enabled, make a credential for it + cred = Credential( + created=now(), + modified=now(), + name='Ansible Galaxy', + organization=org, + credential_type=galaxy_type, + inputs = { + 'url': 'https://galaxy.ansible.com/' + } + ) + cred.save() + org.galaxy_credentials.add(cred) diff --git a/awx/main/redact.py b/awx/main/redact.py index c0e2365941..32899d935e 100644 --- a/awx/main/redact.py +++ b/awx/main/redact.py @@ -10,14 +10,6 @@ class UriCleaner(object): @staticmethod def remove_sensitive(cleartext): - # exclude_list contains the items that will _not_ be redacted - # TODO: replace this with the server URLs from proj.org.credentials - exclude_list = [] - #exclude_list = [settings.PUBLIC_GALAXY_SERVER['url']] - #if settings.PRIMARY_GALAXY_URL: - # exclude_list += [settings.PRIMARY_GALAXY_URL] - #if settings.FALLBACK_GALAXY_SERVERS: - # exclude_list += [server['url'] for server in settings.FALLBACK_GALAXY_SERVERS] redactedtext = cleartext text_index = 0 while True: @@ -25,10 +17,6 @@ class UriCleaner(object): if not match: break uri_str = match.group(1) - # Do not redact items from the exclude list - if any(uri_str.startswith(exclude_uri) for exclude_uri in exclude_list): - text_index = match.start() + len(uri_str) - continue try: # May raise a ValueError if invalid URI for one reason or another o = urlparse.urlsplit(uri_str) diff --git a/awx/main/tests/functional/test_galaxy_credential_migration.py b/awx/main/tests/functional/test_galaxy_credential_migration.py new file mode 100644 index 0000000000..7c1c89c202 --- /dev/null +++ b/awx/main/tests/functional/test_galaxy_credential_migration.py @@ -0,0 +1,78 @@ +import importlib + +from django.contrib.contenttypes.models import ContentType +import pytest + +from awx.main.models import Organization +from awx.conf.models import Setting +from awx.main.migrations import _galaxy as galaxy + + +class FakeApps(object): + def get_model(self, app, model): + if app == 'contenttypes': + return ContentType + return getattr(importlib.import_module(f'awx.{app}.models'), model) + + +apps = FakeApps() + + +@pytest.mark.django_db +def test_default_public_galaxy(): + org = Organization.objects.create() + assert org.galaxy_credentials.count() == 0 + galaxy.migrate_galaxy_settings(apps, None) + assert org.galaxy_credentials.count() == 1 + creds = org.galaxy_credentials.all() + assert creds[0].name == 'Ansible Galaxy' + assert creds[0].inputs['url'] == 'https://galaxy.ansible.com/' + + +@pytest.mark.django_db +def test_public_galaxy_disabled(): + Setting.objects.create(key='PUBLIC_GALAXY_ENABLED', value=False) + org = Organization.objects.create() + assert org.galaxy_credentials.count() == 0 + galaxy.migrate_galaxy_settings(apps, None) + assert org.galaxy_credentials.count() == 0 + + +@pytest.mark.django_db +def test_rh_automation_hub(): + Setting.objects.create(key='PRIMARY_GALAXY_URL', value='https://cloud.redhat.com/api/automation-hub/') + Setting.objects.create(key='PRIMARY_GALAXY_TOKEN', value='secret123') + org = Organization.objects.create() + assert org.galaxy_credentials.count() == 0 + galaxy.migrate_galaxy_settings(apps, None) + assert org.galaxy_credentials.count() == 2 + assert org.galaxy_credentials.first().name == 'Ansible Automation Hub (https://cloud.redhat.com/api/automation-hub/)' # noqa + + +@pytest.mark.django_db +def test_multiple_galaxies(): + for i in range(5): + Organization.objects.create(name=f'Org {i}') + + Setting.objects.create(key='PRIMARY_GALAXY_URL', value='https://example.org/') + Setting.objects.create(key='PRIMARY_GALAXY_AUTH_URL', value='https://auth.example.org/') + Setting.objects.create(key='PRIMARY_GALAXY_USERNAME', value='user') + Setting.objects.create(key='PRIMARY_GALAXY_PASSWORD', value='pass') + Setting.objects.create(key='PRIMARY_GALAXY_TOKEN', value='secret123') + + for org in Organization.objects.all(): + assert org.galaxy_credentials.count() == 0 + + galaxy.migrate_galaxy_settings(apps, None) + + for org in Organization.objects.all(): + assert org.galaxy_credentials.count() == 2 + creds = org.galaxy_credentials.all() + assert creds[0].name == 'Private Galaxy (https://example.org/)' + assert creds[0].inputs['url'] == 'https://example.org/' + assert creds[0].inputs['auth_url'] == 'https://auth.example.org/' + assert creds[0].inputs['token'].startswith('$encrypted$') + assert creds[0].get_input('token') == 'secret123' + + assert creds[1].name == 'Ansible Galaxy' + assert creds[1].inputs['url'] == 'https://galaxy.ansible.com/' From 1b4dd7c7837e798b484d558e26b6ee03da583fba Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Tue, 4 Aug 2020 17:29:44 -0400 Subject: [PATCH 04/14] enforce Organization ownership of Galaxy credentials --- awx/api/serializers.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index cd47bcc4c1..360647b60a 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -2611,6 +2611,17 @@ class CredentialSerializer(BaseSerializer): ret.remove(field) return ret + def validate_organization(self, org): + if ( + self.instance and + self.instance.credential_type.kind == 'galaxy' and + org is None + ): + raise serializers.ValidationError(_( + "Galaxy credentials must be owned by an Organization." + )) + return org + def validate_credential_type(self, credential_type): if self.instance and credential_type.pk != self.instance.credential_type.pk: for related_objects in ( @@ -2675,6 +2686,14 @@ class CredentialSerializerCreate(CredentialSerializer): if attrs.get('team'): attrs['organization'] = attrs['team'].organization + if ( + attrs['credential_type'].kind == 'galaxy' and + list(owner_fields) != ['organization'] + ): + raise serializers.ValidationError({"organization": _( + "Galaxy credentials must be owned by an Organization." + )}) + return super(CredentialSerializerCreate, self).validate(attrs) def create(self, validated_data): From e5552b547b3950fe03f58a92273d0daa8237fbfc Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Tue, 4 Aug 2020 18:34:18 -0400 Subject: [PATCH 05/14] properly migrate settings.FALLBACK_GALAXY_SERVERS --- awx/main/migrations/_galaxy.py | 36 +++++++++++++++++++ .../test_galaxy_credential_migration.py | 33 +++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/awx/main/migrations/_galaxy.py b/awx/main/migrations/_galaxy.py index 2f6cfc25fd..55166585d9 100644 --- a/awx/main/migrations/_galaxy.py +++ b/awx/main/migrations/_galaxy.py @@ -4,6 +4,7 @@ import logging from awx.main.utils.encryption import encrypt_field, decrypt_field +from django.conf import settings from django.utils.timezone import now from awx.main.models import CredentialType as ModernCredentialType @@ -69,6 +70,41 @@ def migrate_galaxy_settings(apps, schema_editor): cred.inputs['token'] = encrypt_field(cred, 'token') cred.save() org.galaxy_credentials.add(cred) + + fallback_servers = getattr(settings, 'FALLBACK_GALAXY_SERVERS', []) + for fallback in fallback_servers: + url = fallback.get('url', None) + auth_url = fallback.get('auth_url', None) + username = fallback.get('username', None) + password = fallback.get('password', None) + token = fallback.get('token', None) + if username or password: + logger.error( + f'Specifying HTTP basic auth for the Ansible Galaxy API ' + f'({url}) is no longer supported. ' + 'Please provide an API token instead after your upgrade ' + 'has completed', + ) + inputs = {'url': url} + if token: + inputs['token'] = token + if auth_url: + inputs['auth_url'] = auth_url + cred = Credential( + created=now(), + modified=now(), + name=f'Ansible Galaxy ({url})', + organization=org, + credential_type=galaxy_type, + inputs=inputs + ) + cred.save() + if token: + # encrypt based on the primary key from the prior save + cred.inputs['token'] = encrypt_field(cred, 'token') + cred.save() + org.galaxy_credentials.add(cred) + if public_galaxy_enabled: # If public Galaxy was enabled, make a credential for it cred = Credential( diff --git a/awx/main/tests/functional/test_galaxy_credential_migration.py b/awx/main/tests/functional/test_galaxy_credential_migration.py index 7c1c89c202..1cf008b193 100644 --- a/awx/main/tests/functional/test_galaxy_credential_migration.py +++ b/awx/main/tests/functional/test_galaxy_credential_migration.py @@ -1,5 +1,6 @@ import importlib +from django.conf import settings from django.contrib.contenttypes.models import ContentType import pytest @@ -76,3 +77,35 @@ def test_multiple_galaxies(): assert creds[1].name == 'Ansible Galaxy' assert creds[1].inputs['url'] == 'https://galaxy.ansible.com/' + + +@pytest.mark.django_db +def test_fallback_galaxies(): + org = Organization.objects.create() + assert org.galaxy_credentials.count() == 0 + Setting.objects.create(key='PRIMARY_GALAXY_URL', value='https://example.org/') + Setting.objects.create(key='PRIMARY_GALAXY_AUTH_URL', value='https://auth.example.org/') + Setting.objects.create(key='PRIMARY_GALAXY_TOKEN', value='secret123') + try: + settings.FALLBACK_GALAXY_SERVERS = [{ + 'id': 'abc123', + 'url': 'https://some-other-galaxy.example.org/', + 'auth_url': 'https://some-other-galaxy.sso.example.org/', + 'username': 'user', + 'password': 'pass', + 'token': 'fallback123', + }] + galaxy.migrate_galaxy_settings(apps, None) + finally: + settings.FALLBACK_GALAXY_SERVERS = [] + assert org.galaxy_credentials.count() == 3 + creds = org.galaxy_credentials.all() + assert creds[0].name == 'Private Galaxy (https://example.org/)' + assert creds[0].inputs['url'] == 'https://example.org/' + assert creds[1].name == 'Ansible Galaxy (https://some-other-galaxy.example.org/)' + assert creds[1].inputs['url'] == 'https://some-other-galaxy.example.org/' + assert creds[1].inputs['auth_url'] == 'https://some-other-galaxy.sso.example.org/' + assert creds[1].inputs['token'].startswith('$encrypted$') + assert creds[1].get_input('token') == 'fallback123' + assert creds[2].name == 'Ansible Galaxy' + assert creds[2].inputs['url'] == 'https://galaxy.ansible.com/' From 011822b1f03c88bb4e709117b90e0bfe30180c2b Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Wed, 5 Aug 2020 07:57:27 -0400 Subject: [PATCH 06/14] make a global "managed by AWX/Tower" Credential to represent Galaxy --- awx/api/serializers.py | 10 ++++++- awx/api/views/__init__.py | 7 +++++ awx/main/access.py | 7 ++--- .../migrations/0118_galaxy_credentials.py | 5 ++++ awx/main/migrations/_galaxy.py | 27 ++++++++++--------- awx/main/models/credential/__init__.py | 4 +++ .../test_galaxy_credential_migration.py | 6 ++++- 7 files changed, 46 insertions(+), 20 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 360647b60a..1299ba7aee 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -2537,10 +2537,11 @@ class CredentialTypeSerializer(BaseSerializer): class CredentialSerializer(BaseSerializer): show_capabilities = ['edit', 'delete', 'copy', 'use'] capabilities_prefetch = ['admin', 'use'] + managed_by_tower = serializers.ReadOnlyField() class Meta: model = Credential - fields = ('*', 'organization', 'credential_type', 'inputs', 'kind', 'cloud', 'kubernetes') + fields = ('*', 'organization', 'credential_type', 'managed_by_tower', 'inputs', 'kind', 'cloud', 'kubernetes') extra_kwargs = { 'credential_type': { 'label': _('Credential Type'), @@ -2604,6 +2605,13 @@ class CredentialSerializer(BaseSerializer): return summary_dict + def validate(self, attrs): + if self.instance and self.instance.managed_by_tower: + raise PermissionDenied( + detail=_("Modifications not allowed for managed credentials") + ) + return super(CredentialSerializer, self).validate(attrs) + def get_validation_exclusions(self, obj=None): ret = super(CredentialSerializer, self).get_validation_exclusions(obj) for field in ('credential_type', 'inputs'): diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 0bb709fce3..4f436c8f0e 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -1356,6 +1356,13 @@ class CredentialDetail(RetrieveUpdateDestroyAPIView): model = models.Credential serializer_class = serializers.CredentialSerializer + def destroy(self, request, *args, **kwargs): + instance = self.get_object() + if instance.managed_by_tower: + raise PermissionDenied(detail=_("Deletion not allowed for managed credentials")) + return super(CredentialDetail, self).destroy(request, *args, **kwargs) + + class CredentialActivityStreamList(SubListAPIView): diff --git a/awx/main/access.py b/awx/main/access.py index 4f54be6e12..8c4d162a7f 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -1103,11 +1103,6 @@ class CredentialTypeAccess(BaseAccess): def can_use(self, obj): return True - def get_method_capability(self, method, obj, parent_obj): - if obj.managed_by_tower: - return False - return super(CredentialTypeAccess, self).get_method_capability(method, obj, parent_obj) - def filtered_queryset(self): return self.model.objects.all() @@ -1182,6 +1177,8 @@ class CredentialAccess(BaseAccess): def get_user_capabilities(self, obj, **kwargs): user_capabilities = super(CredentialAccess, self).get_user_capabilities(obj, **kwargs) user_capabilities['use'] = self.can_use(obj) + if getattr(obj, 'managed_by_tower', False) is True: + user_capabilities['edit'] = user_capabilities['delete'] = False return user_capabilities diff --git a/awx/main/migrations/0118_galaxy_credentials.py b/awx/main/migrations/0118_galaxy_credentials.py index d47f966fec..c469141767 100644 --- a/awx/main/migrations/0118_galaxy_credentials.py +++ b/awx/main/migrations/0118_galaxy_credentials.py @@ -42,5 +42,10 @@ class Migration(migrations.Migration): name='galaxy_credentials', field=awx.main.fields.OrderedManyToManyField(blank=True, related_name='organization_galaxy_credentials', through='main.OrganizationGalaxyCredentialMembership', to='main.Credential'), ), + migrations.AddField( + model_name='credential', + name='managed_by_tower', + field=models.BooleanField(default=False, editable=False), + ), migrations.RunPython(galaxy.migrate_galaxy_settings) ] diff --git a/awx/main/migrations/_galaxy.py b/awx/main/migrations/_galaxy.py index 55166585d9..6341ee640c 100644 --- a/awx/main/migrations/_galaxy.py +++ b/awx/main/migrations/_galaxy.py @@ -32,6 +32,18 @@ def migrate_galaxy_settings(apps, schema_editor): # ...UNLESS this behavior was explicitly disabled via this setting public_galaxy_enabled = False + public_galaxy_credential = Credential( + created=now(), + modified=now(), + name='Ansible Galaxy', + managed_by_tower=True, + credential_type=galaxy_type, + inputs = { + 'url': 'https://galaxy.ansible.com/' + } + ) + public_galaxy_credential.save() + for org in Organization.objects.all(): if private_galaxy_url and private_galaxy_url.value: # If a setting exists for a private Galaxy URL, make a credential for it @@ -106,16 +118,5 @@ def migrate_galaxy_settings(apps, schema_editor): org.galaxy_credentials.add(cred) if public_galaxy_enabled: - # If public Galaxy was enabled, make a credential for it - cred = Credential( - created=now(), - modified=now(), - name='Ansible Galaxy', - organization=org, - credential_type=galaxy_type, - inputs = { - 'url': 'https://galaxy.ansible.com/' - } - ) - cred.save() - org.galaxy_credentials.add(cred) + # If public Galaxy was enabled, associate it to the org + org.galaxy_credentials.add(public_galaxy_credential) diff --git a/awx/main/models/credential/__init__.py b/awx/main/models/credential/__init__.py index 9756fd1639..df12177aae 100644 --- a/awx/main/models/credential/__init__.py +++ b/awx/main/models/credential/__init__.py @@ -96,6 +96,10 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin): help_text=_('Specify the type of credential you want to create. Refer ' 'to the Ansible Tower documentation for details on each type.') ) + managed_by_tower = models.BooleanField( + default=False, + editable=False + ) organization = models.ForeignKey( 'Organization', null=True, diff --git a/awx/main/tests/functional/test_galaxy_credential_migration.py b/awx/main/tests/functional/test_galaxy_credential_migration.py index 1cf008b193..110628e19c 100644 --- a/awx/main/tests/functional/test_galaxy_credential_migration.py +++ b/awx/main/tests/functional/test_galaxy_credential_migration.py @@ -4,7 +4,7 @@ from django.conf import settings from django.contrib.contenttypes.models import ContentType import pytest -from awx.main.models import Organization +from awx.main.models import Credential, Organization from awx.conf.models import Setting from awx.main.migrations import _galaxy as galaxy @@ -78,6 +78,10 @@ def test_multiple_galaxies(): assert creds[1].name == 'Ansible Galaxy' assert creds[1].inputs['url'] == 'https://galaxy.ansible.com/' + public_galaxy_creds = Credential.objects.filter(name='Ansible Galaxy') + assert public_galaxy_creds.count() == 1 + assert public_galaxy_creds.first().managed_by_tower is True + @pytest.mark.django_db def test_fallback_galaxies(): From 458807c0c7deeb9eb1efaa80873f04e61daebb19 Mon Sep 17 00:00:00 2001 From: mabashian Date: Wed, 5 Aug 2020 11:36:25 -0400 Subject: [PATCH 07/14] Add a Galaxy Credential multi-select field to the Organizations form --- .../add/organizations-add.controller.js | 39 ++++-- .../edit/organizations-edit.controller.js | 22 +++- .../galaxy-credentials-modal.directive.js | 123 ++++++++++++++++++ .../galaxy-credentials-modal.partial.html | 22 ++++ ...laxy-credentials-multiselect.controller.js | 14 ++ .../galaxy-credentials.block.less | 15 +++ .../galaxy-credentials.directive.js | 19 +++ .../galaxy-credentials.partial.html | 18 +++ awx/ui/client/src/organizations/main.js | 24 +++- .../src/organizations/organizations.form.js | 11 +- .../multi-credential.service.js | 46 +++++++ 11 files changed, 336 insertions(+), 17 deletions(-) create mode 100644 awx/ui/client/src/organizations/galaxy-credentials-multiselect/galaxy-credentials-modal/galaxy-credentials-modal.directive.js create mode 100644 awx/ui/client/src/organizations/galaxy-credentials-multiselect/galaxy-credentials-modal/galaxy-credentials-modal.partial.html create mode 100644 awx/ui/client/src/organizations/galaxy-credentials-multiselect/galaxy-credentials-multiselect.controller.js create mode 100644 awx/ui/client/src/organizations/galaxy-credentials-multiselect/galaxy-credentials.block.less create mode 100644 awx/ui/client/src/organizations/galaxy-credentials-multiselect/galaxy-credentials.directive.js create mode 100644 awx/ui/client/src/organizations/galaxy-credentials-multiselect/galaxy-credentials.partial.html diff --git a/awx/ui/client/src/organizations/add/organizations-add.controller.js b/awx/ui/client/src/organizations/add/organizations-add.controller.js index cd6aea6cb9..69fdfce1db 100644 --- a/awx/ui/client/src/organizations/add/organizations-add.controller.js +++ b/awx/ui/client/src/organizations/add/organizations-add.controller.js @@ -4,11 +4,12 @@ * All Rights Reserved *************************************************/ -export default ['$scope', '$rootScope', '$location', '$stateParams', - 'OrganizationForm', 'GenerateForm', 'Rest', 'Alert', - 'ProcessErrors', 'GetBasePath', 'Wait', 'CreateSelect2', '$state','InstanceGroupsService', 'ConfigData', - function($scope, $rootScope, $location, $stateParams, OrganizationForm, - GenerateForm, Rest, Alert, ProcessErrors, GetBasePath, Wait, CreateSelect2, $state, InstanceGroupsService, ConfigData) { +export default ['$scope', '$rootScope', '$location', '$stateParams', 'OrganizationForm', + 'GenerateForm', 'Rest', 'Alert', 'ProcessErrors', 'GetBasePath', 'Wait', 'CreateSelect2', + '$state','InstanceGroupsService', 'ConfigData', 'MultiCredentialService', + function($scope, $rootScope, $location, $stateParams, OrganizationForm, + GenerateForm, Rest, Alert, ProcessErrors, GetBasePath, Wait, CreateSelect2, + $state, InstanceGroupsService, ConfigData, MultiCredentialService) { Rest.setUrl(GetBasePath('organizations')); Rest.options() @@ -57,18 +58,32 @@ export default ['$scope', '$rootScope', '$location', '$stateParams', const organization_id = data.id, instance_group_url = data.related.instance_groups; - InstanceGroupsService.addInstanceGroups(instance_group_url, $scope.instance_groups) + MultiCredentialService + .saveRelatedSequentially({ + related: { + credentials: data.related.galaxy_credentials + } + }, $scope.credentials) .then(() => { - Wait('stop'); - $rootScope.$broadcast("EditIndicatorChange", "organizations", organization_id); - $state.go('organizations.edit', {organization_id: organization_id}, {reload: true}); - }) - .catch(({data, status}) => { + InstanceGroupsService.addInstanceGroups(instance_group_url, $scope.instance_groups) + .then(() => { + Wait('stop'); + $rootScope.$broadcast("EditIndicatorChange", "organizations", organization_id); + $state.go('organizations.edit', {organization_id: organization_id}, {reload: true}); + }) + .catch(({data, status}) => { + ProcessErrors($scope, data, status, form, { + hdr: 'Error!', + msg: 'Failed to save instance groups. POST returned status: ' + status + }); + }); + }).catch(({data, status}) => { ProcessErrors($scope, data, status, form, { hdr: 'Error!', - msg: 'Failed to save instance groups. POST returned status: ' + status + msg: 'Failed to save Galaxy credentials. POST returned status: ' + status }); }); + }) .catch(({data, status}) => { let explanation = _.has(data, "name") ? data.name[0] : ""; diff --git a/awx/ui/client/src/organizations/edit/organizations-edit.controller.js b/awx/ui/client/src/organizations/edit/organizations-edit.controller.js index 74397b02cc..9ffe8e859b 100644 --- a/awx/ui/client/src/organizations/edit/organizations-edit.controller.js +++ b/awx/ui/client/src/organizations/edit/organizations-edit.controller.js @@ -6,10 +6,12 @@ export default ['$scope', '$location', '$stateParams', 'isOrgAdmin', 'isNotificationAdmin', 'OrganizationForm', 'Rest', 'ProcessErrors', 'Prompt', 'i18n', 'isOrgAuditor', - 'GetBasePath', 'Wait', '$state', 'ToggleNotification', 'CreateSelect2', 'InstanceGroupsService', 'InstanceGroupsData', 'ConfigData', + 'GetBasePath', 'Wait', '$state', 'ToggleNotification', 'CreateSelect2', 'InstanceGroupsService', + 'InstanceGroupsData', 'ConfigData', 'GalaxyCredentialsData', 'MultiCredentialService', function($scope, $location, $stateParams, isOrgAdmin, isNotificationAdmin, OrganizationForm, Rest, ProcessErrors, Prompt, i18n, isOrgAuditor, - GetBasePath, Wait, $state, ToggleNotification, CreateSelect2, InstanceGroupsService, InstanceGroupsData, ConfigData) { + GetBasePath, Wait, $state, ToggleNotification, CreateSelect2, InstanceGroupsService, + InstanceGroupsData, ConfigData, GalaxyCredentialsData, MultiCredentialService) { let form = OrganizationForm(), defaultUrl = GetBasePath('organizations'), @@ -29,6 +31,7 @@ export default ['$scope', '$location', '$stateParams', 'isOrgAdmin', 'isNotifica }); $scope.instance_groups = InstanceGroupsData; + $scope.credentials = GalaxyCredentialsData; const virtualEnvs = ConfigData.custom_virtualenvs || []; $scope.custom_virtualenvs_visible = virtualEnvs.length > 1; $scope.custom_virtualenvs_options = virtualEnvs.filter( @@ -100,7 +103,14 @@ export default ['$scope', '$location', '$stateParams', 'isOrgAdmin', 'isNotifica Rest.setUrl(defaultUrl + id + '/'); Rest.put(params) .then(() => { - InstanceGroupsService.editInstanceGroups(instance_group_url, $scope.instance_groups) + MultiCredentialService + .saveRelatedSequentially({ + related: { + credentials: $scope.organization_obj.related.galaxy_credentials + } + }, $scope.credentials) + .then(() => { + InstanceGroupsService.editInstanceGroups(instance_group_url, $scope.instance_groups) .then(() => { Wait('stop'); $state.go($state.current, {}, { reload: true }); @@ -111,6 +121,12 @@ export default ['$scope', '$location', '$stateParams', 'isOrgAdmin', 'isNotifica msg: 'Failed to update instance groups. POST returned status: ' + status }); }); + }).catch(({data, status}) => { + ProcessErrors($scope, data, status, form, { + hdr: 'Error!', + msg: 'Failed to save Galaxy credentials. POST returned status: ' + status + }); + }); $scope.organization_name = $scope.name; main = params; }) diff --git a/awx/ui/client/src/organizations/galaxy-credentials-multiselect/galaxy-credentials-modal/galaxy-credentials-modal.directive.js b/awx/ui/client/src/organizations/galaxy-credentials-multiselect/galaxy-credentials-modal/galaxy-credentials-modal.directive.js new file mode 100644 index 0000000000..145a835d88 --- /dev/null +++ b/awx/ui/client/src/organizations/galaxy-credentials-multiselect/galaxy-credentials-modal/galaxy-credentials-modal.directive.js @@ -0,0 +1,123 @@ +export default ['templateUrl', '$window', function(templateUrl, $window) { + return { + restrict: 'E', + scope: { + galaxyCredentials: '=' + }, + templateUrl: templateUrl('organizations/galaxy-credentials-multiselect/galaxy-credentials-modal/galaxy-credentials-modal'), + + link: function(scope, element) { + + $('#galaxy-credentials-modal').on('hidden.bs.modal', function () { + $('#galaxy-credentials-modal').off('hidden.bs.modal'); + $(element).remove(); + }); + + scope.showModal = function() { + $('#galaxy-credentials-modal').modal('show'); + }; + + scope.destroyModal = function() { + $('#galaxy-credentials-modal').modal('hide'); + }; + }, + + controller: ['$scope', '$compile', 'QuerySet', 'GetBasePath','generateList', 'CredentialList', function($scope, $compile, qs, GetBasePath, GenerateList, CredentialList) { + + function init() { + + $scope.credential_queryset = { + order_by: 'name', + page_size: 5, + credential_type__kind: 'galaxy' + }; + + $scope.credential_default_params = { + order_by: 'name', + page_size: 5, + credential_type__kind: 'galaxy' + }; + + qs.search(GetBasePath('credentials'), $scope.credential_queryset) + .then(res => { + $scope.credential_dataset = res.data; + $scope.credentials = $scope.credential_dataset.results; + + let credentialList = _.cloneDeep(CredentialList); + + credentialList.listTitle = false; + credentialList.well = false; + credentialList.multiSelect = true; + credentialList.multiSelectPreview = { + selectedRows: 'igTags', + availableRows: 'credentials' + }; + credentialList.fields.name.ngClick = "linkoutCredential(credential)"; + credentialList.fields.name.columnClass = 'col-md-11 col-sm-11 col-xs-11'; + delete credentialList.fields.consumed_capacity; + delete credentialList.fields.jobs_running; + + let html = `${GenerateList.build({ + list: credentialList, + input_type: 'galaxy-credentials-modal-body', + hideViewPerPage: true, + mode: 'lookup' + })}`; + + $scope.list = credentialList; + $('#galaxy-credentials-modal-body').append($compile(html)($scope)); + + if ($scope.galaxyCredentials) { + $scope.galaxyCredentials = $scope.galaxyCredentials.map( (item) => { + item.isSelected = true; + if (!$scope.igTags) { + $scope.igTags = []; + } + $scope.igTags.push(item); + return item; + }); + } + + $scope.showModal(); + }); + + $scope.$watch('credentials', function(){ + angular.forEach($scope.credentials, function(credentialRow) { + angular.forEach($scope.igTags, function(selectedCredential){ + if(selectedCredential.id === credentialRow.id) { + credentialRow.isSelected = true; + } + }); + }); + }); + } + + init(); + + $scope.$on("selectedOrDeselected", function(e, value) { + let item = value.value; + if (value.isSelected) { + if(!$scope.igTags) { + $scope.igTags = []; + } + $scope.igTags.push(item); + } else { + _.remove($scope.igTags, { id: item.id }); + } + }); + + $scope.linkoutCredential = function(credential) { + $window.open('/#/credentials/' + credential.id,'_blank'); + }; + + $scope.cancelForm = function() { + $scope.destroyModal(); + }; + + $scope.saveForm = function() { + $scope.galaxyCredentials = $scope.igTags; + $scope.destroyModal(); + }; + }] + }; +}]; diff --git a/awx/ui/client/src/organizations/galaxy-credentials-multiselect/galaxy-credentials-modal/galaxy-credentials-modal.partial.html b/awx/ui/client/src/organizations/galaxy-credentials-multiselect/galaxy-credentials-modal/galaxy-credentials-modal.partial.html new file mode 100644 index 0000000000..dbf481005e --- /dev/null +++ b/awx/ui/client/src/organizations/galaxy-credentials-multiselect/galaxy-credentials-modal/galaxy-credentials-modal.partial.html @@ -0,0 +1,22 @@ + diff --git a/awx/ui/client/src/organizations/galaxy-credentials-multiselect/galaxy-credentials-multiselect.controller.js b/awx/ui/client/src/organizations/galaxy-credentials-multiselect/galaxy-credentials-multiselect.controller.js new file mode 100644 index 0000000000..548f528798 --- /dev/null +++ b/awx/ui/client/src/organizations/galaxy-credentials-multiselect/galaxy-credentials-multiselect.controller.js @@ -0,0 +1,14 @@ +export default ['$scope', + function($scope) { + + $scope.galaxyCredentialsTags = []; + + $scope.$watch('galaxyCredentials', function() { + $scope.galaxyCredentialsTags = $scope.galaxyCredentials; + }, true); + + $scope.deleteTag = function(tag){ + _.remove($scope.galaxyCredentials, {id: tag.id}); + }; + } +]; \ No newline at end of file diff --git a/awx/ui/client/src/organizations/galaxy-credentials-multiselect/galaxy-credentials.block.less b/awx/ui/client/src/organizations/galaxy-credentials-multiselect/galaxy-credentials.block.less new file mode 100644 index 0000000000..bbfef9de99 --- /dev/null +++ b/awx/ui/client/src/organizations/galaxy-credentials-multiselect/galaxy-credentials.block.less @@ -0,0 +1,15 @@ +#instance-groups-panel { + table { + overflow: hidden; + } + .List-header { + margin-bottom: 20px; + } + .isActive { + border-left: 10px solid @list-row-select-bord; + } + .instances-list, + .instance-jobs-list { + margin-top: 20px; + } +} diff --git a/awx/ui/client/src/organizations/galaxy-credentials-multiselect/galaxy-credentials.directive.js b/awx/ui/client/src/organizations/galaxy-credentials-multiselect/galaxy-credentials.directive.js new file mode 100644 index 0000000000..d966c5e519 --- /dev/null +++ b/awx/ui/client/src/organizations/galaxy-credentials-multiselect/galaxy-credentials.directive.js @@ -0,0 +1,19 @@ +import galaxyCredentialsMultiselectController from './galaxy-credentials-multiselect.controller'; +export default ['templateUrl', '$compile', + function(templateUrl, $compile) { + return { + scope: { + galaxyCredentials: '=', + fieldIsDisabled: '=' + }, + restrict: 'E', + templateUrl: templateUrl('organizations/galaxy-credentials-multiselect/galaxy-credentials'), + controller: galaxyCredentialsMultiselectController, + link: function(scope) { + scope.openInstanceGroupsModal = function() { + $('#content-container').append($compile('')(scope)); + }; + } + }; + } +]; diff --git a/awx/ui/client/src/organizations/galaxy-credentials-multiselect/galaxy-credentials.partial.html b/awx/ui/client/src/organizations/galaxy-credentials-multiselect/galaxy-credentials.partial.html new file mode 100644 index 0000000000..09202327b4 --- /dev/null +++ b/awx/ui/client/src/organizations/galaxy-credentials-multiselect/galaxy-credentials.partial.html @@ -0,0 +1,18 @@ +
+ + + + +
+ +
+
+ {{tag.name | sanitize}} +
+
+
diff --git a/awx/ui/client/src/organizations/main.js b/awx/ui/client/src/organizations/main.js index 8540261ede..a831c13095 100644 --- a/awx/ui/client/src/organizations/main.js +++ b/awx/ui/client/src/organizations/main.js @@ -12,8 +12,10 @@ import organizationsLinkout from './linkout/main'; import OrganizationsLinkoutStates from './linkout/organizations-linkout.route'; import OrganizationForm from './organizations.form'; import OrganizationList from './organizations.list'; -import { N_ } from '../i18n'; +import galaxyCredentialsMultiselect from './galaxy-credentials-multiselect/galaxy-credentials.directive'; +import galaxyCredentialsModal from './galaxy-credentials-multiselect/galaxy-credentials-modal/galaxy-credentials-modal.directive'; +import { N_ } from '../i18n'; export default angular.module('Organizations', [ @@ -24,6 +26,8 @@ angular.module('Organizations', [ .controller('OrganizationsEdit', OrganizationsEdit) .factory('OrganizationForm', OrganizationForm) .factory('OrganizationList', OrganizationList) + .directive('galaxyCredentialsMultiselect', galaxyCredentialsMultiselect) + .directive('galaxyCredentialsModal', galaxyCredentialsModal) .config(['$stateProvider', 'stateDefinitionsProvider', '$stateExtenderProvider', function($stateProvider, stateDefinitionsProvider, $stateExtenderProvider) { let stateExtender = $stateExtenderProvider.$get(), @@ -81,6 +85,24 @@ angular.module('Organizations', [ }); }); }], + GalaxyCredentialsData: ['$stateParams', 'Rest', 'GetBasePath', 'ProcessErrors', + function($stateParams, Rest, GetBasePath, ProcessErrors){ + let path = `${GetBasePath('organizations')}${$stateParams.organization_id}/galaxy_credentials/`; + Rest.setUrl(path); + return Rest.get() + .then(({data}) => { + if (data.results.length > 0) { + return data.results; + } + }) + .catch(({data, status}) => { + ProcessErrors(null, data, status, null, { + hdr: 'Error!', + msg: 'Failed to get credentials. GET returned ' + + 'status: ' + status + }); + }); + }], InstanceGroupsData: ['$stateParams', 'Rest', 'GetBasePath', 'ProcessErrors', function($stateParams, Rest, GetBasePath, ProcessErrors){ let path = `${GetBasePath('organizations')}${$stateParams.organization_id}/instance_groups/`; diff --git a/awx/ui/client/src/organizations/organizations.form.js b/awx/ui/client/src/organizations/organizations.form.js index 19f6e87419..712e6be9ba 100644 --- a/awx/ui/client/src/organizations/organizations.form.js +++ b/awx/ui/client/src/organizations/organizations.form.js @@ -55,6 +55,15 @@ export default ['NotificationsList', 'i18n', ngDisabled: '!(organization_obj.summary_fields.user_capabilities.edit || canAdd)', ngShow: 'custom_virtualenvs_visible' }, + credential: { + label: i18n._('Galaxy Credentials'), + type: 'custom', + awPopOver: "

" + i18n._("Select Galaxy credentials. The selection order sets precedence for the sync and lookup of the content") + "

", + dataTitle: i18n._('Galaxy Credentials'), + dataContainer: 'body', + dataPlacement: 'right', + control: '', + }, max_hosts: { label: i18n._('Max Hosts'), type: 'number', @@ -69,7 +78,7 @@ export default ['NotificationsList', 'i18n', awPopOver: "

" + i18n._("The maximum number of hosts allowed to be managed by this organization. Value defaults to 0 which means no limit. Refer to the Ansible documentation for more details.") + "

", ngDisabled: '!current_user.is_superuser', ngShow: 'BRAND_NAME === "Tower"' - } + }, }, buttons: { //for now always generates