From c2099554004d461937fd1333737cd5ade04b6425 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Mon, 21 Jan 2019 18:20:24 -0500 Subject: [PATCH 01/74] add credential plugin system and minimal working hashivault --- Makefile | 3 + awx/main/credential_plugins/__init__.py | 0 awx/main/credential_plugins/hashivault.py | 48 +++++ awx/main/credential_plugins/plugin.py | 3 + .../setup_managed_credential_types.py | 14 ++ .../0067_v350_credential_plugins.py | 51 +++++ awx/main/models/__init__.py | 2 +- awx/main/models/credential/__init__.py | 95 ++++++++- awx/main/tests/conftest.py | 8 + awx/main/tests/functional/test_credential.py | 1 + docs/credential_plugins.md | 9 + docs/licenses/hvac.txt | 201 ++++++++++++++++++ requirements/requirements.in | 1 + requirements/requirements.txt | 1 + setup.py | 3 + .../awx.egg-info/entry_points.txt | 2 + tools/docker-hashivault-override.yml | 15 ++ 17 files changed, 453 insertions(+), 4 deletions(-) create mode 100644 awx/main/credential_plugins/__init__.py create mode 100644 awx/main/credential_plugins/hashivault.py create mode 100644 awx/main/credential_plugins/plugin.py create mode 100644 awx/main/management/commands/setup_managed_credential_types.py create mode 100644 awx/main/migrations/0067_v350_credential_plugins.py create mode 100644 docs/credential_plugins.md create mode 100644 docs/licenses/hvac.txt create mode 100644 tools/docker-hashivault-override.yml diff --git a/Makefile b/Makefile index 109d0be226..294d940ba1 100644 --- a/Makefile +++ b/Makefile @@ -575,6 +575,9 @@ docker-compose: docker-auth docker-compose-cluster: docker-auth CURRENT_UID=$(shell id -u) TAG=$(COMPOSE_TAG) DEV_DOCKER_TAG_BASE=$(DEV_DOCKER_TAG_BASE) docker-compose -f tools/docker-compose-cluster.yml up +docker-compose-hashivault: docker-auth + CURRENT_UID=$(shell id -u) TAG=$(COMPOSE_TAG) DEV_DOCKER_TAG_BASE=$(DEV_DOCKER_TAG_BASE) docker-compose -f tools/docker-compose.yml -f tools/docker-hashivault-override.yml up --no-recreate awx + docker-compose-test: docker-auth cd tools && CURRENT_UID=$(shell id -u) TAG=$(COMPOSE_TAG) DEV_DOCKER_TAG_BASE=$(DEV_DOCKER_TAG_BASE) docker-compose run --rm --service-ports awx /bin/bash diff --git a/awx/main/credential_plugins/__init__.py b/awx/main/credential_plugins/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/awx/main/credential_plugins/hashivault.py b/awx/main/credential_plugins/hashivault.py new file mode 100644 index 0000000000..27828b4e18 --- /dev/null +++ b/awx/main/credential_plugins/hashivault.py @@ -0,0 +1,48 @@ +from .plugin import CredentialPlugin + +from hvac import Client + + +hashi_inputs = { + 'fields': [{ + 'id': 'url', + 'label': 'Hashivault Server URL', + 'type': 'string', + 'help_text': 'The Hashivault server url.' + }, { + 'id': 'secret_path', + 'label': 'Secret Path', + 'type': 'string', + 'help_text': 'The path to the secret.' + }, { + 'id': 'secret_field', + 'label': 'Secret Field', + 'type': 'string', + 'help_text': 'The data field to access on the secret.' + }, { + 'id': 'token', + 'label': 'Token', + 'type': 'string', + 'secret': True, + 'help_text': 'An access token for the Hashivault server.' + }], + 'required': ['url', 'secret_path', 'token'], +} + + +def hashi_backend(**kwargs): + token = kwargs.get('token') + url = kwargs.get('url') + secret_path = kwargs.get('secret_path') + secret_field = kwargs.get('secret_field', None) + verify = kwargs.get('verify', False) + + client = Client(url=url, token=token, verify=verify) + response = client.read(secret_path) + + if secret_field: + return response['data'][secret_field] + return response['data'] + + +hashivault_plugin = CredentialPlugin('Hashivault', inputs=hashi_inputs, backend=hashi_backend) diff --git a/awx/main/credential_plugins/plugin.py b/awx/main/credential_plugins/plugin.py new file mode 100644 index 0000000000..c5edde7bc1 --- /dev/null +++ b/awx/main/credential_plugins/plugin.py @@ -0,0 +1,3 @@ +from collections import namedtuple + +CredentialPlugin = namedtuple('CredentialPlugin', ['name', 'inputs', 'backend']) diff --git a/awx/main/management/commands/setup_managed_credential_types.py b/awx/main/management/commands/setup_managed_credential_types.py new file mode 100644 index 0000000000..04e170528d --- /dev/null +++ b/awx/main/management/commands/setup_managed_credential_types.py @@ -0,0 +1,14 @@ +# Copyright (c) 2019 Ansible by Red Hat +# All Rights Reserved. + +from django.core.management.base import BaseCommand + +from awx.main.models import CredentialType + + +class Command(BaseCommand): + + help = 'Load default managed credential types.' + + def handle(self, *args, **options): + CredentialType.setup_tower_managed_defaults() diff --git a/awx/main/migrations/0067_v350_credential_plugins.py b/awx/main/migrations/0067_v350_credential_plugins.py new file mode 100644 index 0000000000..0d0e65255b --- /dev/null +++ b/awx/main/migrations/0067_v350_credential_plugins.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import taggit.managers + +# AWX +from awx.main.models import CredentialType + + +def setup_tower_managed_defaults(apps, schema_editor): + CredentialType.setup_tower_managed_defaults() + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('taggit', '0002_auto_20150616_2121'), + ('main', '0066_v350_inventorysource_custom_virtualenv'), + ] + + operations = [ + migrations.CreateModel( + name='CredentialInputSource', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(default=None, editable=False)), + ('modified', models.DateTimeField(default=None, editable=False)), + ('description', models.TextField(blank=True, default='')), + ('input_field_name', models.CharField(max_length=1024)), + ('created_by', models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{'class': 'credentialinputsource', 'model_name': 'credentialinputsource', 'app_label': 'main'}(class)s_created+", to=settings.AUTH_USER_MODEL)), + ('modified_by', models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{'class': 'credentialinputsource', 'model_name': 'credentialinputsource', 'app_label': 'main'}(class)s_modified+", to=settings.AUTH_USER_MODEL)), + ('source_credential', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='target_input_source', to='main.Credential')), + ('tags', taggit.managers.TaggableManager(blank=True, help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags')), + ('target_credential', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='input_source', to='main.Credential')), + ], + ), + migrations.AlterField( + model_name='credentialtype', + name='kind', + field=models.CharField(choices=[('ssh', 'Machine'), ('vault', 'Vault'), ('net', 'Network'), ('scm', 'Source Control'), ('cloud', 'Cloud'), ('insights', 'Insights'), ('external', 'External')], max_length=32), + ), + migrations.AlterUniqueTogether( + name='credentialinputsource', + unique_together=set([('target_credential', 'input_field_name')]), + ), + migrations.RunPython(setup_tower_managed_defaults), + ] diff --git a/awx/main/models/__init__.py b/awx/main/models/__init__.py index 848db3b593..2e4821dcff 100644 --- a/awx/main/models/__init__.py +++ b/awx/main/models/__init__.py @@ -16,7 +16,7 @@ from awx.main.models.organization import ( # noqa Organization, Profile, Team, UserSessionMembership ) from awx.main.models.credential import ( # noqa - Credential, CredentialType, ManagedCredentialType, V1Credential, build_safe_env + Credential, CredentialType, CredentialInputSource, ManagedCredentialType, V1Credential, build_safe_env ) from awx.main.models.projects import Project, ProjectUpdate # noqa from awx.main.models.inventory import ( # noqa diff --git a/awx/main/models/credential/__init__.py b/awx/main/models/credential/__init__.py index 1db1cedc44..ba50d7b723 100644 --- a/awx/main/models/credential/__init__.py +++ b/awx/main/models/credential/__init__.py @@ -4,6 +4,7 @@ import functools import inspect import logging import os +from pkg_resources import iter_entry_points import re import stat import tempfile @@ -26,7 +27,11 @@ from awx.main.fields import (ImplicitRoleField, CredentialInputField, from awx.main.utils import decrypt_field, classproperty from awx.main.utils.safe_yaml import safe_dump from awx.main.validators import validate_ssh_private_key -from awx.main.models.base import CommonModelNameNotUnique, PasswordFieldsModel +from awx.main.models.base import ( + CommonModelNameNotUnique, + PasswordFieldsModel, + PrimordialModel +) from awx.main.models.mixins import ResourceMixin from awx.main.models.rbac import ( ROLE_SINGLETON_SYSTEM_ADMINISTRATOR, @@ -35,9 +40,10 @@ from awx.main.models.rbac import ( from awx.main.utils import encrypt_field from . import injectors as builtin_injectors -__all__ = ['Credential', 'CredentialType', 'V1Credential', 'build_safe_env'] +__all__ = ['Credential', 'CredentialType', 'CredentialInputSource', 'V1Credential', 'build_safe_env'] logger = logging.getLogger('awx.main.models.credential') +credential_plugins = [ep.load() for ep in iter_entry_points('awx.credential_plugins')] HIDDEN_PASSWORD = '**********' @@ -220,7 +226,6 @@ class V1Credential(object): } - class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin): ''' A credential contains information about how to talk to a remote resource @@ -275,6 +280,10 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin): 'admin_role', ]) + def __init__(self, *args, **kwargs): + super(Credential, self).__init__(*args, **kwargs) + self.dynamic_input_fields = self._get_dynamic_input_field_names() + def __getattr__(self, item): if item != 'inputs': if item in V1Credential.FIELDS: @@ -441,6 +450,8 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin): :param field_name(str): The name of the input field. :param default(optional[str]): A default return value to use. """ + if field_name in self.dynamic_input_fields: + return self._get_dynamic_input(field_name) if field_name in self.credential_type.secret_fields: try: return decrypt_field(self, field_name) @@ -461,8 +472,18 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin): raise AttributeError(field_name) def has_input(self, field_name): + if field_name in self.dynamic_input_fields: + return True return field_name in self.inputs and self.inputs[field_name] not in ('', None) + def _get_dynamic_input_field_names(self): + input_sources = CredentialInputSource.objects.filter(target_credential=self) + return [obj.input_field_name for obj in input_sources] + + def _get_dynamic_input(self, field_name): + input_sources = CredentialInputSource.objects.filter(target_credential=self) + return input_sources.filter(input_field_name=field_name).first().get_input_value() + class CredentialType(CommonModelNameNotUnique): ''' @@ -484,6 +505,7 @@ class CredentialType(CommonModelNameNotUnique): ('scm', _('Source Control')), ('cloud', _('Cloud')), ('insights', _('Insights')), + ('external', _('External')), ) kind = models.CharField( @@ -583,6 +605,15 @@ class CredentialType(CommonModelNameNotUnique): created.inputs = created.injectors = {} created.save() + @classmethod + def load_plugin(cls, plugin): + ManagedCredentialType( + namespace=plugin.name.lower(), + name=plugin.name, + kind='external', + inputs=plugin.inputs + ) + @classmethod def from_v1_kind(cls, kind, data={}): match = None @@ -1253,3 +1284,61 @@ ManagedCredentialType( } }, ) + + +class CredentialInputSource(PrimordialModel): + + class Meta: + app_label = 'main' + unique_together = (('target_credential', 'input_field_name'),) + + target_credential = models.ForeignKey( + 'Credential', + related_name='input_source', + on_delete=models.CASCADE, + null=True, + ) + source_credential = models.ForeignKey( + 'Credential', + related_name='target_input_source', + on_delete=models.CASCADE, + null=True, + ) + input_field_name = models.CharField( + max_length=1024, + ) + + def clean_target_credential(self): + if self.target_credential.kind == 'external': + raise ValidationError(_('Target must be a non-external credential')) + return self.target_credential + + def clean_source_credential(self): + if self.source_credential.kind != 'external': + raise ValidationError(_('Source must be an external credential')) + return self.source_credential + + def clean_input_field_name(self): + if self.input_field_name not in self.target_credential.credential_type.defined_fields: + raise ValidationError(_('Input field must be defined on target credential.')) + return self.input_field_name + + def save(self, *args, **kwargs): + self.full_clean() + super(CredentialInputSource, self).save(*args, **kwargs) + + def get_input_value(self): + plugin_name = self.source_credential.credential_type.name + [backend] = [p.backend for p in credential_plugins if p.name == plugin_name] + + backend_kwargs = {} + for field_name, value in self.source_credential.inputs.items(): + if field_name in self.source_credential.credential_type.secret_fields: + backend_kwargs[field_name] = decrypt_field(self.source_credential, field_name) + else: + backend_kwargs[field_name] = value + return backend(**backend_kwargs) + + +for plugin in credential_plugins: + CredentialType.load_plugin(plugin) diff --git a/awx/main/tests/conftest.py b/awx/main/tests/conftest.py index ed4acd4b48..e79fb7b67a 100644 --- a/awx/main/tests/conftest.py +++ b/awx/main/tests/conftest.py @@ -4,6 +4,7 @@ import pytest from unittest import mock from contextlib import contextmanager +from awx.main.models import Credential from awx.main.tests.factories import ( create_organization, create_job_template, @@ -139,3 +140,10 @@ def pytest_runtest_teardown(item, nextitem): # this is a local test cache, so we want every test to start with empty cache cache.clear() +@pytest.fixture(scope='session', autouse=True) +def mock_external_credential_input_sources(): + # Credential objects query their related input sources on initialization. + # We mock that behavior out of credentials by default unless we need to + # test it explicitly. + with mock.patch.object(Credential, '_get_dynamic_input_field_names', new=lambda _: []) as _fixture: + yield _fixture diff --git a/awx/main/tests/functional/test_credential.py b/awx/main/tests/functional/test_credential.py index 4b3ff2d75e..6ccbfc6344 100644 --- a/awx/main/tests/functional/test_credential.py +++ b/awx/main/tests/functional/test_credential.py @@ -79,6 +79,7 @@ def test_default_cred_types(): 'azure_rm', 'cloudforms', 'gce', + 'hashivault', 'insights', 'net', 'openstack', diff --git a/docs/credential_plugins.md b/docs/credential_plugins.md new file mode 100644 index 0000000000..3cf07cf6c5 --- /dev/null +++ b/docs/credential_plugins.md @@ -0,0 +1,9 @@ +Credential Plugins +================================ +### Development +```shell +# The vault server will be available at localhost:8200. +# From within the awx container, the vault server is reachable at hashivault:8200. +# See docker-hashivault-override.yml for the development root token. +make docker-compose-hashivault +``` diff --git a/docs/licenses/hvac.txt b/docs/licenses/hvac.txt new file mode 100644 index 0000000000..8dada3edaf --- /dev/null +++ b/docs/licenses/hvac.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/requirements/requirements.in b/requirements/requirements.in index 8ba7cb07dc..bf8b9cd577 100644 --- a/requirements/requirements.in +++ b/requirements/requirements.in @@ -49,3 +49,4 @@ uWSGI==2.0.17 uwsgitop==0.10.0 pip==9.0.1 setuptools==36.0.1 +hvac==0.7.1 diff --git a/requirements/requirements.txt b/requirements/requirements.txt index df2cbfd26a..2137d01e17 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -39,6 +39,7 @@ django==1.11.16 djangorestframework-yaml==1.0.3 djangorestframework==3.7.7 future==0.16.0 # via django-radius +hvac==0.7.1 hyperlink==18.0.0 # via twisted idna==2.8 # via hyperlink incremental==17.5.0 # via twisted diff --git a/setup.py b/setup.py index 294190104b..38bb8b84f0 100755 --- a/setup.py +++ b/setup.py @@ -114,6 +114,9 @@ setup( 'console_scripts': [ 'awx-manage = awx:manage', ], + 'awx.credential_plugins': [ + 'hashivault = awx.main.credential_plugins.hashivault:hashivault_plugin', + ] }, data_files = proc_data_files([ ("%s" % homedir, ["config/wsgi.py", diff --git a/tools/docker-compose/awx.egg-info/entry_points.txt b/tools/docker-compose/awx.egg-info/entry_points.txt index a0ad90d8ce..dbfc7331f7 100644 --- a/tools/docker-compose/awx.egg-info/entry_points.txt +++ b/tools/docker-compose/awx.egg-info/entry_points.txt @@ -2,3 +2,5 @@ tower-manage = awx:manage awx-manage = awx:manage +[awx.credential_plugins] +hashivault = awx.main.credential_plugins.hashivault:hashivault_plugin diff --git a/tools/docker-hashivault-override.yml b/tools/docker-hashivault-override.yml new file mode 100644 index 0000000000..8ada54abcb --- /dev/null +++ b/tools/docker-hashivault-override.yml @@ -0,0 +1,15 @@ +version: '2' +services: + # Primary Tower Development Container link + awx: + links: + - hashivault + hashivault: + image: vault:1.0.1 + container_name: tools_hashivault_1 + ports: + - '8200:8200' + cap_add: + - IPC_LOCK + environment: + VAULT_DEV_ROOT_TOKEN_ID: 'vaultdev' From d87144c4a7d425d12313de31d62d1d860c2833ad Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Wed, 30 Jan 2019 17:01:00 -0500 Subject: [PATCH 02/74] add api for managing credential input sources --- awx/api/serializers.py | 40 +++++- awx/api/urls/credential.py | 2 + awx/api/urls/credential_input_source.py | 17 +++ awx/api/urls/urls.py | 2 + awx/api/views/__init__.py | 27 ++++ awx/api/views/root.py | 1 + awx/main/access.py | 17 ++- awx/main/models/credential/__init__.py | 4 + awx/main/tests/conftest.py | 1 + .../api/test_credential_input_sources.py | 115 ++++++++++++++++++ awx/main/tests/functional/conftest.py | 39 ++++++ 11 files changed, 262 insertions(+), 3 deletions(-) create mode 100644 awx/api/urls/credential_input_source.py create mode 100644 awx/main/tests/functional/api/test_credential_input_sources.py diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 7a67041128..ca4262dbed 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -46,7 +46,7 @@ from awx.main.constants import ( CENSOR_VALUE, ) from awx.main.models import ( - ActivityStream, AdHocCommand, AdHocCommandEvent, Credential, + ActivityStream, AdHocCommand, AdHocCommandEvent, Credential, CredentialInputSource, CredentialType, CustomInventoryScript, Fact, Group, Host, Instance, InstanceGroup, Inventory, InventorySource, InventoryUpdate, InventoryUpdateEvent, Job, JobEvent, JobHostSummary, JobLaunchConfig, @@ -2629,6 +2629,7 @@ class CredentialSerializer(BaseSerializer): )) if self.version > 1: res['copy'] = self.reverse('api:credential_copy', kwargs={'pk': obj.pk}) + res['input_sources'] = self.reverse('api:credential_input_source_sublist', kwargs={'pk': obj.pk}) # TODO: remove when API v1 is removed if self.version > 1: @@ -2815,6 +2816,43 @@ class CredentialSerializerCreate(CredentialSerializer): return credential +class CredentialInputSourceSerializer(BaseSerializer): + source_credential_name = serializers.SerializerMethodField( + read_only=True, + help_text=_('The name of the source credential.') + ) + source_credential_type = serializers.SerializerMethodField( + read_only=True, + help_text=_('The credential type of the source credential.') + ) + + class Meta: + model = CredentialInputSource + fields = ( + 'id', + 'type', + 'url', + 'input_field_name', + 'target_credential', + 'source_credential', + 'source_credential_type', + 'source_credential_name', + 'created', + 'modified', + ) + extra_kwargs = { + 'input_field_name': {'required': True}, + 'target_credential': {'required': True}, + 'source_credential': {'required': True}, + } + + def get_source_credential_name(self, obj): + return obj.source_credential.name + + def get_source_credential_type(self, obj): + return obj.source_credential.credential_type.id + + class UserCredentialSerializerCreate(CredentialSerializerCreate): class Meta: diff --git a/awx/api/urls/credential.py b/awx/api/urls/credential.py index c444da9090..3143b91c9e 100644 --- a/awx/api/urls/credential.py +++ b/awx/api/urls/credential.py @@ -12,6 +12,7 @@ from awx.api.views import ( CredentialOwnerUsersList, CredentialOwnerTeamsList, CredentialCopy, + CredentialInputSourceSubList, ) @@ -24,6 +25,7 @@ urls = [ url(r'^(?P[0-9]+)/owner_users/$', CredentialOwnerUsersList.as_view(), name='credential_owner_users_list'), url(r'^(?P[0-9]+)/owner_teams/$', CredentialOwnerTeamsList.as_view(), name='credential_owner_teams_list'), url(r'^(?P[0-9]+)/copy/$', CredentialCopy.as_view(), name='credential_copy'), + url(r'^(?P[0-9]+)/input_sources/$', CredentialInputSourceSubList.as_view(), name='credential_input_source_sublist'), ] __all__ = ['urls'] diff --git a/awx/api/urls/credential_input_source.py b/awx/api/urls/credential_input_source.py new file mode 100644 index 0000000000..5f660dfdf8 --- /dev/null +++ b/awx/api/urls/credential_input_source.py @@ -0,0 +1,17 @@ +# Copyright (c) 2019 Ansible, Inc. +# All Rights Reserved. + +from django.conf.urls import url + +from awx.api.views import ( + CredentialInputSourceDetail, + CredentialInputSourceList, +) + + +urls = [ + url(r'^$', CredentialInputSourceList.as_view(), name='credential_input_source_list'), + url(r'^(?P[0-9]+)/$', CredentialInputSourceDetail.as_view(), name='credential_input_source_detail'), +] + +__all__ = ['urls'] diff --git a/awx/api/urls/urls.py b/awx/api/urls/urls.py index 52e9ef1cf0..c5da931a69 100644 --- a/awx/api/urls/urls.py +++ b/awx/api/urls/urls.py @@ -47,6 +47,7 @@ from .inventory_update import urls as inventory_update_urls from .inventory_script import urls as inventory_script_urls from .credential_type import urls as credential_type_urls from .credential import urls as credential_urls +from .credential_input_source import urls as credential_input_source_urls from .role import urls as role_urls from .job_template import urls as job_template_urls from .job import urls as job_urls @@ -119,6 +120,7 @@ v1_urls = [ v2_urls = [ url(r'^$', ApiV2RootView.as_view(), name='api_v2_root_view'), url(r'^credential_types/', include(credential_type_urls)), + url(r'^credential_input_sources/', include(credential_input_source_urls)), url(r'^hosts/(?P[0-9]+)/ansible_facts/$', HostAnsibleFactsDetail.as_view(), name='host_ansible_facts_detail'), url(r'^jobs/(?P[0-9]+)/extra_credentials/$', JobExtraCredentialsList.as_view(), name='job_extra_credentials_list'), url(r'^jobs/(?P[0-9]+)/credentials/$', JobCredentialsList.as_view(), name='job_credentials_list'), diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 902c7d6910..fec83b854e 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -1419,6 +1419,33 @@ class CredentialCopy(CopyAPIView): copy_return_serializer_class = serializers.CredentialSerializer +class CredentialInputSourceDetail(RetrieveUpdateDestroyAPIView): + + view_name = _("Credential Input Source Detail") + + model = models.CredentialInputSource + serializer_class = serializers.CredentialInputSourceSerializer + + +class CredentialInputSourceList(ListCreateAPIView): + + view_name = _("Credential Input Sources") + + model = models.CredentialInputSource + serializer_class = serializers.CredentialInputSourceSerializer + + +class CredentialInputSourceSubList(SubListAPIView): + + view_name = _("Credential Input Sources") + + model = models.CredentialInputSource + serializer_class = serializers.CredentialInputSourceSerializer + parent_model = models.Credential + relationship = 'input_source' + parent_key = 'target_credential' + + class HostRelatedSearchMixin(object): @property diff --git a/awx/api/views/root.py b/awx/api/views/root.py index c7ecbbeef5..66bc11b710 100644 --- a/awx/api/views/root.py +++ b/awx/api/views/root.py @@ -101,6 +101,7 @@ class ApiVersionRootView(APIView): data['credentials'] = reverse('api:credential_list', request=request) if get_request_version(request) > 1: data['credential_types'] = reverse('api:credential_type_list', request=request) + data['credential_input_sources'] = reverse('api:credential_input_source_list', request=request) data['applications'] = reverse('api:o_auth2_application_list', request=request) data['tokens'] = reverse('api:o_auth2_token_list', request=request) data['inventory'] = reverse('api:inventory_list', request=request) diff --git a/awx/main/access.py b/awx/main/access.py index 284893eb31..d13f4ed682 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -30,8 +30,8 @@ from awx.main.utils import ( ) from awx.main.models import ( ActivityStream, AdHocCommand, AdHocCommandEvent, Credential, CredentialType, - CustomInventoryScript, Group, Host, Instance, InstanceGroup, Inventory, - InventorySource, InventoryUpdate, InventoryUpdateEvent, Job, JobEvent, + CredentialInputSource, CustomInventoryScript, Group, Host, Instance, InstanceGroup, + Inventory, InventorySource, InventoryUpdate, InventoryUpdateEvent, Job, JobEvent, JobHostSummary, JobLaunchConfig, JobTemplate, Label, Notification, NotificationTemplate, Organization, Project, ProjectUpdate, ProjectUpdateEvent, Role, Schedule, SystemJob, SystemJobEvent, @@ -1163,6 +1163,19 @@ class CredentialAccess(BaseAccess): return self.can_change(obj, None) +class CredentialInputSourceAccess(BaseAccess): + ''' + I can see credential input sources when: + - I'm a superuser (TODO: Update) + I can create credential input sources when: + - I'm a superuser (TODO: Update) + I can delete credential input sources when: + - I'm a superuser (TODO: Update) + ''' + + model = CredentialInputSource + + class TeamAccess(BaseAccess): ''' I can see a team when: diff --git a/awx/main/models/credential/__init__.py b/awx/main/models/credential/__init__.py index ba50d7b723..13fb146672 100644 --- a/awx/main/models/credential/__init__.py +++ b/awx/main/models/credential/__init__.py @@ -1339,6 +1339,10 @@ class CredentialInputSource(PrimordialModel): backend_kwargs[field_name] = value return backend(**backend_kwargs) + def get_absolute_url(self, request=None): + view_name = 'api:credential_input_source_detail' + return reverse(view_name, kwargs={'pk': self.pk}, request=request) + for plugin in credential_plugins: CredentialType.load_plugin(plugin) diff --git a/awx/main/tests/conftest.py b/awx/main/tests/conftest.py index e79fb7b67a..7418a0d735 100644 --- a/awx/main/tests/conftest.py +++ b/awx/main/tests/conftest.py @@ -140,6 +140,7 @@ def pytest_runtest_teardown(item, nextitem): # this is a local test cache, so we want every test to start with empty cache cache.clear() + @pytest.fixture(scope='session', autouse=True) def mock_external_credential_input_sources(): # Credential objects query their related input sources on initialization. diff --git a/awx/main/tests/functional/api/test_credential_input_sources.py b/awx/main/tests/functional/api/test_credential_input_sources.py new file mode 100644 index 0000000000..3524ca70fe --- /dev/null +++ b/awx/main/tests/functional/api/test_credential_input_sources.py @@ -0,0 +1,115 @@ +import pytest + +from awx.api.versioning import reverse + + +@pytest.mark.django_db +def test_create_credential_input_source(get, post, admin, vault_credential, external_credential): + list_url = reverse( + 'api:credential_input_source_list', + kwargs={'version': 'v2'} + ) + sublist_url = reverse( + 'api:credential_input_source_sublist', + kwargs={'version': 'v2', 'pk': vault_credential.pk} + ) + params = { + 'source_credential': external_credential.pk, + 'target_credential': vault_credential.pk, + 'input_field_name': 'vault_password' + } + response = post(list_url, params, admin) + assert response.status_code == 201 + + response = get(response.data['url'], admin) + assert response.status_code == 200 + + response = get(list_url, admin) + assert response.status_code == 200 + assert response.data['count'] == 1 + + response = get(sublist_url, admin) + assert response.status_code == 200 + assert response.data['count'] == 1 + + +@pytest.mark.django_db +def test_create_credential_input_source_using_sublist_returns_405(post, admin, vault_credential, external_credential): + sublist_url = reverse( + 'api:credential_input_source_sublist', + kwargs={'version': 'v2', 'pk': vault_credential.pk} + ) + params = { + 'source_credential': external_credential.pk, + 'target_credential': vault_credential.pk, + 'input_field_name': 'vault_password' + } + response = post(sublist_url, params, admin) + assert response.status_code == 405 + + +@pytest.mark.django_db +def test_create_credential_input_source_with_external_target_returns_400(post, admin, external_credential, other_external_credential): + list_url = reverse( + 'api:credential_input_source_list', + kwargs={'version': 'v2'} + ) + params = { + 'source_credential': external_credential.pk, + 'target_credential': other_external_credential.pk, + 'input_field_name': 'token' + } + response = post(list_url, params, admin) + assert response.status_code == 400 + assert response.data['target_credential'] == ['Target must be a non-external credential'] + + +@pytest.mark.django_db +def test_create_credential_input_source_with_non_external_source_returns_400(post, admin, credential, vault_credential): + list_url = reverse( + 'api:credential_input_source_list', + kwargs={'version': 'v2'} + ) + params = { + 'source_credential': credential.pk, + 'target_credential': vault_credential.pk, + 'input_field_name': 'vault_password' + } + response = post(list_url, params, admin) + assert response.status_code == 400 + assert response.data['source_credential'] == ['Source must be an external credential'] + + +@pytest.mark.django_db +def test_create_credential_input_source_with_undefined_input_returns_400(post, admin, vault_credential, external_credential): + list_url = reverse( + 'api:credential_input_source_list', + kwargs={'version': 'v2'} + ) + params = { + 'source_credential': external_credential.pk, + 'target_credential': vault_credential.pk, + 'input_field_name': 'not_defined_for_credential_type' + } + response = post(list_url, params, admin) + assert response.status_code == 400 + assert response.data['input_field_name'] == ['Input field must be defined on target credential.'] + + +@pytest.mark.django_db +def test_create_credential_input_source_with_already_used_input_returns_400(post, admin, vault_credential, external_credential, other_external_credential): + list_url = reverse( + 'api:credential_input_source_list', + kwargs={'version': 'v2'} + ) + all_params = [{ + 'source_credential': external_credential.pk, + 'target_credential': vault_credential.pk, + 'input_field_name': 'vault_password' + }, { + 'source_credential': other_external_credential.pk, + 'target_credential': vault_credential.pk, + 'input_field_name': 'vault_password' + }] + all_responses = [post(list_url, params, admin) for params in all_params] + assert all_responses.pop().status_code == 400 diff --git a/awx/main/tests/functional/conftest.py b/awx/main/tests/functional/conftest.py index 8837339783..8dcbc381a4 100644 --- a/awx/main/tests/functional/conftest.py +++ b/awx/main/tests/functional/conftest.py @@ -250,6 +250,33 @@ def credentialtype_insights(): return insights_type +@pytest.fixture +def credentialtype_external(): + external_type_inputs = { + 'fields': [{ + 'id': 'url', + 'label': 'Server URL', + 'type': 'string', + 'help_text': 'The server url.' + }, { + 'id': 'token', + 'label': 'Token', + 'type': 'string', + 'secret': True, + 'help_text': 'An access token for the server.' + }], + 'required': ['url', 'token'], + } + external_type = CredentialType( + kind='external', + managed_by_tower=True, + name='External Service', + inputs=external_type_inputs + ) + external_type.save() + return external_type + + @pytest.fixture def credential(credentialtype_aws): return Credential.objects.create(credential_type=credentialtype_aws, name='test-cred', @@ -293,6 +320,18 @@ def org_credential(organization, credentialtype_aws): organization=organization) +@pytest.fixture +def external_credential(credentialtype_external): + return Credential.objects.create(credential_type=credentialtype_external, name='external-cred', + inputs={'url': 'http://testhost.com', 'token': 'secret1'}) + + +@pytest.fixture +def other_external_credential(credentialtype_external): + return Credential.objects.create(credential_type=credentialtype_external, name='other-external-cred', + inputs={'url': 'http://testhost.com', 'token': 'secret2'}) + + @pytest.fixture def inventory(organization): return organization.inventories.create(name="test-inv") From 9036ba492ce05b57ec180b1b3fe44710b3b3e752 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Mon, 18 Feb 2019 14:42:14 -0500 Subject: [PATCH 03/74] switch CredentialInput creation to use the associate/disassociate view --- awx/api/views/__init__.py | 4 +- awx/api/views/root.py | 1 - .../api/test_credential_input_sources.py | 99 ++++++++++--------- 3 files changed, 57 insertions(+), 47 deletions(-) diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index fec83b854e..3d158315d3 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -1427,7 +1427,7 @@ class CredentialInputSourceDetail(RetrieveUpdateDestroyAPIView): serializer_class = serializers.CredentialInputSourceSerializer -class CredentialInputSourceList(ListCreateAPIView): +class CredentialInputSourceList(ListAPIView): view_name = _("Credential Input Sources") @@ -1435,7 +1435,7 @@ class CredentialInputSourceList(ListCreateAPIView): serializer_class = serializers.CredentialInputSourceSerializer -class CredentialInputSourceSubList(SubListAPIView): +class CredentialInputSourceSubList(SubListCreateAttachDetachAPIView): view_name = _("Credential Input Sources") diff --git a/awx/api/views/root.py b/awx/api/views/root.py index 66bc11b710..c7ecbbeef5 100644 --- a/awx/api/views/root.py +++ b/awx/api/views/root.py @@ -101,7 +101,6 @@ class ApiVersionRootView(APIView): data['credentials'] = reverse('api:credential_list', request=request) if get_request_version(request) > 1: data['credential_types'] = reverse('api:credential_type_list', request=request) - data['credential_input_sources'] = reverse('api:credential_input_source_list', request=request) data['applications'] = reverse('api:o_auth2_application_list', request=request) data['tokens'] = reverse('api:o_auth2_token_list', request=request) data['inventory'] = reverse('api:inventory_list', request=request) diff --git a/awx/main/tests/functional/api/test_credential_input_sources.py b/awx/main/tests/functional/api/test_credential_input_sources.py index 3524ca70fe..4f0cb371a6 100644 --- a/awx/main/tests/functional/api/test_credential_input_sources.py +++ b/awx/main/tests/functional/api/test_credential_input_sources.py @@ -1,115 +1,126 @@ import pytest +from awx.main.models import CredentialInputSource from awx.api.versioning import reverse @pytest.mark.django_db -def test_create_credential_input_source(get, post, admin, vault_credential, external_credential): - list_url = reverse( - 'api:credential_input_source_list', - kwargs={'version': 'v2'} - ) +def test_associate_credential_input_source(get, post, admin, vault_credential, external_credential): sublist_url = reverse( 'api:credential_input_source_sublist', kwargs={'version': 'v2', 'pk': vault_credential.pk} ) + + # attach params = { 'source_credential': external_credential.pk, - 'target_credential': vault_credential.pk, - 'input_field_name': 'vault_password' + 'input_field_name': 'vault_password', + 'associate': True } - response = post(list_url, params, admin) + response = post(sublist_url, params, admin) assert response.status_code == 201 - response = get(response.data['url'], admin) - assert response.status_code == 200 - - response = get(list_url, admin) - assert response.status_code == 200 - assert response.data['count'] == 1 + detail = get(response.data['url'], admin) + assert detail.status_code == 200 response = get(sublist_url, admin) assert response.status_code == 200 assert response.data['count'] == 1 + assert get(reverse( + 'api:credential_input_source_list', + kwargs={'version': 'v2'} + ), admin).data['count'] == 1 + assert CredentialInputSource.objects.count() == 1 + + # detach + params = { + 'id': detail.data['id'], + 'disassociate': True + } + response = post(sublist_url, params, admin) + assert response.status_code == 204 + + response = get(sublist_url, admin) + assert response.status_code == 200 + assert response.data['count'] == 0 + assert get(reverse( + 'api:credential_input_source_list', + kwargs={'version': 'v2'} + ), admin).data['count'] == 0 + assert CredentialInputSource.objects.count() == 0 @pytest.mark.django_db -def test_create_credential_input_source_using_sublist_returns_405(post, admin, vault_credential, external_credential): - sublist_url = reverse( - 'api:credential_input_source_sublist', - kwargs={'version': 'v2', 'pk': vault_credential.pk} - ) +def test_cannot_create_from_list(get, post, admin, vault_credential, external_credential): params = { 'source_credential': external_credential.pk, 'target_credential': vault_credential.pk, - 'input_field_name': 'vault_password' + 'input_field_name': 'vault_password', } - response = post(sublist_url, params, admin) - assert response.status_code == 405 + assert post(reverse( + 'api:credential_input_source_list', + kwargs={'version': 'v2'} + ), params, admin).status_code == 405 @pytest.mark.django_db def test_create_credential_input_source_with_external_target_returns_400(post, admin, external_credential, other_external_credential): - list_url = reverse( - 'api:credential_input_source_list', - kwargs={'version': 'v2'} + sublist_url = reverse( + 'api:credential_input_source_sublist', + kwargs={'version': 'v2', 'pk': other_external_credential.pk} ) params = { 'source_credential': external_credential.pk, - 'target_credential': other_external_credential.pk, - 'input_field_name': 'token' + 'input_field_name': 'token', + 'associate': True, } - response = post(list_url, params, admin) + response = post(sublist_url, params, admin) assert response.status_code == 400 assert response.data['target_credential'] == ['Target must be a non-external credential'] @pytest.mark.django_db def test_create_credential_input_source_with_non_external_source_returns_400(post, admin, credential, vault_credential): - list_url = reverse( - 'api:credential_input_source_list', - kwargs={'version': 'v2'} + sublist_url = reverse( + 'api:credential_input_source_sublist', + kwargs={'version': 'v2', 'pk': vault_credential.pk} ) params = { 'source_credential': credential.pk, - 'target_credential': vault_credential.pk, 'input_field_name': 'vault_password' } - response = post(list_url, params, admin) + response = post(sublist_url, params, admin) assert response.status_code == 400 assert response.data['source_credential'] == ['Source must be an external credential'] @pytest.mark.django_db def test_create_credential_input_source_with_undefined_input_returns_400(post, admin, vault_credential, external_credential): - list_url = reverse( - 'api:credential_input_source_list', - kwargs={'version': 'v2'} + sublist_url = reverse( + 'api:credential_input_source_sublist', + kwargs={'version': 'v2', 'pk': vault_credential.pk} ) params = { 'source_credential': external_credential.pk, - 'target_credential': vault_credential.pk, 'input_field_name': 'not_defined_for_credential_type' } - response = post(list_url, params, admin) + response = post(sublist_url, params, admin) assert response.status_code == 400 assert response.data['input_field_name'] == ['Input field must be defined on target credential.'] @pytest.mark.django_db def test_create_credential_input_source_with_already_used_input_returns_400(post, admin, vault_credential, external_credential, other_external_credential): - list_url = reverse( - 'api:credential_input_source_list', - kwargs={'version': 'v2'} + sublist_url = reverse( + 'api:credential_input_source_sublist', + kwargs={'version': 'v2', 'pk': vault_credential.pk} ) all_params = [{ 'source_credential': external_credential.pk, - 'target_credential': vault_credential.pk, 'input_field_name': 'vault_password' }, { 'source_credential': other_external_credential.pk, - 'target_credential': vault_credential.pk, 'input_field_name': 'vault_password' }] - all_responses = [post(list_url, params, admin) for params in all_params] + all_responses = [post(sublist_url, params, admin) for params in all_params] assert all_responses.pop().status_code == 400 From 89b731a0cb5cb09be65f4dc6e0aa172c186aaa8c Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Thu, 21 Feb 2019 15:10:36 -0500 Subject: [PATCH 04/74] Improve the HashiCorp Vault KV name and field labels/help_text --- awx/main/credential_plugins/hashivault.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/awx/main/credential_plugins/hashivault.py b/awx/main/credential_plugins/hashivault.py index 27828b4e18..0fd14bd051 100644 --- a/awx/main/credential_plugins/hashivault.py +++ b/awx/main/credential_plugins/hashivault.py @@ -6,27 +6,27 @@ from hvac import Client hashi_inputs = { 'fields': [{ 'id': 'url', - 'label': 'Hashivault Server URL', + 'label': 'Server URL', 'type': 'string', - 'help_text': 'The Hashivault server url.' + 'help_text': 'The URL to the HashiCorp Vault', }, { 'id': 'secret_path', 'label': 'Secret Path', 'type': 'string', - 'help_text': 'The path to the secret.' + 'help_text': 'The path to the secret e.g., /some-engine/some-secret/', }, { 'id': 'secret_field', 'label': 'Secret Field', 'type': 'string', - 'help_text': 'The data field to access on the secret.' + 'help_text': 'The name of the key to look up in the secret.', }, { 'id': 'token', 'label': 'Token', 'type': 'string', 'secret': True, - 'help_text': 'An access token for the Hashivault server.' + 'help_text': 'The access token used to authenticate to the Vault server', }], - 'required': ['url', 'secret_path', 'token'], + 'required': ['url', 'secret_path', 'secret_field', 'token'], } @@ -45,4 +45,4 @@ def hashi_backend(**kwargs): return response['data'] -hashivault_plugin = CredentialPlugin('Hashivault', inputs=hashi_inputs, backend=hashi_backend) +hashivault_plugin = CredentialPlugin('HashiCorp Vault KV Lookup', inputs=hashi_inputs, backend=hashi_backend) From 63997838cdaa968d366040f4cec5c605657f8a0c Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Thu, 21 Feb 2019 15:49:07 -0500 Subject: [PATCH 05/74] support HashiCorp Vault versioned secrets (API v2) --- awx/main/credential_plugins/hashivault.py | 61 ++++++++++++++++++++--- 1 file changed, 55 insertions(+), 6 deletions(-) diff --git a/awx/main/credential_plugins/hashivault.py b/awx/main/credential_plugins/hashivault.py index 0fd14bd051..c78082e809 100644 --- a/awx/main/credential_plugins/hashivault.py +++ b/awx/main/credential_plugins/hashivault.py @@ -1,3 +1,6 @@ +import os +import pathlib + from .plugin import CredentialPlugin from hvac import Client @@ -11,12 +14,12 @@ hashi_inputs = { 'help_text': 'The URL to the HashiCorp Vault', }, { 'id': 'secret_path', - 'label': 'Secret Path', + 'label': 'Path to Secret', 'type': 'string', 'help_text': 'The path to the secret e.g., /some-engine/some-secret/', }, { 'id': 'secret_field', - 'label': 'Secret Field', + 'label': 'Key Name', 'type': 'string', 'help_text': 'The name of the key to look up in the secret.', }, { @@ -25,8 +28,19 @@ hashi_inputs = { 'type': 'string', 'secret': True, 'help_text': 'The access token used to authenticate to the Vault server', + }, { + 'id': 'api_version', + 'label': 'API Version', + 'choices': ['v1', 'v2'], + 'help_text': 'API v1 is for static key/value lookups. API v2 is for versioned key/value lookups.', + 'default': 'v1', + }, { + 'id': 'secret_version', + 'label': 'Secret Version (v2 only)', + 'type': 'string', + 'help_text': 'Used to specify a specific secret version (if left empty, the latest version will be used).', }], - 'required': ['url', 'secret_path', 'secret_field', 'token'], + 'required': ['url', 'secret_path', 'secret_field', 'token', 'api_version'], } @@ -37,12 +51,47 @@ def hashi_backend(**kwargs): secret_field = kwargs.get('secret_field', None) verify = kwargs.get('verify', False) + api_version = kwargs.get('api_version', None) + client = Client(url=url, token=token, verify=verify) - response = client.read(secret_path) + if api_version == 'v2': + try: + mount_point, *path = pathlib.Path(secret_path.lstrip(os.sep)).parts + os.path.join(*path) + response = client.secrets.kv.v2.read_secret_version( + mount_point=mount_point, + path=os.path.join(*path), + version=kwargs.get('secret_version', None) + )['data'] + except Exception: + raise RuntimeError( + 'could not read secret {} from {}'.format(secret_path, url) + ) + else: + try: + response = client.read(secret_path) + except Exception: + raise RuntimeError( + 'could not read secret {} from {}'.format(secret_path, url) + ) + + if response is None: + raise RuntimeError( + 'could not read secret {} from {}'.format(secret_path, url) + ) if secret_field: - return response['data'][secret_field] + try: + return response['data'][secret_field] + except KeyError: + raise RuntimeError( + '{} is not present at {}'.format(secret_field, secret_path) + ) return response['data'] -hashivault_plugin = CredentialPlugin('HashiCorp Vault KV Lookup', inputs=hashi_inputs, backend=hashi_backend) +hashivault_plugin = CredentialPlugin( + 'HashiCorp Vault Secret Lookup', + inputs=hashi_inputs, + backend=hashi_backend +) From 0a8746922526e27b8e900264563d4cade353c71e Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Thu, 21 Feb 2019 18:09:29 -0500 Subject: [PATCH 06/74] give credential plugins an explicit namespace --- awx/main/models/credential/__init__.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/awx/main/models/credential/__init__.py b/awx/main/models/credential/__init__.py index 13fb146672..cf914a3f33 100644 --- a/awx/main/models/credential/__init__.py +++ b/awx/main/models/credential/__init__.py @@ -43,7 +43,10 @@ from . import injectors as builtin_injectors __all__ = ['Credential', 'CredentialType', 'CredentialInputSource', 'V1Credential', 'build_safe_env'] logger = logging.getLogger('awx.main.models.credential') -credential_plugins = [ep.load() for ep in iter_entry_points('awx.credential_plugins')] +credential_plugins = dict( + (ep.name, ep.load()) + for ep in iter_entry_points('awx.credential_plugins') +) HIDDEN_PASSWORD = '**********' @@ -606,9 +609,9 @@ class CredentialType(CommonModelNameNotUnique): created.save() @classmethod - def load_plugin(cls, plugin): + def load_plugin(cls, ns, plugin): ManagedCredentialType( - namespace=plugin.name.lower(), + namespace=ns, name=plugin.name, kind='external', inputs=plugin.inputs @@ -1328,8 +1331,10 @@ class CredentialInputSource(PrimordialModel): super(CredentialInputSource, self).save(*args, **kwargs) def get_input_value(self): - plugin_name = self.source_credential.credential_type.name - [backend] = [p.backend for p in credential_plugins if p.name == plugin_name] + [backend] = [ + plugin.backend for ns, plugin in credential_plugins.items() + if ns == self.source_credential.credential_type.namespace + ] backend_kwargs = {} for field_name, value in self.source_credential.inputs.items(): @@ -1344,5 +1349,5 @@ class CredentialInputSource(PrimordialModel): return reverse(view_name, kwargs={'pk': self.pk}, request=request) -for plugin in credential_plugins: - CredentialType.load_plugin(plugin) +for ns, plugin in credential_plugins.items(): + CredentialType.load_plugin(ns, plugin) From 4ed5bca5e31976496c657036474aa25cd888cdc8 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Fri, 22 Feb 2019 01:21:08 -0500 Subject: [PATCH 07/74] add credential plugin support for Azure Key Vault --- awx/main/credential_plugins/azure_kv.py | 62 +++++++++++++++++++ requirements/requirements.in | 3 +- requirements/requirements.txt | 21 +++++-- setup.py | 1 + .../awx.egg-info/entry_points.txt | 1 + 5 files changed, 81 insertions(+), 7 deletions(-) create mode 100644 awx/main/credential_plugins/azure_kv.py diff --git a/awx/main/credential_plugins/azure_kv.py b/awx/main/credential_plugins/azure_kv.py new file mode 100644 index 0000000000..e695d52e6b --- /dev/null +++ b/awx/main/credential_plugins/azure_kv.py @@ -0,0 +1,62 @@ +from .plugin import CredentialPlugin + +from azure.keyvault import KeyVaultClient, KeyVaultAuthentication +from azure.common.credentials import ServicePrincipalCredentials + + +azure_keyvault_inputs = { + 'fields': [{ + 'id': 'url', + 'label': 'Vault URL (DNS Name)', + 'type': 'string', + }, { + 'id': 'client', + 'label': 'Client ID', + 'type': 'string' + }, { + 'id': 'secret', + 'label': 'Client Secret', + 'type': 'string', + 'secret': True, + }, { + 'id': 'tenant', + 'label': 'Tenant ID', + 'type': 'string' + }, { + 'id': 'secret_field', + 'label': 'Secret Name', + 'type': 'string', + 'help_text': 'The name of the secret to look up.', + }, { + 'id': 'secret_version', + 'label': 'Secret Version', + 'type': 'string', + 'help_text': 'Used to specify a specific secret version (if left empty, the latest version will be used).', + }], + 'required': ['url', 'client', 'secret', 'tenant'], +} + + +def azure_keyvault_backend(**kwargs): + url = kwargs['url'] + + def auth_callback(server, resource, scope): + credentials = ServicePrincipalCredentials( + url = url, + client_id = kwargs['client'], + secret = kwargs['secret'], + tenant = kwargs['tenant'], + resource = "https://vault.azure.net", + ) + token = credentials.token + return token['token_type'], token['access_token'] + + kv = KeyVaultClient(KeyVaultAuthentication(auth_callback)) + return kv.get_secret(url, kwargs['secret_field'], kwargs.get('secret_version', '')).value + + +azure_keyvault_plugin = CredentialPlugin( + 'Microsoft Azure Key Vault', + inputs=azure_keyvault_inputs, + backend=azure_keyvault_backend +) diff --git a/requirements/requirements.in b/requirements/requirements.in index bf8b9cd577..b9be785eae 100644 --- a/requirements/requirements.in +++ b/requirements/requirements.in @@ -2,6 +2,7 @@ ansible-runner==1.3.1 appdirs==1.4.2 asgi-amqp==1.1.3 asgiref==1.1.2 +azure-keyvault==1.1.0 boto==2.47.0 channels==1.1.8 celery==4.2.1 @@ -39,7 +40,7 @@ python-radius==1.0 python3-saml==1.4.0 social-auth-core==3.0.0 social-auth-app-django==2.1.0 -requests<2.16 # Older versions rely on certify +requests==2.21.0 requests-futures==0.9.7 service-identity==17.0.0 slackclient==1.1.2 diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 2137d01e17..fbc87110a1 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -4,6 +4,7 @@ # # pip-compile requirements/requirements.in # +adal==1.2.1 # via msrestazure amqp==2.3.2 # via kombu ansible-runner==1.3.1 appdirs==1.4.2 @@ -14,13 +15,18 @@ asn1crypto==0.24.0 # via cryptography attrs==18.2.0 # via automat, service-identity, twisted autobahn==19.2.1 # via daphne automat==0.7.0 # via twisted +azure-common==1.1.18 # via azure-keyvault +azure-keyvault==1.1.0 +azure-nspkg==3.0.2 # via azure-keyvault billiard==3.5.0.5 # via celery boto==2.47.0 celery==4.2.1 +certifi==2018.11.29 # via msrest, requests cffi==1.12.1 # via cryptography channels==1.1.8 +chardet==3.0.4 # via requests constantly==15.1.0 # via twisted -cryptography==2.5 # via pyopenssl +cryptography==2.5 # via adal, azure-keyvault, pyopenssl daphne==1.3.0 defusedxml==0.5.0 django-auth-ldap==1.7.0 @@ -41,11 +47,11 @@ djangorestframework==3.7.7 future==0.16.0 # via django-radius hvac==0.7.1 hyperlink==18.0.0 # via twisted -idna==2.8 # via hyperlink +idna==2.8 # via hyperlink, requests incremental==17.5.0 # via twisted inflect==2.1.0 # via jaraco.itertools irc==16.2 -isodate==0.6.0 # via python3-saml +isodate==0.6.0 # via msrest, python3-saml jaraco.classes==2.0 # via jaraco.collections jaraco.collections==2.0 # via irc, jaraco.text jaraco.functools==2.0 # via irc, jaraco.text, tempora @@ -62,6 +68,8 @@ markdown==2.6.11 markupsafe==1.1.0 # via jinja2 more-itertools==6.0.0 # via irc, jaraco.functools, jaraco.itertools msgpack-python==0.5.6 # via asgi-amqp +msrest==0.6.4 # via azure-keyvault, msrestazure +msrestazure==0.6.0 # via azure-keyvault netaddr==0.7.19 # via pyrad oauthlib==2.0.6 # via django-oauth-toolkit, requests-oauthlib, social-auth-core ordereddict==1.1 @@ -75,7 +83,7 @@ pyasn1==0.4.5 # via pyasn1-modules, python-ldap, service-identity pycparser==2.19 # via cffi pygerduty==0.37.0 pyhamcrest==1.9.0 # via twisted -pyjwt==1.7.1 # via social-auth-core, twilio +pyjwt==1.7.1 # via adal, social-auth-core, twilio pyopenssl==19.0.0 # via service-identity pyparsing==2.2.0 pyrad==2.1 # via django-radius @@ -90,8 +98,8 @@ python3-saml==1.4.0 pytz==2018.9 # via celery, django, irc, tempora, twilio pyyaml==3.13 # via djangorestframework-yaml requests-futures==0.9.7 -requests-oauthlib==1.2.0 # via social-auth-core -requests[security]==2.15.1 +requests-oauthlib==1.2.0 # via msrest, social-auth-core +requests[security]==2.21.0 service-identity==17.0.0 simplejson==3.16.0 # via uwsgitop six==1.12.0 # via asgi-amqp, asgiref, autobahn, automat, cryptography, django-extensions, irc, isodate, jaraco.classes, jaraco.collections, jaraco.itertools, jaraco.logging, jaraco.stream, pygerduty, pyhamcrest, pyopenssl, pyrad, python-dateutil, python-memcached, slackclient, social-auth-app-django, social-auth-core, tacacs-plus, tempora, twilio, txaio, websocket-client @@ -104,6 +112,7 @@ twilio==6.10.4 twisted==18.9.0 # via daphne txaio==18.8.1 # via autobahn typing==3.6.6 # via django-extensions +urllib3==1.24.1 # via requests uwsgi==2.0.17 uwsgitop==0.10.0 vine==1.2.0 # via amqp diff --git a/setup.py b/setup.py index 38bb8b84f0..237d6df1e4 100755 --- a/setup.py +++ b/setup.py @@ -116,6 +116,7 @@ setup( ], 'awx.credential_plugins': [ 'hashivault = awx.main.credential_plugins.hashivault:hashivault_plugin', + 'azure_kv = awx.main.credential_plugins.azure_kv:azure_keyvault_plugin', ] }, data_files = proc_data_files([ diff --git a/tools/docker-compose/awx.egg-info/entry_points.txt b/tools/docker-compose/awx.egg-info/entry_points.txt index dbfc7331f7..accfb15870 100644 --- a/tools/docker-compose/awx.egg-info/entry_points.txt +++ b/tools/docker-compose/awx.egg-info/entry_points.txt @@ -4,3 +4,4 @@ awx-manage = awx:manage [awx.credential_plugins] hashivault = awx.main.credential_plugins.hashivault:hashivault_plugin +azure_kv = awx.main.credential_plugins.azure_kv:azure_keyvault_plugin From 7a43f00a5df54771a5f97541792093e03488f9f3 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Fri, 22 Feb 2019 14:45:27 -0500 Subject: [PATCH 08/74] add support for HashiCorp signed SSH certificates --- awx/main/credential_plugins/azure_kv.py | 2 +- awx/main/credential_plugins/hashivault.py | 94 ++++++++++++++----- awx/main/models/credential/__init__.py | 10 +- awx/main/tasks.py | 33 ++++++- awx/main/tests/functional/test_credential.py | 4 +- setup.py | 3 +- .../awx.egg-info/entry_points.txt | 3 +- 7 files changed, 114 insertions(+), 35 deletions(-) diff --git a/awx/main/credential_plugins/azure_kv.py b/awx/main/credential_plugins/azure_kv.py index e695d52e6b..2fe8c00ec9 100644 --- a/awx/main/credential_plugins/azure_kv.py +++ b/awx/main/credential_plugins/azure_kv.py @@ -37,7 +37,7 @@ azure_keyvault_inputs = { } -def azure_keyvault_backend(**kwargs): +def azure_keyvault_backend(raw, **kwargs): url = kwargs['url'] def auth_callback(server, resource, scope): diff --git a/awx/main/credential_plugins/hashivault.py b/awx/main/credential_plugins/hashivault.py index c78082e809..555de51c27 100644 --- a/awx/main/credential_plugins/hashivault.py +++ b/awx/main/credential_plugins/hashivault.py @@ -6,22 +6,12 @@ from .plugin import CredentialPlugin from hvac import Client -hashi_inputs = { +base_inputs = { 'fields': [{ 'id': 'url', 'label': 'Server URL', 'type': 'string', 'help_text': 'The URL to the HashiCorp Vault', - }, { - 'id': 'secret_path', - 'label': 'Path to Secret', - 'type': 'string', - 'help_text': 'The path to the secret e.g., /some-engine/some-secret/', - }, { - 'id': 'secret_field', - 'label': 'Key Name', - 'type': 'string', - 'help_text': 'The name of the key to look up in the secret.', }, { 'id': 'token', 'label': 'Token', @@ -29,31 +19,60 @@ hashi_inputs = { 'secret': True, 'help_text': 'The access token used to authenticate to the Vault server', }, { - 'id': 'api_version', - 'label': 'API Version', - 'choices': ['v1', 'v2'], - 'help_text': 'API v1 is for static key/value lookups. API v2 is for versioned key/value lookups.', - 'default': 'v1', + 'id': 'secret_path', + 'label': 'Path to Secret', + 'type': 'string', + 'help_text': 'The path to the secret e.g., /some-engine/some-secret/', + }], + 'required': ['url', 'token', 'secret_path'], +} + +hashi_kv_inputs = { + 'fields': base_inputs['fields'] + [{ + 'id': 'secret_field', + 'label': 'Key Name', + 'type': 'string', + 'help_text': 'The name of the key to look up in the secret.', }, { 'id': 'secret_version', 'label': 'Secret Version (v2 only)', 'type': 'string', 'help_text': 'Used to specify a specific secret version (if left empty, the latest version will be used).', + }, { + 'id': 'api_version', + 'label': 'API Version', + 'choices': ['v1', 'v2'], + 'help_text': 'API v1 is for static key/value lookups. API v2 is for versioned key/value lookups.', + 'default': 'v1', }], - 'required': ['url', 'secret_path', 'secret_field', 'token', 'api_version'], + 'required': base_inputs['required'] + ['secret_field', 'api_version'] +} + +hashi_ssh_inputs = { + 'fields': base_inputs['fields'] + [{ + 'id': 'role', + 'label': 'Role Name', + 'type': 'string', + 'help_text': 'The name of the role used to sign.' + }, { + 'id': 'valid_principals', + 'label': 'Valid Principals', + 'type': 'string', + 'help_text': 'Valid principals (either usernames or hostnames) that the certificate should be signed for.', + }], + 'required': base_inputs['required'] + ['role'] } -def hashi_backend(**kwargs): - token = kwargs.get('token') - url = kwargs.get('url') - secret_path = kwargs.get('secret_path') +def kv_backend(raw, **kwargs): + token = kwargs['token'] + url = kwargs['url'] + secret_path = kwargs['secret_path'] secret_field = kwargs.get('secret_field', None) - verify = kwargs.get('verify', False) api_version = kwargs.get('api_version', None) - client = Client(url=url, token=token, verify=verify) + client = Client(url=url, token=token, verify=True) if api_version == 'v2': try: mount_point, *path = pathlib.Path(secret_path.lstrip(os.sep)).parts @@ -90,8 +109,31 @@ def hashi_backend(**kwargs): return response['data'] -hashivault_plugin = CredentialPlugin( +def ssh_backend(raw, **kwargs): + token = kwargs['token'] + url = kwargs['url'] + + client = Client(url=url, token=token, verify=True) + json = { + 'public_key': raw + } + if kwargs.get('valid_principals'): + json['valid_principals'] = kwargs['valid_principals'] + resp = client._adapter.post( + '/v1/{}/sign/{}'.format(kwargs['secret_path'], kwargs['role']), + json=json, + ) + return resp.json()['data']['signed_key'] + + +hashivault_kv_plugin = CredentialPlugin( 'HashiCorp Vault Secret Lookup', - inputs=hashi_inputs, - backend=hashi_backend + inputs=hashi_kv_inputs, + backend=kv_backend +) + +hashivault_ssh_plugin = CredentialPlugin( + 'HashiCorp Vault Signed SSH', + inputs=hashi_ssh_inputs, + backend=ssh_backend ) diff --git a/awx/main/models/credential/__init__.py b/awx/main/models/credential/__init__.py index cf914a3f33..03c86f6800 100644 --- a/awx/main/models/credential/__init__.py +++ b/awx/main/models/credential/__init__.py @@ -808,6 +808,11 @@ ManagedCredentialType( 'format': 'ssh_private_key', 'secret': True, 'multiline': True + }, { + 'id': 'ssh_public_key_data', + 'label': ugettext_noop('Signed SSH Certificate'), + 'type': 'string', + 'multiline': True, }, { 'id': 'ssh_key_unlock', 'label': ugettext_noop('Private Key Passphrase'), @@ -1342,7 +1347,10 @@ class CredentialInputSource(PrimordialModel): backend_kwargs[field_name] = decrypt_field(self.source_credential, field_name) else: backend_kwargs[field_name] = value - return backend(**backend_kwargs) + return backend( + self.target_credential.inputs.get(self.input_field_name), + **backend_kwargs + ) def get_absolute_url(self, request=None): view_name = 'api:credential_input_source_detail' diff --git a/awx/main/tasks.py b/awx/main/tasks.py index a95074b19d..cb479779e7 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -772,7 +772,12 @@ class BaseTask(object): 'credentials': { : '/path/to/decrypted/data', : '/path/to/decrypted/data', - : '/path/to/decrypted/data', + ... + }, + 'certificates': { + : /path/to/signed/ssh/certificate, + : /path/to/signed/ssh/certificate, + ... } } ''' @@ -787,7 +792,6 @@ class BaseTask(object): # and we're running an earlier version (<6.5). if 'OPENSSH PRIVATE KEY' in data and not openssh_keys_supported: raise RuntimeError(OPENSSH_KEY_ERROR) - for credential, data in private_data.get('credentials', {}).items(): # OpenSSH formatted keys must have a trailing newline to be # accepted by ssh-add. if 'OPENSSH PRIVATE KEY' in data and not data.endswith('\n'): @@ -813,6 +817,13 @@ class BaseTask(object): f.close() os.chmod(path, stat.S_IRUSR | stat.S_IWUSR) private_data_files['credentials'][credential] = path + for credential, data in private_data.get('certificates', {}).items(): + name = 'credential_%d-cert.pub' % credential.pk + path = os.path.join(kwargs['private_data_dir'], name) + with open(path, 'w') as f: + f.write(data) + f.close() + os.chmod(path, stat.S_IRUSR | stat.S_IWUSR) return private_data_files def build_passwords(self, instance, runtime_passwords): @@ -1269,7 +1280,12 @@ class RunJob(BaseTask): 'credentials': { : , : , - : + ... + }, + 'certificates': { + : , + : , + ... } } ''' @@ -1279,6 +1295,8 @@ class RunJob(BaseTask): # back (they will be written to a temporary file). if credential.has_input('ssh_key_data'): private_data['credentials'][credential] = credential.get_input('ssh_key_data', default='') + if credential.has_input('ssh_public_key_data'): + private_data.setdefault('certificates', {})[credential] = credential.get_input('ssh_public_key_data', default='') if credential.kind == 'openstack': openstack_auth = dict(auth_url=credential.get_input('host', default=''), @@ -2187,7 +2205,12 @@ class RunAdHocCommand(BaseTask): 'credentials': { : , : , - : + ... + }, + 'certificates': { + : , + : , + ... } } ''' @@ -2197,6 +2220,8 @@ class RunAdHocCommand(BaseTask): private_data = {'credentials': {}} if creds and creds.has_input('ssh_key_data'): private_data['credentials'][creds] = creds.get_input('ssh_key_data', default='') + if creds and creds.has_input('ssh_public_key_data'): + private_data.setdefault('certificates', {})[creds] = creds.get_input('ssh_public_key_data', default='') return private_data def build_passwords(self, ad_hoc_command, runtime_passwords): diff --git a/awx/main/tests/functional/test_credential.py b/awx/main/tests/functional/test_credential.py index 6ccbfc6344..0dd0f8fdce 100644 --- a/awx/main/tests/functional/test_credential.py +++ b/awx/main/tests/functional/test_credential.py @@ -76,10 +76,12 @@ GLqbpJyX2r3p/Rmo6mLY71SqpA== def test_default_cred_types(): assert sorted(CredentialType.defaults.keys()) == [ 'aws', + 'azure_kv', 'azure_rm', 'cloudforms', 'gce', - 'hashivault', + 'hashivault_kv', + 'hashivault_ssh', 'insights', 'net', 'openstack', diff --git a/setup.py b/setup.py index 237d6df1e4..1fadc25dcf 100755 --- a/setup.py +++ b/setup.py @@ -115,7 +115,8 @@ setup( 'awx-manage = awx:manage', ], 'awx.credential_plugins': [ - 'hashivault = awx.main.credential_plugins.hashivault:hashivault_plugin', + 'hashivault_kv = awx.main.credential_plugins.hashivault:hashivault_kv_plugin', + 'hashivault_ssh = awx.main.credential_plugins.hashivault:hashivault_ssh_plugin', 'azure_kv = awx.main.credential_plugins.azure_kv:azure_keyvault_plugin', ] }, diff --git a/tools/docker-compose/awx.egg-info/entry_points.txt b/tools/docker-compose/awx.egg-info/entry_points.txt index accfb15870..f1832e730c 100644 --- a/tools/docker-compose/awx.egg-info/entry_points.txt +++ b/tools/docker-compose/awx.egg-info/entry_points.txt @@ -3,5 +3,6 @@ tower-manage = awx:manage awx-manage = awx:manage [awx.credential_plugins] -hashivault = awx.main.credential_plugins.hashivault:hashivault_plugin +hashivault_kv = awx.main.credential_plugins.hashivault:hashivault_kv_plugin +hashivault_ssh = awx.main.credential_plugins.hashivault:hashivault_ssh_plugin azure_kv = awx.main.credential_plugins.azure_kv:azure_keyvault_plugin From 0ee223f799db7db93da60860c6223d303353a44e Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Mon, 25 Feb 2019 09:33:43 -0500 Subject: [PATCH 09/74] add api for testing credential plugins --- awx/api/urls/credential.py | 2 + awx/api/urls/credential_type.py | 2 + awx/api/views/__init__.py | 47 ++++++++++++++++ awx/main/models/credential/__init__.py | 16 ++++-- .../credentials/add-credentials.controller.js | 50 ++++++++++++++++- .../add-edit-credentials.view.html | 1 + .../credentials/credentials.strings.js | 5 ++ .../edit-credentials.controller.js | 51 ++++++++++++++++- .../client/lib/components/action/_index.less | 4 +- .../lib/components/form/action.directive.js | 10 ++++ .../lib/components/form/action.partial.html | 4 +- .../lib/components/form/form.directive.js | 55 ++++++++++++------- awx/ui/client/lib/models/Base.js | 11 +++- .../lib/services/base-string.service.js | 1 + 14 files changed, 224 insertions(+), 35 deletions(-) diff --git a/awx/api/urls/credential.py b/awx/api/urls/credential.py index 3143b91c9e..e041e08477 100644 --- a/awx/api/urls/credential.py +++ b/awx/api/urls/credential.py @@ -13,6 +13,7 @@ from awx.api.views import ( CredentialOwnerTeamsList, CredentialCopy, CredentialInputSourceSubList, + CredentialExternalTest, ) @@ -26,6 +27,7 @@ urls = [ url(r'^(?P[0-9]+)/owner_teams/$', CredentialOwnerTeamsList.as_view(), name='credential_owner_teams_list'), url(r'^(?P[0-9]+)/copy/$', CredentialCopy.as_view(), name='credential_copy'), url(r'^(?P[0-9]+)/input_sources/$', CredentialInputSourceSubList.as_view(), name='credential_input_source_sublist'), + url(r'^(?P[0-9]+)/test/$', CredentialExternalTest.as_view(), name='credential_external_test'), ] __all__ = ['urls'] diff --git a/awx/api/urls/credential_type.py b/awx/api/urls/credential_type.py index 22c097523b..5fa033fd33 100644 --- a/awx/api/urls/credential_type.py +++ b/awx/api/urls/credential_type.py @@ -8,6 +8,7 @@ from awx.api.views import ( CredentialTypeDetail, CredentialTypeCredentialList, CredentialTypeActivityStreamList, + CredentialTypeExternalTest, ) @@ -16,6 +17,7 @@ urls = [ url(r'^(?P[0-9]+)/$', CredentialTypeDetail.as_view(), name='credential_type_detail'), url(r'^(?P[0-9]+)/credentials/$', CredentialTypeCredentialList.as_view(), name='credential_type_credential_list'), url(r'^(?P[0-9]+)/activity_stream/$', CredentialTypeActivityStreamList.as_view(), name='credential_type_activity_stream_list'), + url(r'^(?P[0-9]+)/test/$', CredentialTypeExternalTest.as_view(), name='credential_type_external_test'), ] __all__ = ['urls'] diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 3d158315d3..70e56fcb03 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -1419,6 +1419,32 @@ class CredentialCopy(CopyAPIView): copy_return_serializer_class = serializers.CredentialSerializer +class CredentialExternalTest(SubDetailAPIView): + """ + Test updates to the input values of an external credential before + saving them. + """ + + view_name = _('External Credential Test') + + model = models.Credential + serializer_class = serializers.EmptySerializer + + def post(self, request, *args, **kwargs): + obj = self.get_object() + test_inputs = {} + for field_name, value in request.data.get('inputs', {}).items(): + if value == '$encrypted$': + test_inputs[field_name] = obj.get_input(field_name) + else: + test_inputs[field_name] = value + try: + obj.credential_type.plugin.backend(None, **test_inputs) + return Response({}, status=status.HTTP_202_ACCEPTED) + except Exception as exc: + return Response({'inputs': str(exc)}, status=status.HTTP_400_BAD_REQUEST) + + class CredentialInputSourceDetail(RetrieveUpdateDestroyAPIView): view_name = _("Credential Input Source Detail") @@ -1446,6 +1472,27 @@ class CredentialInputSourceSubList(SubListCreateAttachDetachAPIView): parent_key = 'target_credential' +class CredentialTypeExternalTest(SubDetailAPIView): + """ + Test a complete set of input values for an external credential before + saving it. + """ + + view_name = _('External Credential Type Test') + + model = models.CredentialType + serializer_class = serializers.EmptySerializer + + def post(self, request, *args, **kwargs): + obj = self.get_object() + test_inputs = request.data.get('inputs', {}) + try: + obj.plugin.backend(None, **test_inputs) + return Response({}, status=status.HTTP_202_ACCEPTED) + except Exception as exc: + return Response({'inputs': str(exc)}, status=status.HTTP_400_BAD_REQUEST) + + class HostRelatedSearchMixin(object): @property diff --git a/awx/main/models/credential/__init__.py b/awx/main/models/credential/__init__.py index 03c86f6800..eac6fd4d71 100644 --- a/awx/main/models/credential/__init__.py +++ b/awx/main/models/credential/__init__.py @@ -577,6 +577,16 @@ class CredentialType(CommonModelNameNotUnique): if field.get('ask_at_runtime', False) is True ] + @property + def plugin(self): + if self.kind != 'external': + raise AttributeError('plugin') + [plugin] = [ + plugin for ns, plugin in credential_plugins.items() + if ns == self.namespace + ] + return plugin + def default_for_field(self, field_id): for field in self.inputs.get('fields', []): if field['id'] == field_id: @@ -1336,11 +1346,7 @@ class CredentialInputSource(PrimordialModel): super(CredentialInputSource, self).save(*args, **kwargs) def get_input_value(self): - [backend] = [ - plugin.backend for ns, plugin in credential_plugins.items() - if ns == self.source_credential.credential_type.namespace - ] - + backend = self.source_credential.credential_type.plugin.backend backend_kwargs = {} for field_name, value in self.source_credential.inputs.items(): if field_name in self.source_credential.credential_type.secret_fields: diff --git a/awx/ui/client/features/credentials/add-credentials.controller.js b/awx/ui/client/features/credentials/add-credentials.controller.js index 36428d50a8..9193712f80 100644 --- a/awx/ui/client/features/credentials/add-credentials.controller.js +++ b/awx/ui/client/features/credentials/add-credentials.controller.js @@ -4,7 +4,9 @@ function AddCredentialsController ( $scope, strings, componentsStrings, - ConfigService + ConfigService, + ngToast, + $filter ) { const vm = this || {}; @@ -36,6 +38,7 @@ function AddCredentialsController ( vm.form.credential_type._route = 'credentials.add.credentialType'; vm.form.credential_type._model = credentialType; vm.form.credential_type._placeholder = strings.get('inputs.CREDENTIAL_TYPE_PLACEHOLDER'); + vm.isTestable = credentialType.get('kind') === 'external'; const gceFileInputSchema = { id: 'gce_service_account_key', @@ -62,6 +65,8 @@ function AddCredentialsController ( become._choices = Array.from(apiConfig.become_methods, method => method[0]); } + vm.isTestable = credentialType.get('kind') === 'external'; + return fields; }, _source: vm.form.credential_type, @@ -69,6 +74,45 @@ function AddCredentialsController ( _key: 'inputs' }; + vm.form.secondary = ({ inputs }) => { + const name = $filter('sanitize')(credentialType.get('name')); + const endpoint = `${credentialType.get('id')}/test/`; + + return credentialType.http.post({ url: endpoint, data: { inputs }, replace: false }) + .then(() => { + ngToast.success({ + content: ` +
+
+ +
+
+ ${name}: ${strings.get('edit.TEST_PASSED')} +
+
`, + dismissButton: false, + dismissOnTimeout: true + }); + }) + .catch(({ data }) => { + const msg = data.inputs ? `${$filter('sanitize')(data.inputs)}` : strings.get('edit.TEST_FAILED'); + + ngToast.danger({ + content: ` +
+
+ +
+
+ ${name}: ${msg} +
+
`, + dismissButton: false, + dismissOnTimeout: true + }); + }); + }; + vm.form.save = data => { data.user = me.get('id'); @@ -149,7 +193,9 @@ AddCredentialsController.$inject = [ '$scope', 'CredentialsStrings', 'ComponentsStrings', - 'ConfigService' + 'ConfigService', + 'ngToast', + '$filter' ]; export default AddCredentialsController; diff --git a/awx/ui/client/features/credentials/add-edit-credentials.view.html b/awx/ui/client/features/credentials/add-edit-credentials.view.html index dcc9d47e8b..fae203e152 100644 --- a/awx/ui/client/features/credentials/add-edit-credentials.view.html +++ b/awx/ui/client/features/credentials/add-edit-credentials.view.html @@ -23,6 +23,7 @@ + diff --git a/awx/ui/client/features/credentials/credentials.strings.js b/awx/ui/client/features/credentials/credentials.strings.js index cb6260ce55..b198d7f13f 100644 --- a/awx/ui/client/features/credentials/credentials.strings.js +++ b/awx/ui/client/features/credentials/credentials.strings.js @@ -26,6 +26,11 @@ function CredentialsStrings (BaseString) { PANEL_TITLE: t.s('NEW CREDENTIAL') }; + ns.edit = { + TEST_PASSED: t.s('Test passed.'), + TEST_FAILED: t.s('Test failed.') + }; + ns.permissions = { TITLE: t.s('CREDENTIALS PERMISSIONS') }; diff --git a/awx/ui/client/features/credentials/edit-credentials.controller.js b/awx/ui/client/features/credentials/edit-credentials.controller.js index 67f8590f95..1516b71d96 100644 --- a/awx/ui/client/features/credentials/edit-credentials.controller.js +++ b/awx/ui/client/features/credentials/edit-credentials.controller.js @@ -4,7 +4,10 @@ function EditCredentialsController ( $scope, strings, componentsStrings, - ConfigService + ConfigService, + ngToast, + Wait, + $filter, ) { const vm = this || {}; @@ -83,6 +86,7 @@ function EditCredentialsController ( vm.form.credential_type._value = credentialType.get('id'); vm.form.credential_type._displayValue = credentialType.get('name'); vm.form.credential_type._placeholder = strings.get('inputs.CREDENTIAL_TYPE_PLACEHOLDER'); + vm.isTestable = (isEditable && credentialType.get('kind') === 'external'); const gceFileInputSchema = { id: 'gce_service_account_key', @@ -124,6 +128,7 @@ function EditCredentialsController ( } } } + vm.isTestable = (isEditable && credentialType.get('kind') === 'external'); return fields; }, @@ -132,6 +137,45 @@ function EditCredentialsController ( _key: 'inputs' }; + vm.form.secondary = ({ inputs }) => { + const name = $filter('sanitize')(credentialType.get('name')); + const endpoint = `${credential.get('id')}/test/`; + + return credential.http.post({ url: endpoint, data: { inputs }, replace: false }) + .then(() => { + ngToast.success({ + content: ` +
+
+ +
+
+ ${name}: ${strings.get('edit.TEST_PASSED')} +
+
`, + dismissButton: false, + dismissOnTimeout: true + }); + }) + .catch(({ data }) => { + const msg = data.inputs ? `${$filter('sanitize')(data.inputs)}` : strings.get('edit.TEST_FAILED'); + + ngToast.danger({ + content: ` +
+
+ +
+
+ ${name}: ${msg} +
+
`, + dismissButton: false, + dismissOnTimeout: true + }); + }); + }; + /** * If a credential's `credential_type` is changed while editing, the inputs associated with * the old type need to be cleared before saving the inputs associated with the new type. @@ -210,7 +254,10 @@ EditCredentialsController.$inject = [ '$scope', 'CredentialsStrings', 'ComponentsStrings', - 'ConfigService' + 'ConfigService', + 'ngToast', + 'Wait', + '$filter', ]; export default EditCredentialsController; diff --git a/awx/ui/client/lib/components/action/_index.less b/awx/ui/client/lib/components/action/_index.less index 6208e2a41d..c5be9803a3 100644 --- a/awx/ui/client/lib/components/action/_index.less +++ b/awx/ui/client/lib/components/action/_index.less @@ -1,7 +1,7 @@ .at-ActionGroup { margin-top: @at-margin-panel; - button:last-child { - margin-left: @at-margin-panel-inset; + button { + margin-left: 15px; } } diff --git a/awx/ui/client/lib/components/form/action.directive.js b/awx/ui/client/lib/components/form/action.directive.js index 4ba3c7d67a..ab8d6e72a3 100644 --- a/awx/ui/client/lib/components/form/action.directive.js +++ b/awx/ui/client/lib/components/form/action.directive.js @@ -23,6 +23,9 @@ function atFormActionController ($state, strings) { case 'save': vm.setSaveDefaults(); break; + case 'secondary': + vm.setSecondaryDefaults(); + break; default: vm.setCustomDefaults(); } @@ -43,6 +46,13 @@ function atFormActionController ($state, strings) { scope.color = 'success'; scope.action = () => { form.submit(); }; }; + + vm.setSecondaryDefaults = () => { + scope.text = strings.get('TEST'); + scope.fill = ''; + scope.color = 'info'; + scope.action = () => { form.submitSecondary(); }; + }; } atFormActionController.$inject = ['$state', 'ComponentsStrings']; diff --git a/awx/ui/client/lib/components/form/action.partial.html b/awx/ui/client/lib/components/form/action.partial.html index 245c649de1..78c89d0f91 100644 --- a/awx/ui/client/lib/components/form/action.partial.html +++ b/awx/ui/client/lib/components/form/action.partial.html @@ -1,5 +1,7 @@ diff --git a/awx/ui/client/lib/components/form/form.directive.js b/awx/ui/client/lib/components/form/form.directive.js index 97a30911d0..9c008b19e6 100644 --- a/awx/ui/client/lib/components/form/form.directive.js +++ b/awx/ui/client/lib/components/form/form.directive.js @@ -62,6 +62,40 @@ function AtFormController (eventService, strings) { scope.$apply(vm.submit); }; + vm.getSubmitData = () => vm.components + .filter(component => component.category === 'input') + .reduce((values, component) => { + if (component.state._value === undefined) { + return values; + } + + if (component.state._format === 'selectFromOptions') { + values[component.state.id] = component.state._value[0]; + } else if (component.state._key && typeof component.state._value === 'object') { + values[component.state.id] = component.state._value[component.state._key]; + } else if (component.state._group) { + values[component.state._key] = values[component.state._key] || {}; + values[component.state._key][component.state.id] = component.state._value; + } else { + values[component.state.id] = component.state._value; + } + + return values; + }, {}); + + vm.submitSecondary = () => { + if (!vm.state.isValid) { + return; + } + + vm.state.disabled = true; + + const data = vm.getSubmitData(); + + scope.state.secondary(data) + .finally(() => { vm.state.disabled = false; }); + }; + vm.submit = () => { if (!vm.state.isValid) { return; @@ -69,26 +103,7 @@ function AtFormController (eventService, strings) { vm.state.disabled = true; - const data = vm.components - .filter(component => component.category === 'input') - .reduce((values, component) => { - if (component.state._value === undefined) { - return values; - } - - if (component.state._format === 'selectFromOptions') { - values[component.state.id] = component.state._value[0]; - } else if (component.state._key && typeof component.state._value === 'object') { - values[component.state.id] = component.state._value[component.state._key]; - } else if (component.state._group) { - values[component.state._key] = values[component.state._key] || {}; - values[component.state._key][component.state.id] = component.state._value; - } else { - values[component.state.id] = component.state._value; - } - - return values; - }, {}); + const data = vm.getSubmitData(); scope.state.save(data) .then(scope.state.onSaveSuccess) diff --git a/awx/ui/client/lib/models/Base.js b/awx/ui/client/lib/models/Base.js index 1eaa60de87..e5425a3335 100644 --- a/awx/ui/client/lib/models/Base.js +++ b/awx/ui/client/lib/models/Base.js @@ -153,17 +153,22 @@ function httpPost (config = {}) { const req = { method: 'POST', url: this.path, - data: config.data + data: config.data, }; if (config.url) { req.url = `${this.path}${config.url}`; } + if (!('replace' in config)) { + config.replace = true; + } + return $http(req) .then(res => { - this.model.GET = res.data; - + if (config.replace) { + this.model.GET = res.data; + } return res; }); } diff --git a/awx/ui/client/lib/services/base-string.service.js b/awx/ui/client/lib/services/base-string.service.js index 0956c7c5a1..83a860eeb1 100644 --- a/awx/ui/client/lib/services/base-string.service.js +++ b/awx/ui/client/lib/services/base-string.service.js @@ -73,6 +73,7 @@ function BaseStringService (namespace) { this.COPY = t.s('COPY'); this.YES = t.s('YES'); this.CLOSE = t.s('CLOSE'); + this.TEST = t.s('TEST'); this.SUCCESSFUL_CREATION = resource => t.s('{{ resource }} successfully created', { resource: $filter('sanitize')(resource) }); this.deleteResource = { From 69368d874eaafac97b040360996e0a9925792697 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Mon, 25 Feb 2019 13:09:34 -0500 Subject: [PATCH 10/74] move path parameterization to the CredentialInputSource model --- awx/api/serializers.py | 1 + awx/main/credential_plugins/azure_kv.py | 5 +- awx/main/credential_plugins/hashivault.py | 81 ++++---- awx/main/fields.py | 68 ++++++- .../0067_v350_credential_plugins.py | 2 + awx/main/models/credential/__init__.py | 9 +- awx/main/tasks.py | 8 +- .../api/test_credential_input_sources.py | 30 ++- awx/main/tests/functional/conftest.py | 11 +- docs/credential_plugins.md | 180 +++++++++++++++++- 10 files changed, 337 insertions(+), 58 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index ca4262dbed..3f9964fdfc 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -2833,6 +2833,7 @@ class CredentialInputSourceSerializer(BaseSerializer): 'type', 'url', 'input_field_name', + 'metadata', 'target_credential', 'source_credential', 'source_credential_type', diff --git a/awx/main/credential_plugins/azure_kv.py b/awx/main/credential_plugins/azure_kv.py index 2fe8c00ec9..be35720e67 100644 --- a/awx/main/credential_plugins/azure_kv.py +++ b/awx/main/credential_plugins/azure_kv.py @@ -22,7 +22,8 @@ azure_keyvault_inputs = { 'id': 'tenant', 'label': 'Tenant ID', 'type': 'string' - }, { + }], + 'metadata': [{ 'id': 'secret_field', 'label': 'Secret Name', 'type': 'string', @@ -33,7 +34,7 @@ azure_keyvault_inputs = { 'type': 'string', 'help_text': 'Used to specify a specific secret version (if left empty, the latest version will be used).', }], - 'required': ['url', 'client', 'secret', 'tenant'], + 'required': ['url', 'client', 'secret', 'tenant', 'secret_field'], } diff --git a/awx/main/credential_plugins/hashivault.py b/awx/main/credential_plugins/hashivault.py index 555de51c27..c7582eadd6 100644 --- a/awx/main/credential_plugins/hashivault.py +++ b/awx/main/credential_plugins/hashivault.py @@ -1,3 +1,4 @@ +import copy import os import pathlib @@ -18,7 +19,8 @@ base_inputs = { 'type': 'string', 'secret': True, 'help_text': 'The access token used to authenticate to the Vault server', - }, { + }], + 'metadata': [{ 'id': 'secret_path', 'label': 'Path to Secret', 'type': 'string', @@ -27,50 +29,49 @@ base_inputs = { 'required': ['url', 'token', 'secret_path'], } -hashi_kv_inputs = { - 'fields': base_inputs['fields'] + [{ - 'id': 'secret_field', - 'label': 'Key Name', - 'type': 'string', - 'help_text': 'The name of the key to look up in the secret.', - }, { - 'id': 'secret_version', - 'label': 'Secret Version (v2 only)', - 'type': 'string', - 'help_text': 'Used to specify a specific secret version (if left empty, the latest version will be used).', - }, { - 'id': 'api_version', - 'label': 'API Version', - 'choices': ['v1', 'v2'], - 'help_text': 'API v1 is for static key/value lookups. API v2 is for versioned key/value lookups.', - 'default': 'v1', - }], - 'required': base_inputs['required'] + ['secret_field', 'api_version'] -} +hashi_kv_inputs = copy.deepcopy(base_inputs) +hashi_kv_inputs['fields'].append({ + 'id': 'api_version', + 'label': 'API Version', + 'choices': ['v1', 'v2'], + 'help_text': 'API v1 is for static key/value lookups. API v2 is for versioned key/value lookups.', + 'default': 'v1', +}) +hashi_kv_inputs['metadata'].extend([{ + 'id': 'secret_key', + 'label': 'Key Name', + 'type': 'string', + 'help_text': 'The name of the key to look up in the secret.', +}, { + 'id': 'secret_version', + 'label': 'Secret Version (v2 only)', + 'type': 'string', + 'help_text': 'Used to specify a specific secret version (if left empty, the latest version will be used).', +}]) +hashi_kv_inputs['required'].extend(['api_version', 'secret_key']) -hashi_ssh_inputs = { - 'fields': base_inputs['fields'] + [{ - 'id': 'role', - 'label': 'Role Name', - 'type': 'string', - 'help_text': 'The name of the role used to sign.' - }, { - 'id': 'valid_principals', - 'label': 'Valid Principals', - 'type': 'string', - 'help_text': 'Valid principals (either usernames or hostnames) that the certificate should be signed for.', - }], - 'required': base_inputs['required'] + ['role'] -} +hashi_ssh_inputs = copy.deepcopy(base_inputs) +hashi_ssh_inputs['metadata'].extend([{ + 'id': 'role', + 'label': 'Role Name', + 'type': 'string', + 'help_text': 'The name of the role used to sign.' +}, { + 'id': 'valid_principals', + 'label': 'Valid Principals', + 'type': 'string', + 'help_text': 'Valid principals (either usernames or hostnames) that the certificate should be signed for.', +}]) +hashi_ssh_inputs['required'].extend(['role']) def kv_backend(raw, **kwargs): token = kwargs['token'] url = kwargs['url'] secret_path = kwargs['secret_path'] - secret_field = kwargs.get('secret_field', None) + secret_key = kwargs.get('secret_key', None) - api_version = kwargs.get('api_version', None) + api_version = kwargs['api_version'] client = Client(url=url, token=token, verify=True) if api_version == 'v2': @@ -99,12 +100,12 @@ def kv_backend(raw, **kwargs): 'could not read secret {} from {}'.format(secret_path, url) ) - if secret_field: + if secret_key: try: - return response['data'][secret_field] + return response['data'][secret_key] except KeyError: raise RuntimeError( - '{} is not present at {}'.format(secret_field, secret_path) + '{} is not present at {}'.format(secret_key, secret_path) ) return response['data'] diff --git a/awx/main/fields.py b/awx/main/fields.py index f8a20738ef..acd8a4f1a5 100644 --- a/awx/main/fields.py +++ b/awx/main/fields.py @@ -480,6 +480,69 @@ def format_ssh_private_key(value): return True +class DynamicCredentialInputField(JSONSchemaField): + """ + Used to validate JSON for + `awx.main.models.credential:CredentialInputSource().metadata`. + + Metadata for input sources is represented as a dictionary e.g., + {'secret_path': '/kv/somebody', 'secret_key': 'password'} + + For the data to be valid, the keys of this dictionary should correspond + with the metadata field (and datatypes) defined in the associated + target CredentialType e.g., + """ + + def schema(self, credential_type): + # determine the defined fields for the associated credential type + properties = {} + for field in credential_type.inputs.get('metadata', []): + field = field.copy() + properties[field['id']] = field + if field.get('choices', []): + field['enum'] = list(field['choices'])[:] + return { + 'type': 'object', + 'properties': properties, + 'additionalProperties': False, + } + + def validate(self, value, model_instance): + if not isinstance(value, dict): + return super(DynamicCredentialInputField, self).validate(value, model_instance) + + super(JSONSchemaField, self).validate(value, model_instance) + credential_type = model_instance.source_credential.credential_type + errors = {} + for error in Draft4Validator( + self.schema(credential_type), + format_checker=self.format_checker + ).iter_errors(value): + if error.validator == 'pattern' and 'error' in error.schema: + error.message = error.schema['error'].format(instance=error.instance) + if 'id' not in error.schema: + # If the error is not for a specific field, it's specific to + # `inputs` in general + raise django_exceptions.ValidationError( + error.message, + code='invalid', + params={'value': value}, + ) + errors[error.schema['id']] = [error.message] + + defined_metadata = [field.get('id') for field in credential_type.inputs.get('metadata', [])] + for field in credential_type.inputs.get('required', []): + if field in defined_metadata and not value.get(field, None): + errors[field] = [_('required for %s') % ( + credential_type.name + )] + + if errors: + raise serializers.ValidationError({ + 'metadata': errors + }) + + class CredentialInputField(JSONSchemaField): """ Used to validate JSON for @@ -593,8 +656,9 @@ class CredentialInputField(JSONSchemaField): errors[error.schema['id']] = [error.message] inputs = model_instance.credential_type.inputs + defined_fields = model_instance.credential_type.defined_fields for field in inputs.get('required', []): - if not value.get(field, None): + if field in defined_fields and not value.get(field, None): errors[field] = [_('required for %s') % ( model_instance.credential_type.name )] @@ -603,7 +667,7 @@ class CredentialInputField(JSONSchemaField): # represented without complicated JSON schema if ( model_instance.credential_type.managed_by_tower is True and - 'ssh_key_unlock' in model_instance.credential_type.defined_fields + 'ssh_key_unlock' in defined_fields ): # in order to properly test the necessity of `ssh_key_unlock`, we diff --git a/awx/main/migrations/0067_v350_credential_plugins.py b/awx/main/migrations/0067_v350_credential_plugins.py index 0d0e65255b..427660b045 100644 --- a/awx/main/migrations/0067_v350_credential_plugins.py +++ b/awx/main/migrations/0067_v350_credential_plugins.py @@ -7,6 +7,7 @@ import django.db.models.deletion import taggit.managers # AWX +import awx.main.fields from awx.main.models import CredentialType @@ -31,6 +32,7 @@ class Migration(migrations.Migration): ('modified', models.DateTimeField(default=None, editable=False)), ('description', models.TextField(blank=True, default='')), ('input_field_name', models.CharField(max_length=1024)), + ('metadata', awx.main.fields.DynamicCredentialInputField(blank=True, default={})), ('created_by', models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{'class': 'credentialinputsource', 'model_name': 'credentialinputsource', 'app_label': 'main'}(class)s_created+", to=settings.AUTH_USER_MODEL)), ('modified_by', models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{'class': 'credentialinputsource', 'model_name': 'credentialinputsource', 'app_label': 'main'}(class)s_modified+", to=settings.AUTH_USER_MODEL)), ('source_credential', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='target_input_source', to='main.Credential')), diff --git a/awx/main/models/credential/__init__.py b/awx/main/models/credential/__init__.py index eac6fd4d71..f8597ba4dc 100644 --- a/awx/main/models/credential/__init__.py +++ b/awx/main/models/credential/__init__.py @@ -23,7 +23,8 @@ from django.utils.encoding import force_text from awx.api.versioning import reverse from awx.main.fields import (ImplicitRoleField, CredentialInputField, CredentialTypeInputField, - CredentialTypeInjectorField) + CredentialTypeInjectorField, + DynamicCredentialInputField,) from awx.main.utils import decrypt_field, classproperty from awx.main.utils.safe_yaml import safe_dump from awx.main.validators import validate_ssh_private_key @@ -1325,6 +1326,10 @@ class CredentialInputSource(PrimordialModel): input_field_name = models.CharField( max_length=1024, ) + metadata = DynamicCredentialInputField( + blank=True, + default={} + ) def clean_target_credential(self): if self.target_credential.kind == 'external': @@ -1353,6 +1358,8 @@ class CredentialInputSource(PrimordialModel): backend_kwargs[field_name] = decrypt_field(self.source_credential, field_name) else: backend_kwargs[field_name] = value + + backend_kwargs.update(self.metadata) return backend( self.target_credential.inputs.get(self.input_field_name), **backend_kwargs diff --git a/awx/main/tasks.py b/awx/main/tasks.py index cb479779e7..da9dbdb202 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -1283,8 +1283,8 @@ class RunJob(BaseTask): ... }, 'certificates': { - : , - : , + : , + : , ... } } @@ -2208,8 +2208,8 @@ class RunAdHocCommand(BaseTask): ... }, 'certificates': { - : , - : , + : , + : , ... } } diff --git a/awx/main/tests/functional/api/test_credential_input_sources.py b/awx/main/tests/functional/api/test_credential_input_sources.py index 4f0cb371a6..516bb54f67 100644 --- a/awx/main/tests/functional/api/test_credential_input_sources.py +++ b/awx/main/tests/functional/api/test_credential_input_sources.py @@ -15,6 +15,7 @@ def test_associate_credential_input_source(get, post, admin, vault_credential, e params = { 'source_credential': external_credential.pk, 'input_field_name': 'vault_password', + 'metadata': {'key': 'some_example_key'}, 'associate': True } response = post(sublist_url, params, admin) @@ -31,6 +32,8 @@ def test_associate_credential_input_source(get, post, admin, vault_credential, e kwargs={'version': 'v2'} ), admin).data['count'] == 1 assert CredentialInputSource.objects.count() == 1 + input_source = CredentialInputSource.objects.first() + assert input_source.metadata == {'key': 'some_example_key'} # detach params = { @@ -50,6 +53,29 @@ def test_associate_credential_input_source(get, post, admin, vault_credential, e assert CredentialInputSource.objects.count() == 0 +@pytest.mark.django_db +@pytest.mark.parametrize('metadata', [ + {}, # key is required + {'key': None}, # must be a string + {'key': 123}, # must be a string + {'extraneous': 'foo'}, # invalid parameter +]) +def test_associate_credential_input_source_with_invalid_metadata(get, post, admin, vault_credential, external_credential, metadata): + sublist_url = reverse( + 'api:credential_input_source_sublist', + kwargs={'version': 'v2', 'pk': vault_credential.pk} + ) + + params = { + 'source_credential': external_credential.pk, + 'input_field_name': 'vault_password', + 'metadata': metadata, + 'associate': True + } + response = post(sublist_url, params, admin) + assert response.status_code == 400 + + @pytest.mark.django_db def test_cannot_create_from_list(get, post, admin, vault_credential, external_credential): params = { @@ -73,6 +99,7 @@ def test_create_credential_input_source_with_external_target_returns_400(post, a 'source_credential': external_credential.pk, 'input_field_name': 'token', 'associate': True, + 'metadata': {'key': 'some_key'}, } response = post(sublist_url, params, admin) assert response.status_code == 400 @@ -102,7 +129,8 @@ def test_create_credential_input_source_with_undefined_input_returns_400(post, a ) params = { 'source_credential': external_credential.pk, - 'input_field_name': 'not_defined_for_credential_type' + 'input_field_name': 'not_defined_for_credential_type', + 'metadata': {'key': 'some_key'} } response = post(sublist_url, params, admin) assert response.status_code == 400 diff --git a/awx/main/tests/functional/conftest.py b/awx/main/tests/functional/conftest.py index 8dcbc381a4..625ff0c007 100644 --- a/awx/main/tests/functional/conftest.py +++ b/awx/main/tests/functional/conftest.py @@ -265,7 +265,16 @@ def credentialtype_external(): 'secret': True, 'help_text': 'An access token for the server.' }], - 'required': ['url', 'token'], + 'metadata': [{ + 'id': 'key', + 'label': 'Key', + 'type': 'string' + }, { + 'id': 'version', + 'label': 'Version', + 'type': 'string' + }], + 'required': ['url', 'token', 'key'], } external_type = CredentialType( kind='external', diff --git a/docs/credential_plugins.md b/docs/credential_plugins.md index 3cf07cf6c5..3067bb6de2 100644 --- a/docs/credential_plugins.md +++ b/docs/credential_plugins.md @@ -1,9 +1,175 @@ Credential Plugins -================================ -### Development -```shell -# The vault server will be available at localhost:8200. -# From within the awx container, the vault server is reachable at hashivault:8200. -# See docker-hashivault-override.yml for the development root token. -make docker-compose-hashivault +================== + +By default, sensitive credential values (such as SSH passwords, SSH private +keys, API tokens for cloud services) in AWX are encrypted with a symmetric +encryption cipher utilizing AES-256 in CBC mode alongside a SHA-256 HMAC and +stored in the AWX database. + +Alternatively, AWX supports retrieving secret values from third-party secret +management systems, such as HashiCorp Vault and Microsoft Azure Key Vault. +These external secret values will be fetched on demand every time they are +needed (generally speaking, immediately before running a playbook that needs +them). + +Configuring Secret Lookups +-------------------------- + +When configuring AWX to pull a secret from a third party system, there are +generally three steps. + +Here is an example of creating an (1) AWX Machine Credential with +a static username, `example-user` and (2) an externally sourced secret from +HashiCorp Vault Key/Value system which will populate the (3) password field on +the Machine Credential. + +1. Create the Machine Credential with a static username, `example-user`. + + ```shell + HTTP POST https://awx.example.org/api/v2/credentials/ + + { + "organization": X, + "credential_type": , + "inputs": { + "username": "example-user" + } + } + +2. Create a second credential used to _authenticate_ with the external + secret management system (e.g.,, in this example, specifying a URL and an + OAuth2.0 token _to access_ HashiCorp Vault) + + ```shell + HTTP POST https://awx.example.org/api/v2/credentials/ + { + "organization": X, + "credential_type": , + "inputs": { + "url": "https://my-vault.example.org", + "token": "some-oauth-token", + "api_version": "v2", + } + } + +3. _Link_ the `password` field for the Machine credential to the external + system by specifying the source (in this example, the HashiCorp credential) + and metadata about the path (e.g., `/some/path/to/my/password/`). + + ```shell + HTTP POST https://awx.example.org/api/v2/credentials/N/input_sources/ + { + "input_field_name": "", + "source_credential": + "metadata": { + "secret_path": "/path/to/my/secret/" + "secret_field": "password" + "secret_version": 92 + } + } + ``` + +Note that you can perform these lookups on *any* credential field - not just +the `password` field for Machine credentials. You could just as easily create +an AWS credential and use lookups to retrieve the Access Key and Secret Key +from an external secret management system. + +Writing Custom Credential Plugins +--------------------------------- + +Credential Plugins in AWX are just importable Python functions that are +registered using setuptools entrypoints +(https://setuptools.readthedocs.io/en/latest/setuptools.html#dynamic-discovery-of-services-and-plugins) + +Example plugins officially supported in AWX can be found in the source code at +`awx.main.credential_plugins`. + +Credential plugins are any Python object which defines attribute lookups for `.name`, `.inputs`, and `.backend`: + +```python +import collections + +CredentialPlugin = collections.namedtuple('CredentialPlugin', ['name', 'inputs', 'backend']) + +def some_callable(value_from_awx, **kwargs): + return some_libary.get_secret_key( + url=kwargs['url'], + token=kwargs['token'], + key=kwargs['secret_key'] + ) + +some_fancy_plugin = CredentialPlugin( + 'My Plugin Name', + # inputs will be used to create a new CredentialType() instance + # + # inputs.fields represents fields the user will specify *when they create* + # a credential of this type; they generally represent fields + # used for authentication (URL to the credential management system, any + # fields necessary for authentication, such as an OAuth2.0 token, or + # a username and password). They're the types of values you set up _once_ + # in AWX + # + # inputs.metadata represents values the user will specify *every time + # they link two credentials together* + # this is generally _pathing_ information about _where_ in the external + # management system you can find the value you care about i.e., + # + # "I would like Machine Credential A to retrieve its username using + # Credential-O-Matic B at secret_key=some_key" + inputs={ + 'fields': [{ + 'id': 'url', + 'label': 'Server URL', + 'type': 'string', + }, { + 'id': 'token', + 'label': 'Authentication Token', + 'type': 'string', + 'secret': True, + }], + 'metadata': [{ + 'id': 'secret_key', + 'label': 'Secret Key', + 'type': 'string', + 'help_text': 'The value of the key in My Credential System to fetch.' + }], + 'required': ['url', 'token', 'secret_key'], + }, + # backend is a callable function which will be passed all of the values + # defined in `inputs`; this function is responsible for taking the arguments, + # interacting with the third party credential management system in question + # using Python code, and returning the value from the third party + # credential management system + backend = some_callable +``` + +Plugins are registered by specifying an entry point in the `setuptools.setup()` +call (generally in the package's `setup.py` file - https://github.com/ansible/awx/blob/devel/setup.py): + +```python +setuptools.setup( + ..., + entry_points = { + ..., + 'awx.credential_plugins': [ + 'fancy_plugin = awx.main.credential_plugins.fancy:some_fancy_plugin', + ] + } +) +``` + +Fetching vs. Transforming Credential Data +----------------------------------------- +While _most_ credential plugins will be used to _fetch_ secrets from external +systems, they can also be used to *transform* data from Tower _using_ an +external secret management system. An example use case is generating signed +public keys: + +```python +def my_key_signer(unsigned_value_from_awx, **kwargs): + return some_libary.sign( + url=kwargs['url'], + token=kwargs['token'], + public_data=unsigned_value_from_awx + ) ``` From b851e2be4a8d7c69436f3c5aedf93b09c40703ae Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Mon, 25 Feb 2019 20:56:59 -0500 Subject: [PATCH 11/74] don't add hvac as a dependency for hashicorp vault integration hvac is just based on requests anyways, and it doesn't support half of what we need (like the SSH secrets engine API) --- awx/main/credential_plugins/hashivault.py | 63 +++---- docs/credential_plugins.md | 19 ++ docs/licenses/hvac.txt | 201 ---------------------- requirements/requirements.in | 1 - requirements/requirements.txt | 1 - 5 files changed, 52 insertions(+), 233 deletions(-) delete mode 100644 docs/licenses/hvac.txt diff --git a/awx/main/credential_plugins/hashivault.py b/awx/main/credential_plugins/hashivault.py index c7582eadd6..a5ca93f82c 100644 --- a/awx/main/credential_plugins/hashivault.py +++ b/awx/main/credential_plugins/hashivault.py @@ -1,10 +1,11 @@ import copy import os import pathlib +from urllib.parse import urljoin from .plugin import CredentialPlugin -from hvac import Client +import requests base_inputs = { @@ -67,63 +68,65 @@ hashi_ssh_inputs['required'].extend(['role']) def kv_backend(raw, **kwargs): token = kwargs['token'] - url = kwargs['url'] + url = urljoin(kwargs['url'], 'v1') secret_path = kwargs['secret_path'] secret_key = kwargs.get('secret_key', None) api_version = kwargs['api_version'] - client = Client(url=url, token=token, verify=True) + sess = requests.Session() + sess.headers['Authorization'] = 'Bearer {}'.format(token) if api_version == 'v2': + params = {} + if kwargs.get('secret_version'): + params['version'] = kwargs['secret_version'] try: mount_point, *path = pathlib.Path(secret_path.lstrip(os.sep)).parts - os.path.join(*path) - response = client.secrets.kv.v2.read_secret_version( - mount_point=mount_point, - path=os.path.join(*path), - version=kwargs.get('secret_version', None) - )['data'] + '/'.join(*path) except Exception: - raise RuntimeError( - 'could not read secret {} from {}'.format(secret_path, url) - ) - else: - try: - response = client.read(secret_path) - except Exception: - raise RuntimeError( - 'could not read secret {} from {}'.format(secret_path, url) - ) - - if response is None: - raise RuntimeError( - 'could not read secret {} from {}'.format(secret_path, url) + mount_point, path = secret_path, [] + # https://www.vaultproject.io/api/secret/kv/kv-v2.html#read-secret-version + response = sess.get( + '/'.join([url, mount_point, 'data'] + path).rstrip('/'), + params=params ) + response.raise_for_status() + json = response.json()['data'] + else: + # https://www.vaultproject.io/api/secret/kv/kv-v1.html#read-secret + response = sess.get('/'.join([url, secret_path]).rstrip('/')) + response.raise_for_status() + json = response.json() if secret_key: try: - return response['data'][secret_key] + return json['data'][secret_key] except KeyError: raise RuntimeError( '{} is not present at {}'.format(secret_key, secret_path) ) - return response['data'] + return json['data'] def ssh_backend(raw, **kwargs): token = kwargs['token'] - url = kwargs['url'] + url = urljoin(kwargs['url'], 'v1') + secret_path = kwargs['secret_path'] + role = kwargs['role'] - client = Client(url=url, token=token, verify=True) + sess = requests.Session() + sess.headers['Authorization'] = 'Bearer {}'.format(token) json = { 'public_key': raw } if kwargs.get('valid_principals'): json['valid_principals'] = kwargs['valid_principals'] - resp = client._adapter.post( - '/v1/{}/sign/{}'.format(kwargs['secret_path'], kwargs['role']), - json=json, + # https://www.vaultproject.io/api/secret/ssh/index.html#sign-ssh-key + resp = sess.post( + '/'.join([url, secret_path, 'sign', role]).rstrip('/'), + json=json ) + resp.raise_for_status() return resp.json()['data']['signed_key'] diff --git a/docs/credential_plugins.md b/docs/credential_plugins.md index 3067bb6de2..15aa882e1b 100644 --- a/docs/credential_plugins.md +++ b/docs/credential_plugins.md @@ -173,3 +173,22 @@ def my_key_signer(unsigned_value_from_awx, **kwargs): public_data=unsigned_value_from_awx ) ``` + +Programmatic Secret Fetching +---------------------------- +If you want to programmatically fetch secrets from a supported external secret +management system (for example, if you wanted to compose an AWX database connection +string in `/etc/tower/conf.d/postgres.py` using an external system rather than +storing the password in plaintext on your disk), doing so is fairly easy: + +```python +from awx.main.credential_plugins import hashivault +hashivault.hashivault_kv_plugin.backend( + '', + url='https://hcv.example.org', + token='some-valid-token', + api_version='v2', + secret_path='/path/to/secret', + secret_key='dbpass' +) +``` diff --git a/docs/licenses/hvac.txt b/docs/licenses/hvac.txt deleted file mode 100644 index 8dada3edaf..0000000000 --- a/docs/licenses/hvac.txt +++ /dev/null @@ -1,201 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "{}" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright {yyyy} {name of copyright owner} - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/requirements/requirements.in b/requirements/requirements.in index b9be785eae..0fa2258ebf 100644 --- a/requirements/requirements.in +++ b/requirements/requirements.in @@ -50,4 +50,3 @@ uWSGI==2.0.17 uwsgitop==0.10.0 pip==9.0.1 setuptools==36.0.1 -hvac==0.7.1 diff --git a/requirements/requirements.txt b/requirements/requirements.txt index fbc87110a1..7816cfe85c 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -45,7 +45,6 @@ django==1.11.16 djangorestframework-yaml==1.0.3 djangorestframework==3.7.7 future==0.16.0 # via django-radius -hvac==0.7.1 hyperlink==18.0.0 # via twisted idna==2.8 # via hyperlink, requests incremental==17.5.0 # via twisted From dcf17683e22f64b3342f58a975bb554f394e96ed Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Mon, 25 Feb 2019 22:52:47 -0500 Subject: [PATCH 12/74] mark cred plugin strings for translation --- awx/main/credential_plugins/azure_kv.py | 17 ++++++------ awx/main/credential_plugins/hashivault.py | 33 ++++++++++++----------- 2 files changed, 26 insertions(+), 24 deletions(-) diff --git a/awx/main/credential_plugins/azure_kv.py b/awx/main/credential_plugins/azure_kv.py index be35720e67..38c6845715 100644 --- a/awx/main/credential_plugins/azure_kv.py +++ b/awx/main/credential_plugins/azure_kv.py @@ -1,5 +1,6 @@ from .plugin import CredentialPlugin +from django.utils.translation import ugettext_lazy as _ from azure.keyvault import KeyVaultClient, KeyVaultAuthentication from azure.common.credentials import ServicePrincipalCredentials @@ -7,32 +8,32 @@ from azure.common.credentials import ServicePrincipalCredentials azure_keyvault_inputs = { 'fields': [{ 'id': 'url', - 'label': 'Vault URL (DNS Name)', + 'label': _('Vault URL (DNS Name)'), 'type': 'string', }, { 'id': 'client', - 'label': 'Client ID', + 'label': _('Client ID'), 'type': 'string' }, { 'id': 'secret', - 'label': 'Client Secret', + 'label': _('Client Secret'), 'type': 'string', 'secret': True, }, { 'id': 'tenant', - 'label': 'Tenant ID', + 'label': _('Tenant ID'), 'type': 'string' }], 'metadata': [{ 'id': 'secret_field', - 'label': 'Secret Name', + 'label': _('Secret Name'), 'type': 'string', - 'help_text': 'The name of the secret to look up.', + 'help_text': _('The name of the secret to look up.'), }, { 'id': 'secret_version', - 'label': 'Secret Version', + 'label': _('Secret Version'), 'type': 'string', - 'help_text': 'Used to specify a specific secret version (if left empty, the latest version will be used).', + 'help_text': _('Used to specify a specific secret version (if left empty, the latest version will be used).'), }], 'required': ['url', 'client', 'secret', 'tenant', 'secret_field'], } diff --git a/awx/main/credential_plugins/hashivault.py b/awx/main/credential_plugins/hashivault.py index a5ca93f82c..89519492b9 100644 --- a/awx/main/credential_plugins/hashivault.py +++ b/awx/main/credential_plugins/hashivault.py @@ -6,26 +6,27 @@ from urllib.parse import urljoin from .plugin import CredentialPlugin import requests +from django.utils.translation import ugettext_lazy as _ base_inputs = { 'fields': [{ 'id': 'url', - 'label': 'Server URL', + 'label': _('Server URL'), 'type': 'string', - 'help_text': 'The URL to the HashiCorp Vault', + 'help_text': _('The URL to the HashiCorp Vault'), }, { 'id': 'token', - 'label': 'Token', + 'label': _('Token'), 'type': 'string', 'secret': True, - 'help_text': 'The access token used to authenticate to the Vault server', + 'help_text': _('The access token used to authenticate to the Vault server'), }], 'metadata': [{ 'id': 'secret_path', - 'label': 'Path to Secret', + 'label': _('Path to Secret'), 'type': 'string', - 'help_text': 'The path to the secret e.g., /some-engine/some-secret/', + 'help_text': _('The path to the secret e.g., /some-engine/some-secret/'), }], 'required': ['url', 'token', 'secret_path'], } @@ -33,35 +34,35 @@ base_inputs = { hashi_kv_inputs = copy.deepcopy(base_inputs) hashi_kv_inputs['fields'].append({ 'id': 'api_version', - 'label': 'API Version', + 'label': _('API Version'), 'choices': ['v1', 'v2'], - 'help_text': 'API v1 is for static key/value lookups. API v2 is for versioned key/value lookups.', + 'help_text': _('API v1 is for static key/value lookups. API v2 is for versioned key/value lookups.'), 'default': 'v1', }) hashi_kv_inputs['metadata'].extend([{ 'id': 'secret_key', - 'label': 'Key Name', + 'label': _('Key Name'), 'type': 'string', - 'help_text': 'The name of the key to look up in the secret.', + 'help_text': _('The name of the key to look up in the secret.'), }, { 'id': 'secret_version', - 'label': 'Secret Version (v2 only)', + 'label': _('Secret Version (v2 only)'), 'type': 'string', - 'help_text': 'Used to specify a specific secret version (if left empty, the latest version will be used).', + 'help_text': _('Used to specify a specific secret version (if left empty, the latest version will be used).'), }]) hashi_kv_inputs['required'].extend(['api_version', 'secret_key']) hashi_ssh_inputs = copy.deepcopy(base_inputs) hashi_ssh_inputs['metadata'].extend([{ 'id': 'role', - 'label': 'Role Name', + 'label': _('Role Name'), 'type': 'string', - 'help_text': 'The name of the role used to sign.' + 'help_text': _('The name of the role used to sign.') }, { 'id': 'valid_principals', - 'label': 'Valid Principals', + 'label': _('Valid Principals'), 'type': 'string', - 'help_text': 'Valid principals (either usernames or hostnames) that the certificate should be signed for.', + 'help_text': _('Valid principals (either usernames or hostnames) that the certificate should be signed for.'), }]) hashi_ssh_inputs['required'].extend(['role']) From 35cca68f04366456368559f81d1126f711952a32 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Tue, 26 Feb 2019 11:46:38 -0500 Subject: [PATCH 13/74] add RBAC definitions for CredentialInputSource --- awx/main/access.py | 39 ++++- .../api/test_credential_input_sources.py | 144 ++++++++++++++++++ 2 files changed, 177 insertions(+), 6 deletions(-) diff --git a/awx/main/access.py b/awx/main/access.py index d13f4ed682..6f0aec60b2 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -1165,16 +1165,43 @@ class CredentialAccess(BaseAccess): class CredentialInputSourceAccess(BaseAccess): ''' - I can see credential input sources when: - - I'm a superuser (TODO: Update) - I can create credential input sources when: - - I'm a superuser (TODO: Update) - I can delete credential input sources when: - - I'm a superuser (TODO: Update) + I can see a CredentialInputSource when: + - I can see the associated target_credential + I can create/change a CredentialInputSource when: + - I'm an admin of the associated target_credential + - I have use access to the associated source credential + I can delete a CredentialInputSource when: + - I'm an admin of the associated target_credential ''' model = CredentialInputSource + def filtered_queryset(self): + return CredentialInputSource.objects.filter( + target_credential__in=Credential.accessible_pk_qs(self.user, 'read_role')) + + @check_superuser + def can_read(self, obj): + return self.user in obj.target_credential.read_role + + @check_superuser + def can_add(self, data): + return ( + self.check_related('target_credential', Credential, data, role_field='admin_role') and + self.check_related('source_credential', Credential, data, role_field='use_role') + ) + + @check_superuser + def can_change(self, obj, data): + if self.can_add(data) is False: + return False + + return self.user in obj.target_credential.admin_role + + @check_superuser + def can_delete(self, obj): + return self.user in obj.target_credential.admin_role + class TeamAccess(BaseAccess): ''' diff --git a/awx/main/tests/functional/api/test_credential_input_sources.py b/awx/main/tests/functional/api/test_credential_input_sources.py index 516bb54f67..a4237083c0 100644 --- a/awx/main/tests/functional/api/test_credential_input_sources.py +++ b/awx/main/tests/functional/api/test_credential_input_sources.py @@ -106,6 +106,150 @@ def test_create_credential_input_source_with_external_target_returns_400(post, a assert response.data['target_credential'] == ['Target must be a non-external credential'] +@pytest.mark.django_db +def test_input_source_rbac_associate(get, post, alice, vault_credential, external_credential): + sublist_url = reverse( + 'api:credential_input_source_sublist', + kwargs={'version': 'v2', 'pk': vault_credential.pk} + ) + params = { + 'source_credential': external_credential.pk, + 'input_field_name': 'vault_password', + 'associate': True, + 'metadata': {'key': 'some_key'}, + } + + # alice can't admin the target *or* source cred + response = post(sublist_url, params, alice) + assert response.status_code == 403 + + # alice can't use the source cred + vault_credential.admin_role.members.add(alice) + response = post(sublist_url, params, alice) + assert response.status_code == 403 + + # alice is allowed to associate now + external_credential.use_role.members.add(alice) + response = post(sublist_url, params, alice) + assert response.status_code == 201 + + # now let's try disassociation + detail = get(response.data['url'], alice) + assert detail.status_code == 200 + vault_credential.admin_role.members.remove(alice) + external_credential.use_role.members.remove(alice) + + # now that permissions are removed, alice can't *read* the input source + assert get(response.data['url'], alice).status_code == 403 + + # alice can't admin the target (so she can't remove the input source) + params = { + 'id': detail.data['id'], + 'disassociate': True + } + response = post(sublist_url, params, alice) + assert response.status_code == 403 + + # alice is allowed to disassociate now + vault_credential.admin_role.members.add(alice) + response = post(sublist_url, params, alice) + assert response.status_code == 204 + + +@pytest.mark.django_db +def test_input_source_detail_rbac(get, post, patch, delete, admin, alice, + vault_credential, external_credential, + other_external_credential): + sublist_url = reverse( + 'api:credential_input_source_sublist', + kwargs={'version': 'v2', 'pk': vault_credential.pk} + ) + params = { + 'source_credential': external_credential.pk, + 'input_field_name': 'vault_password', + 'associate': True, + 'metadata': {'key': 'some_key'}, + } + + response = post(sublist_url, params, admin) + assert response.status_code == 201 + + url = response.data['url'] + + # alice can't read the input source directly because she can't read the target cred + detail = get(url, alice) + assert detail.status_code == 403 + + # alice can read the input source directly + vault_credential.read_role.members.add(alice) + detail = get(url, alice) + assert detail.status_code == 200 + + # she can also see it on the credential sublist + response = get(sublist_url, admin) + assert response.status_code == 200 + assert response.data['count'] == 1 + + # alice can't change or delete the input source because she can't change the target cred + assert patch(url, {'input_field_name': 'vault_id'}, alice).status_code == 403 + assert delete(url, alice).status_code == 403 + + # alice can admin the target cred, so she can change the input field name + vault_credential.admin_role.members.add(alice) + external_credential.use_role.members.add(alice) + assert patch(url, {'input_field_name': 'vault_id'}, alice).status_code == 200 + assert CredentialInputSource.objects.first().input_field_name == 'vault_id' + + # she _cannot_, however, apply a source credential she doesn't have access to + assert patch(url, {'source_credential': other_external_credential.pk}, alice).status_code == 403 + + assert delete(url, alice).status_code == 204 + assert CredentialInputSource.objects.count() == 0 + + +@pytest.mark.django_db +def test_input_source_rbac_swap_target_credential(get, post, put, patch, admin, alice, + machine_credential, vault_credential, + external_credential): + # If you change the target credential for an input source, + # you have to have admin role on the *original* credential (so you can + # remove the relationship) *and* on the *new* credential (so you can apply the + # new relationship) + sublist_url = reverse( + 'api:credential_input_source_sublist', + kwargs={'version': 'v2', 'pk': vault_credential.pk} + ) + params = { + 'source_credential': external_credential.pk, + 'input_field_name': 'vault_password', + 'associate': True, + 'metadata': {'key': 'some_key'}, + } + + response = post(sublist_url, params, admin) + assert response.status_code == 201 + url = response.data['url'] + + # alice can't change target cred because she can't admin either one + assert patch(url, { + 'target_credential': machine_credential.pk, + 'input_field_name': 'password' + }, alice).status_code == 403 + + # alice still can't change target cred because she can't admin *the new one* + vault_credential.admin_role.members.add(alice) + assert patch(url, { + 'target_credential': machine_credential.pk, + 'input_field_name': 'password' + }, alice).status_code == 403 + + machine_credential.admin_role.members.add(alice) + assert patch(url, { + 'target_credential': machine_credential.pk, + 'input_field_name': 'password' + }, alice).status_code == 200 + + @pytest.mark.django_db def test_create_credential_input_source_with_non_external_source_returns_400(post, admin, credential, vault_credential): sublist_url = reverse( From ca6d124417c7eb65a54397abf0d18555ac056a82 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Tue, 26 Feb 2019 15:51:30 -0500 Subject: [PATCH 14/74] add API examples for supported credential plugins --- docs/credential_plugins.md | 215 ++++++++++++++++++++++++++++++------- 1 file changed, 175 insertions(+), 40 deletions(-) diff --git a/docs/credential_plugins.md b/docs/credential_plugins.md index 15aa882e1b..851eebdff3 100644 --- a/docs/credential_plugins.md +++ b/docs/credential_plugins.md @@ -2,9 +2,9 @@ Credential Plugins ================== By default, sensitive credential values (such as SSH passwords, SSH private -keys, API tokens for cloud services) in AWX are encrypted with a symmetric -encryption cipher utilizing AES-256 in CBC mode alongside a SHA-256 HMAC and -stored in the AWX database. +keys, API tokens for cloud services) in AWX are stored in the AWX database +after being encrypted with a symmetric encryption cipher utilizing AES-256 in +CBC mode alongside a SHA-256 HMAC. Alternatively, AWX supports retrieving secret values from third-party secret management systems, such as HashiCorp Vault and Microsoft Azure Key Vault. @@ -25,50 +25,14 @@ the Machine Credential. 1. Create the Machine Credential with a static username, `example-user`. - ```shell - HTTP POST https://awx.example.org/api/v2/credentials/ - - { - "organization": X, - "credential_type": , - "inputs": { - "username": "example-user" - } - } - 2. Create a second credential used to _authenticate_ with the external - secret management system (e.g.,, in this example, specifying a URL and an + secret management system (in this example, specifying a URL and an OAuth2.0 token _to access_ HashiCorp Vault) - ```shell - HTTP POST https://awx.example.org/api/v2/credentials/ - { - "organization": X, - "credential_type": , - "inputs": { - "url": "https://my-vault.example.org", - "token": "some-oauth-token", - "api_version": "v2", - } - } - 3. _Link_ the `password` field for the Machine credential to the external system by specifying the source (in this example, the HashiCorp credential) and metadata about the path (e.g., `/some/path/to/my/password/`). - ```shell - HTTP POST https://awx.example.org/api/v2/credentials/N/input_sources/ - { - "input_field_name": "", - "source_credential": - "metadata": { - "secret_path": "/path/to/my/secret/" - "secret_field": "password" - "secret_version": 92 - } - } - ``` - Note that you can perform these lookups on *any* credential field - not just the `password` field for Machine credentials. You could just as easily create an AWS credential and use lookups to retrieve the Access Key and Secret Key @@ -192,3 +156,174 @@ hashivault.hashivault_kv_plugin.backend( secret_key='dbpass' ) ``` + +Supported Plugins +================= + +HashiCorp Vault KV +------------------ + +AWX supports retrieving secret values from HashiCorp Vault KV +(https://www.vaultproject.io/api/secret/kv/) + +The following example illustrates how to configure a Machine credential to pull +its password from an HashiCorp Vault: + +1. Look up the ID of the Machine and HashiCorp Vault Secret Lookup credential + types (in this example, `1` and `15`): + +```shell +~ curl -sik "https://awx.example.org/api/v2/credential_types/?name=Machine" \ + -H "Authorization: Bearer " +HTTP/1.1 200 OK +{ + "results": [ + { + "id": 1, + "url": "/api/v2/credential_types/1/", + "name": "Machine", + ... +``` + +```shell +~ curl -sik "https://awx.example.org/api/v2/credential_types/?name__startswith=HashiCorp" \ + -H "Authorization: Bearer " +HTTP/1.1 200 OK +{ + "results": [ + { + "id": 15, + "url": "/api/v2/credential_types/15/", + "name": "HashiCorp Vault Secret Lookup", + ... +``` + +2. Create a Machine and a HashiCorp Vault credential: + +```shell +~ curl -sik "https://awx.example.org/api/v2/credentials/" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -X POST \ + -d '{"user": N, "credential_type": 1, "name": "My SSH", "inputs": {"username": "example"}}' + +HTTP/1.1 201 Created +{ + "credential_type": 1, + "description": "", + "id": 1, + ... +``` + +```shell +~ curl -sik "https://awx.example.org/api/v2/credentials/" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -X POST \ + -d '{"user": N, "credential_type": 15, "name": "My Hashi Credential", "inputs": {"url": "https://vault.example.org", "token": "vault-token", "api_version": "v2"}}' + +HTTP/1.1 201 Created +{ + "credential_type": 15, + "description": "", + "id": 2, + ... +``` + +3. Link the Machine credential to the HashiCorp Vault credential: + +```shell +~ curl -sik "https://awx.example.org/api/v2/credentials/1/input_sources/" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -X POST \ + -d '{"source_credential": 2, "input_field_name": "password", "metadata": {"secret_path": "/kv/my-secret", "secret_key": "password"}}' +HTTP/1.1 201 Created +``` + + +HashiCorp Vault SSH Secrets Engine +---------------------------------- + +AWX supports signing public keys via HashiCorp Vault's SSH Secrets Engine +(https://www.vaultproject.io/api/secret/ssh/) + +The following example illustrates how to configure a Machine credential to sign +a public key using HashiCorp Vault: + +1. Look up the ID of the Machine and HashiCorp Vault Signed SSH credential + types (in this example, `1` and `16`): + +```shell +~ curl -sik "https://awx.example.org/api/v2/credential_types/?name=Machine" \ + -H "Authorization: Bearer " +HTTP/1.1 200 OK +{ + "results": [ + { + "id": 1, + "url": "/api/v2/credential_types/1/", + "name": "Machine", + ... +``` + +```shell +~ curl -sik "https://awx.example.org/api/v2/credential_types/?name__startswith=HashiCorp" \ + -H "Authorization: Bearer " +HTTP/1.1 200 OK +{ + "results": [ + { + "id": 16, + "url": "/api/v2/credential_types/16/", + "name": "HashiCorp Vault Signed SSH", +``` + +2. Create a Machine and a HashiCorp Vault credential: + +```shell +~ curl -sik "https://awx.example.org/api/v2/credentials/" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -X POST \ + -d '{"user": N, "credential_type": 1, "name": "My SSH", "inputs": {"username": "example", "ssh_key_data": "RSA KEY DATA", "ssh_public_key_data": "UNSIGNED PUBLIC KEY DATA"}}' + +HTTP/1.1 201 Created +{ + "credential_type": 1, + "description": "", + "id": 1, + ... +``` + +```shell +~ curl -sik "https://awx.example.org/api/v2/credentials/" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -X POST \ + -d '{"user": N, "credential_type": 16, "name": "My Hashi Credential", "inputs": {"url": "https://vault.example.org", "token": "vault-token"}}' + +HTTP/1.1 201 Created +{ + "credential_type": 16, + "description": "", + "id": 2, + ... +``` + +3. Link the Machine credential to the HashiCorp Vault credential: + +```shell +~ curl -sik "https://awx.example.org/api/v2/credentials/1/input_sources/" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -X POST \ + -d '{"source_credential": 2, "input_field_name": "password", "metadata": {"secret_path": "/ssh/", "role": "example-role"}}' +HTTP/1.1 201 Created +``` + +4. Associate the Machine credential with a Job Template. When the Job Template + is run, AWX will use the provided HashiCorp URL and token to sign the + unsigned public key data using the HashiCorp Vault SSH Secrets API. + AWX will generate an `id_rsa` and `id_rsa-cert.pub` on the fly and + apply them using `ssh-add`. From 13366c1e75ee367de2a6f8985ca37bfd26f88f55 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Tue, 26 Feb 2019 17:45:26 -0500 Subject: [PATCH 15/74] Encrypt machine.ssh_public_key_data (in case users paste in signed data) --- awx/main/models/credential/__init__.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/awx/main/models/credential/__init__.py b/awx/main/models/credential/__init__.py index f8597ba4dc..11578b397d 100644 --- a/awx/main/models/credential/__init__.py +++ b/awx/main/models/credential/__init__.py @@ -824,6 +824,7 @@ ManagedCredentialType( 'label': ugettext_noop('Signed SSH Certificate'), 'type': 'string', 'multiline': True, + 'secret': True, }, { 'id': 'ssh_key_unlock', 'label': ugettext_noop('Private Key Passphrase'), @@ -1360,8 +1361,11 @@ class CredentialInputSource(PrimordialModel): backend_kwargs[field_name] = value backend_kwargs.update(self.metadata) + raw = self.target_credential.inputs.get(self.input_field_name) + if self.input_field_name in self.target_credential.credential_type.secret_fields: + raw = decrypt_field(self.target_credential, self.input_field_name) return backend( - self.target_credential.inputs.get(self.input_field_name), + raw, **backend_kwargs ) From e727909a6139c303ff6e0889d601b9f0ff49a632 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Tue, 26 Feb 2019 21:25:26 -0500 Subject: [PATCH 16/74] rename the CredentialInputSource related_names so they're plural --- awx/api/views/__init__.py | 2 +- awx/main/migrations/0067_v350_credential_plugins.py | 4 ++-- awx/main/models/credential/__init__.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 70e56fcb03..08b875c9a8 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -1468,7 +1468,7 @@ class CredentialInputSourceSubList(SubListCreateAttachDetachAPIView): model = models.CredentialInputSource serializer_class = serializers.CredentialInputSourceSerializer parent_model = models.Credential - relationship = 'input_source' + relationship = 'input_sources' parent_key = 'target_credential' diff --git a/awx/main/migrations/0067_v350_credential_plugins.py b/awx/main/migrations/0067_v350_credential_plugins.py index 427660b045..8d3a1f824f 100644 --- a/awx/main/migrations/0067_v350_credential_plugins.py +++ b/awx/main/migrations/0067_v350_credential_plugins.py @@ -35,9 +35,9 @@ class Migration(migrations.Migration): ('metadata', awx.main.fields.DynamicCredentialInputField(blank=True, default={})), ('created_by', models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{'class': 'credentialinputsource', 'model_name': 'credentialinputsource', 'app_label': 'main'}(class)s_created+", to=settings.AUTH_USER_MODEL)), ('modified_by', models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{'class': 'credentialinputsource', 'model_name': 'credentialinputsource', 'app_label': 'main'}(class)s_modified+", to=settings.AUTH_USER_MODEL)), - ('source_credential', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='target_input_source', to='main.Credential')), + ('source_credential', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='target_input_sources', to='main.Credential')), ('tags', taggit.managers.TaggableManager(blank=True, help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags')), - ('target_credential', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='input_source', to='main.Credential')), + ('target_credential', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='input_sources', to='main.Credential')), ], ), migrations.AlterField( diff --git a/awx/main/models/credential/__init__.py b/awx/main/models/credential/__init__.py index 11578b397d..edc9de5372 100644 --- a/awx/main/models/credential/__init__.py +++ b/awx/main/models/credential/__init__.py @@ -1314,13 +1314,13 @@ class CredentialInputSource(PrimordialModel): target_credential = models.ForeignKey( 'Credential', - related_name='input_source', + related_name='input_sources', on_delete=models.CASCADE, null=True, ) source_credential = models.ForeignKey( 'Credential', - related_name='target_input_source', + related_name='target_input_sources', on_delete=models.CASCADE, null=True, ) From b911f8bf77915c67015bcbed0ea6cf2bfa4a2e02 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Wed, 27 Feb 2019 14:51:48 -0500 Subject: [PATCH 17/74] allow creation at /api/v2/credential_input_sources --- awx/api/views/__init__.py | 2 +- awx/api/views/root.py | 1 + awx/main/models/credential/__init__.py | 9 ++++- .../api/test_credential_input_sources.py | 39 +++++++++++++++++-- 4 files changed, 45 insertions(+), 6 deletions(-) diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 08b875c9a8..50436d8697 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -1453,7 +1453,7 @@ class CredentialInputSourceDetail(RetrieveUpdateDestroyAPIView): serializer_class = serializers.CredentialInputSourceSerializer -class CredentialInputSourceList(ListAPIView): +class CredentialInputSourceList(ListCreateAPIView): view_name = _("Credential Input Sources") diff --git a/awx/api/views/root.py b/awx/api/views/root.py index c7ecbbeef5..66bc11b710 100644 --- a/awx/api/views/root.py +++ b/awx/api/views/root.py @@ -101,6 +101,7 @@ class ApiVersionRootView(APIView): data['credentials'] = reverse('api:credential_list', request=request) if get_request_version(request) > 1: data['credential_types'] = reverse('api:credential_type_list', request=request) + data['credential_input_sources'] = reverse('api:credential_input_source_list', request=request) data['applications'] = reverse('api:o_auth2_application_list', request=request) data['tokens'] = reverse('api:o_auth2_token_list', request=request) data['inventory'] = reverse('api:inventory_list', request=request) diff --git a/awx/main/models/credential/__init__.py b/awx/main/models/credential/__init__.py index edc9de5372..a572b89120 100644 --- a/awx/main/models/credential/__init__.py +++ b/awx/main/models/credential/__init__.py @@ -1343,8 +1343,13 @@ class CredentialInputSource(PrimordialModel): return self.source_credential def clean_input_field_name(self): - if self.input_field_name not in self.target_credential.credential_type.defined_fields: - raise ValidationError(_('Input field must be defined on target credential.')) + defined_fields = self.target_credential.credential_type.defined_fields + if self.input_field_name not in defined_fields: + raise ValidationError(_( + 'Input field must be defined on target credential (options are {}).'.format( + ', '.join(sorted(defined_fields)) + ) + )) return self.input_field_name def save(self, *args, **kwargs): diff --git a/awx/main/tests/functional/api/test_credential_input_sources.py b/awx/main/tests/functional/api/test_credential_input_sources.py index a4237083c0..35e95a3a39 100644 --- a/awx/main/tests/functional/api/test_credential_input_sources.py +++ b/awx/main/tests/functional/api/test_credential_input_sources.py @@ -77,16 +77,18 @@ def test_associate_credential_input_source_with_invalid_metadata(get, post, admi @pytest.mark.django_db -def test_cannot_create_from_list(get, post, admin, vault_credential, external_credential): +def test_create_from_list(get, post, admin, vault_credential, external_credential): params = { 'source_credential': external_credential.pk, 'target_credential': vault_credential.pk, 'input_field_name': 'vault_password', + 'metadata': {'key': 'some_example_key'}, } assert post(reverse( 'api:credential_input_source_list', kwargs={'version': 'v2'} - ), params, admin).status_code == 405 + ), params, admin).status_code == 201 + assert CredentialInputSource.objects.count() == 1 @pytest.mark.django_db @@ -207,6 +209,37 @@ def test_input_source_detail_rbac(get, post, patch, delete, admin, alice, assert CredentialInputSource.objects.count() == 0 +@pytest.mark.django_db +def test_input_source_create_rbac(get, post, patch, delete, alice, + vault_credential, external_credential, + other_external_credential): + sublist_url = reverse( + 'api:credential_input_source_list', + kwargs={'version': 'v2'} + ) + params = { + 'target_credential': vault_credential.pk, + 'source_credential': external_credential.pk, + 'input_field_name': 'vault_password', + 'metadata': {'key': 'some_key'}, + } + + # alice can't create the inv source because she has access to neither credential + response = post(sublist_url, params, alice) + assert response.status_code == 403 + + # alice still can't because she can't use the source credential + vault_credential.admin_role.members.add(alice) + response = post(sublist_url, params, alice) + assert response.status_code == 403 + + # alice can create an input source if she has permissions on both credentials + external_credential.use_role.members.add(alice) + response = post(sublist_url, params, alice) + assert response.status_code == 201 + assert CredentialInputSource.objects.count() == 1 + + @pytest.mark.django_db def test_input_source_rbac_swap_target_credential(get, post, put, patch, admin, alice, machine_credential, vault_credential, @@ -278,7 +311,7 @@ def test_create_credential_input_source_with_undefined_input_returns_400(post, a } response = post(sublist_url, params, admin) assert response.status_code == 400 - assert response.data['input_field_name'] == ['Input field must be defined on target credential.'] + assert response.data['input_field_name'] == ['Input field must be defined on target credential (options are vault_id, vault_password).'] @pytest.mark.django_db From e9532dea8e5b4a3b81d07cbcb570cb3a05907773 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Wed, 27 Feb 2019 15:12:07 -0500 Subject: [PATCH 18/74] cache dynamic input fields Query dynamic input fields once on attribute access and then cache it for future use. --- awx/main/models/credential/__init__.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/awx/main/models/credential/__init__.py b/awx/main/models/credential/__init__.py index a572b89120..db8b4a48d6 100644 --- a/awx/main/models/credential/__init__.py +++ b/awx/main/models/credential/__init__.py @@ -284,10 +284,6 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin): 'admin_role', ]) - def __init__(self, *args, **kwargs): - super(Credential, self).__init__(*args, **kwargs) - self.dynamic_input_fields = self._get_dynamic_input_field_names() - def __getattr__(self, item): if item != 'inputs': if item in V1Credential.FIELDS: @@ -377,6 +373,14 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin): needed.append('vault_password') return needed + @property + def dynamic_input_fields(self): + dynamic_input_fields = getattr(self, '_dynamic_input_fields', None) + if dynamic_input_fields is None: + self._dynamic_input_fields = self._get_dynamic_input_field_names() + return self._dynamic_input_fields + return dynamic_input_fields + def _password_field_allows_ask(self, field): return field in self.credential_type.askable_fields From 368d933799314341f3c46b74a924f7185ef1d0f9 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Wed, 27 Feb 2019 16:12:19 -0500 Subject: [PATCH 19/74] remove association behavior from /api/v2/credentials/input_sources/ --- awx/api/views/__init__.py | 2 +- .../api/test_credential_input_sources.py | 134 +++++++++--------- 2 files changed, 67 insertions(+), 69 deletions(-) diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 50436d8697..4d77b032e5 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -1461,7 +1461,7 @@ class CredentialInputSourceList(ListCreateAPIView): serializer_class = serializers.CredentialInputSourceSerializer -class CredentialInputSourceSubList(SubListCreateAttachDetachAPIView): +class CredentialInputSourceSubList(SubListCreateAPIView): view_name = _("Credential Input Sources") diff --git a/awx/main/tests/functional/api/test_credential_input_sources.py b/awx/main/tests/functional/api/test_credential_input_sources.py index 35e95a3a39..5c83bb4402 100644 --- a/awx/main/tests/functional/api/test_credential_input_sources.py +++ b/awx/main/tests/functional/api/test_credential_input_sources.py @@ -5,51 +5,45 @@ from awx.api.versioning import reverse @pytest.mark.django_db -def test_associate_credential_input_source(get, post, admin, vault_credential, external_credential): - sublist_url = reverse( - 'api:credential_input_source_sublist', - kwargs={'version': 'v2', 'pk': vault_credential.pk} +def test_associate_credential_input_source(get, post, delete, admin, vault_credential, external_credential): + list_url = reverse( + 'api:credential_input_source_list', + kwargs={'version': 'v2'} ) # attach params = { + 'target_credential': vault_credential.pk, 'source_credential': external_credential.pk, 'input_field_name': 'vault_password', - 'metadata': {'key': 'some_example_key'}, - 'associate': True + 'metadata': {'key': 'some_example_key'} } - response = post(sublist_url, params, admin) + response = post(list_url, params, admin) assert response.status_code == 201 detail = get(response.data['url'], admin) assert detail.status_code == 200 - response = get(sublist_url, admin) + response = get(list_url, admin) assert response.status_code == 200 assert response.data['count'] == 1 - assert get(reverse( - 'api:credential_input_source_list', - kwargs={'version': 'v2'} - ), admin).data['count'] == 1 assert CredentialInputSource.objects.count() == 1 input_source = CredentialInputSource.objects.first() assert input_source.metadata == {'key': 'some_example_key'} # detach - params = { - 'id': detail.data['id'], - 'disassociate': True - } - response = post(sublist_url, params, admin) + response = delete( + reverse( + 'api:credential_input_source_detail', + kwargs={'version': 'v2', 'pk': detail.data['id']} + ), + admin + ) assert response.status_code == 204 - response = get(sublist_url, admin) + response = get(list_url, admin) assert response.status_code == 200 assert response.data['count'] == 0 - assert get(reverse( - 'api:credential_input_source_list', - kwargs={'version': 'v2'} - ), admin).data['count'] == 0 assert CredentialInputSource.objects.count() == 0 @@ -61,19 +55,20 @@ def test_associate_credential_input_source(get, post, admin, vault_credential, e {'extraneous': 'foo'}, # invalid parameter ]) def test_associate_credential_input_source_with_invalid_metadata(get, post, admin, vault_credential, external_credential, metadata): - sublist_url = reverse( - 'api:credential_input_source_sublist', - kwargs={'version': 'v2', 'pk': vault_credential.pk} + list_url = reverse( + 'api:credential_input_source_list', + kwargs={'version': 'v2'}, ) params = { + 'target_credential': vault_credential.pk, 'source_credential': external_credential.pk, 'input_field_name': 'vault_password', 'metadata': metadata, - 'associate': True } - response = post(sublist_url, params, admin) + response = post(list_url, params, admin) assert response.status_code == 400 + assert b'metadata' in response.content @pytest.mark.django_db @@ -93,46 +88,46 @@ def test_create_from_list(get, post, admin, vault_credential, external_credentia @pytest.mark.django_db def test_create_credential_input_source_with_external_target_returns_400(post, admin, external_credential, other_external_credential): - sublist_url = reverse( - 'api:credential_input_source_sublist', - kwargs={'version': 'v2', 'pk': other_external_credential.pk} + list_url = reverse( + 'api:credential_input_source_list', + kwargs={'version': 'v2'} ) params = { + 'target_credential': other_external_credential.pk, 'source_credential': external_credential.pk, 'input_field_name': 'token', - 'associate': True, 'metadata': {'key': 'some_key'}, } - response = post(sublist_url, params, admin) + response = post(list_url, params, admin) assert response.status_code == 400 assert response.data['target_credential'] == ['Target must be a non-external credential'] @pytest.mark.django_db -def test_input_source_rbac_associate(get, post, alice, vault_credential, external_credential): - sublist_url = reverse( - 'api:credential_input_source_sublist', - kwargs={'version': 'v2', 'pk': vault_credential.pk} +def test_input_source_rbac_associate(get, post, delete, alice, vault_credential, external_credential): + list_url = reverse( + 'api:credential_input_source_list', + kwargs={'version': 'v2'} ) params = { + 'target_credential': vault_credential.pk, 'source_credential': external_credential.pk, 'input_field_name': 'vault_password', - 'associate': True, 'metadata': {'key': 'some_key'}, } # alice can't admin the target *or* source cred - response = post(sublist_url, params, alice) + response = post(list_url, params, alice) assert response.status_code == 403 # alice can't use the source cred vault_credential.admin_role.members.add(alice) - response = post(sublist_url, params, alice) + response = post(list_url, params, alice) assert response.status_code == 403 # alice is allowed to associate now external_credential.use_role.members.add(alice) - response = post(sublist_url, params, alice) + response = post(list_url, params, alice) assert response.status_code == 201 # now let's try disassociation @@ -145,16 +140,16 @@ def test_input_source_rbac_associate(get, post, alice, vault_credential, externa assert get(response.data['url'], alice).status_code == 403 # alice can't admin the target (so she can't remove the input source) - params = { - 'id': detail.data['id'], - 'disassociate': True - } - response = post(sublist_url, params, alice) + delete_url = reverse( + 'api:credential_input_source_detail', + kwargs={'version': 'v2', 'pk': detail.data['id']} + ) + response = delete(delete_url, alice) assert response.status_code == 403 # alice is allowed to disassociate now vault_credential.admin_role.members.add(alice) - response = post(sublist_url, params, alice) + response = delete(delete_url, alice) assert response.status_code == 204 @@ -169,7 +164,6 @@ def test_input_source_detail_rbac(get, post, patch, delete, admin, alice, params = { 'source_credential': external_credential.pk, 'input_field_name': 'vault_password', - 'associate': True, 'metadata': {'key': 'some_key'}, } @@ -213,7 +207,7 @@ def test_input_source_detail_rbac(get, post, patch, delete, admin, alice, def test_input_source_create_rbac(get, post, patch, delete, alice, vault_credential, external_credential, other_external_credential): - sublist_url = reverse( + list_url = reverse( 'api:credential_input_source_list', kwargs={'version': 'v2'} ) @@ -225,17 +219,17 @@ def test_input_source_create_rbac(get, post, patch, delete, alice, } # alice can't create the inv source because she has access to neither credential - response = post(sublist_url, params, alice) + response = post(list_url, params, alice) assert response.status_code == 403 # alice still can't because she can't use the source credential vault_credential.admin_role.members.add(alice) - response = post(sublist_url, params, alice) + response = post(list_url, params, alice) assert response.status_code == 403 # alice can create an input source if she has permissions on both credentials external_credential.use_role.members.add(alice) - response = post(sublist_url, params, alice) + response = post(list_url, params, alice) assert response.status_code == 201 assert CredentialInputSource.objects.count() == 1 @@ -248,18 +242,18 @@ def test_input_source_rbac_swap_target_credential(get, post, put, patch, admin, # you have to have admin role on the *original* credential (so you can # remove the relationship) *and* on the *new* credential (so you can apply the # new relationship) - sublist_url = reverse( - 'api:credential_input_source_sublist', - kwargs={'version': 'v2', 'pk': vault_credential.pk} + list_url = reverse( + 'api:credential_input_source_list', + kwargs={'version': 'v2'} ) params = { + 'target_credential': vault_credential.pk, 'source_credential': external_credential.pk, 'input_field_name': 'vault_password', - 'associate': True, 'metadata': {'key': 'some_key'}, } - response = post(sublist_url, params, admin) + response = post(list_url, params, admin) assert response.status_code == 201 url = response.data['url'] @@ -285,47 +279,51 @@ def test_input_source_rbac_swap_target_credential(get, post, put, patch, admin, @pytest.mark.django_db def test_create_credential_input_source_with_non_external_source_returns_400(post, admin, credential, vault_credential): - sublist_url = reverse( - 'api:credential_input_source_sublist', - kwargs={'version': 'v2', 'pk': vault_credential.pk} + list_url = reverse( + 'api:credential_input_source_list', + kwargs={'version': 'v2'} ) params = { + 'target_credential': vault_credential.pk, 'source_credential': credential.pk, 'input_field_name': 'vault_password' } - response = post(sublist_url, params, admin) + response = post(list_url, params, admin) assert response.status_code == 400 assert response.data['source_credential'] == ['Source must be an external credential'] @pytest.mark.django_db def test_create_credential_input_source_with_undefined_input_returns_400(post, admin, vault_credential, external_credential): - sublist_url = reverse( - 'api:credential_input_source_sublist', - kwargs={'version': 'v2', 'pk': vault_credential.pk} + list_url = reverse( + 'api:credential_input_source_list', + kwargs={'version': 'v2'} ) params = { + 'target_credential': vault_credential.pk, 'source_credential': external_credential.pk, 'input_field_name': 'not_defined_for_credential_type', 'metadata': {'key': 'some_key'} } - response = post(sublist_url, params, admin) + response = post(list_url, params, admin) assert response.status_code == 400 assert response.data['input_field_name'] == ['Input field must be defined on target credential (options are vault_id, vault_password).'] @pytest.mark.django_db def test_create_credential_input_source_with_already_used_input_returns_400(post, admin, vault_credential, external_credential, other_external_credential): - sublist_url = reverse( - 'api:credential_input_source_sublist', - kwargs={'version': 'v2', 'pk': vault_credential.pk} + list_url = reverse( + 'api:credential_input_source_list', + kwargs={'version': 'v2'} ) all_params = [{ + 'target_credential': vault_credential.pk, 'source_credential': external_credential.pk, 'input_field_name': 'vault_password' }, { + 'target_credential': vault_credential.pk, 'source_credential': other_external_credential.pk, 'input_field_name': 'vault_password' }] - all_responses = [post(sublist_url, params, admin) for params in all_params] + all_responses = [post(list_url, params, admin) for params in all_params] assert all_responses.pop().status_code == 400 From 018ff9162047aa2a12bc676b5a7f0210f32fb651 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Wed, 27 Feb 2019 22:14:08 -0500 Subject: [PATCH 20/74] add related and summary fields to the CredentialInputSource endpoint --- awx/api/serializers.py | 29 +++++++++-------------------- 1 file changed, 9 insertions(+), 20 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 3f9964fdfc..7a36209fc6 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -133,6 +133,8 @@ SUMMARIZABLE_FK_FIELDS = { 'notification_template': DEFAULT_SUMMARY_FIELDS, 'instance_group': {'id', 'name', 'controller_id'}, 'insights_credential': DEFAULT_SUMMARY_FIELDS, + 'source_credential': DEFAULT_SUMMARY_FIELDS + ('kind', 'cloud', 'credential_type_id'), + 'target_credential': DEFAULT_SUMMARY_FIELDS + ('kind', 'cloud', 'credential_type_id'), } @@ -2817,29 +2819,16 @@ class CredentialSerializerCreate(CredentialSerializer): class CredentialInputSourceSerializer(BaseSerializer): - source_credential_name = serializers.SerializerMethodField( - read_only=True, - help_text=_('The name of the source credential.') - ) - source_credential_type = serializers.SerializerMethodField( - read_only=True, - help_text=_('The credential type of the source credential.') - ) class Meta: model = CredentialInputSource fields = ( - 'id', - 'type', - 'url', + '*', 'input_field_name', 'metadata', 'target_credential', 'source_credential', - 'source_credential_type', - 'source_credential_name', - 'created', - 'modified', + '-name', ) extra_kwargs = { 'input_field_name': {'required': True}, @@ -2847,11 +2836,11 @@ class CredentialInputSourceSerializer(BaseSerializer): 'source_credential': {'required': True}, } - def get_source_credential_name(self, obj): - return obj.source_credential.name - - def get_source_credential_type(self, obj): - return obj.source_credential.credential_type.id + def get_related(self, obj): + res = super(CredentialInputSourceSerializer, self).get_related(obj) + res['source_credential'] = obj.source_credential.get_absolute_url(request=self.context.get('request')) + res['target_credential'] = obj.target_credential.get_absolute_url(request=self.context.get('request')) + return res class UserCredentialSerializerCreate(CredentialSerializerCreate): From 011d7eb8929c0b5b19c312ff3b661c8f233b0a9d Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Thu, 28 Feb 2019 10:21:48 -0500 Subject: [PATCH 21/74] clean up access to various CredentialInputSource fields (#3336) --- awx/main/access.py | 1 + awx/main/models/credential/__init__.py | 26 +++++++++----------------- awx/main/tests/conftest.py | 2 +- 3 files changed, 11 insertions(+), 18 deletions(-) diff --git a/awx/main/access.py b/awx/main/access.py index 6f0aec60b2..753d436b3d 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -1175,6 +1175,7 @@ class CredentialInputSourceAccess(BaseAccess): ''' model = CredentialInputSource + select_related = ('target_credential', 'source_credential') def filtered_queryset(self): return CredentialInputSource.objects.filter( diff --git a/awx/main/models/credential/__init__.py b/awx/main/models/credential/__init__.py index db8b4a48d6..ea24636eb1 100644 --- a/awx/main/models/credential/__init__.py +++ b/awx/main/models/credential/__init__.py @@ -18,6 +18,7 @@ from django.db import models from django.utils.translation import ugettext_lazy as _, ugettext_noop from django.core.exceptions import ValidationError from django.utils.encoding import force_text +from django.utils.functional import cached_property # AWX from awx.api.versioning import reverse @@ -373,13 +374,9 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin): needed.append('vault_password') return needed - @property + @cached_property def dynamic_input_fields(self): - dynamic_input_fields = getattr(self, '_dynamic_input_fields', None) - if dynamic_input_fields is None: - self._dynamic_input_fields = self._get_dynamic_input_field_names() - return self._dynamic_input_fields - return dynamic_input_fields + return [obj.input_field_name for obj in self.input_sources.all()] def _password_field_allows_ask(self, field): return field in self.credential_type.askable_fields @@ -458,7 +455,7 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin): :param field_name(str): The name of the input field. :param default(optional[str]): A default return value to use. """ - if field_name in self.dynamic_input_fields: + if self.kind != 'external' and field_name in self.dynamic_input_fields: return self._get_dynamic_input(field_name) if field_name in self.credential_type.secret_fields: try: @@ -484,13 +481,12 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin): return True return field_name in self.inputs and self.inputs[field_name] not in ('', None) - def _get_dynamic_input_field_names(self): - input_sources = CredentialInputSource.objects.filter(target_credential=self) - return [obj.input_field_name for obj in input_sources] - def _get_dynamic_input(self, field_name): - input_sources = CredentialInputSource.objects.filter(target_credential=self) - return input_sources.filter(input_field_name=field_name).first().get_input_value() + for input_source in self.input_sources.all(): + if input_source.input_field_name == field_name: + return input_source.get_input_value() + else: + raise ValueError('{} is not a dynamic input field'.format(field_name)) class CredentialType(CommonModelNameNotUnique): @@ -1356,10 +1352,6 @@ class CredentialInputSource(PrimordialModel): )) return self.input_field_name - def save(self, *args, **kwargs): - self.full_clean() - super(CredentialInputSource, self).save(*args, **kwargs) - def get_input_value(self): backend = self.source_credential.credential_type.plugin.backend backend_kwargs = {} diff --git a/awx/main/tests/conftest.py b/awx/main/tests/conftest.py index 7418a0d735..5b660b29ca 100644 --- a/awx/main/tests/conftest.py +++ b/awx/main/tests/conftest.py @@ -146,5 +146,5 @@ def mock_external_credential_input_sources(): # Credential objects query their related input sources on initialization. # We mock that behavior out of credentials by default unless we need to # test it explicitly. - with mock.patch.object(Credential, '_get_dynamic_input_field_names', new=lambda _: []) as _fixture: + with mock.patch.object(Credential, 'dynamic_input_fields', new=[]) as _fixture: yield _fixture From e2d474ddd23c94f093a9b3c84ffded2892759352 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Thu, 28 Feb 2019 10:28:24 -0500 Subject: [PATCH 22/74] document restriction of external-external credential source linking --- docs/credential_plugins.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/credential_plugins.md b/docs/credential_plugins.md index 851eebdff3..d00e66bdb2 100644 --- a/docs/credential_plugins.md +++ b/docs/credential_plugins.md @@ -36,7 +36,9 @@ the Machine Credential. Note that you can perform these lookups on *any* credential field - not just the `password` field for Machine credentials. You could just as easily create an AWS credential and use lookups to retrieve the Access Key and Secret Key -from an external secret management system. +from an external secret management system. Also note that external credential +sources cannot ever be used to look up secrets for other external credential +sources. Writing Custom Credential Plugins --------------------------------- From 42f4956a7f6bc27d346c63230946aada7fed355c Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Thu, 28 Feb 2019 11:24:38 -0500 Subject: [PATCH 23/74] enforce required credential fields at job start time rather than on save this is necessary for credential plugins support so that you can (in two requests): 1. Save a Credential with _no_ input values defined 2. Create/associate one (or more) CredentialInputSource records to the new Credential --- awx/main/fields.py | 6 -- awx/main/models/unified_jobs.py | 17 ++++++ .../tests/functional/api/test_credential.py | 61 +++++++++++-------- awx/main/tests/functional/test_credential.py | 2 +- 4 files changed, 52 insertions(+), 34 deletions(-) diff --git a/awx/main/fields.py b/awx/main/fields.py index acd8a4f1a5..7f77d5632a 100644 --- a/awx/main/fields.py +++ b/awx/main/fields.py @@ -655,13 +655,7 @@ class CredentialInputField(JSONSchemaField): ) errors[error.schema['id']] = [error.message] - inputs = model_instance.credential_type.inputs defined_fields = model_instance.credential_type.defined_fields - for field in inputs.get('required', []): - if field in defined_fields and not value.get(field, None): - errors[field] = [_('required for %s') % ( - model_instance.credential_type.name - )] # `ssh_key_unlock` requirements are very specific and can't be # represented without complicated JSON schema diff --git a/awx/main/models/unified_jobs.py b/awx/main/models/unified_jobs.py index d30da550f4..1b341a5476 100644 --- a/awx/main/models/unified_jobs.py +++ b/awx/main/models/unified_jobs.py @@ -1230,6 +1230,23 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique self.save(update_fields=['job_explanation']) return (False, None) + # verify that any associated credentials aren't missing required field data + missing_credential_inputs = [] + for credential in self.credentials.all(): + defined_fields = credential.credential_type.defined_fields + for required in credential.credential_type.inputs.get('required', []): + if required in defined_fields and not credential.has_input(required): + missing_credential_inputs.append(required) + + if missing_credential_inputs: + self.job_explanation = '{} cannot start because Credential {} does not provide one or more required fields ({}).'.format( + self._meta.verbose_name.title(), + credential.name, + ', '.join(sorted(missing_credential_inputs)) + ) + self.save(update_fields=['job_explanation']) + return (False, None) + needed = self.get_passwords_needed_to_start() try: start_args = json.loads(decrypt_field(self, 'start_args')) diff --git a/awx/main/tests/functional/api/test_credential.py b/awx/main/tests/functional/api/test_credential.py index 98e4a5b0a7..2cb287b044 100644 --- a/awx/main/tests/functional/api/test_credential.py +++ b/awx/main/tests/functional/api/test_credential.py @@ -763,7 +763,7 @@ def test_falsey_field_data(get, post, organization, admin, field_value): 'credential_type': net.pk, 'organization': organization.id, 'inputs': { - 'username': 'joe-user', # username is required + 'username': 'joe-user', 'authorize': field_value } } @@ -952,9 +952,15 @@ def test_vault_password_required(post, organization, admin): }, admin ) - assert response.status_code == 400 - assert response.data['inputs'] == {'vault_password': ['required for Vault']} - assert Credential.objects.count() == 0 + assert response.status_code == 201 + assert Credential.objects.count() == 1 + + # vault_password must be specified by launch time + j = Job() + j.save() + j.credentials.add(Credential.objects.first()) + assert j.pre_start() == (False, None) + assert 'required fields (vault_password)' in j.job_explanation # @@ -1236,14 +1242,15 @@ def test_aws_create_fail_required_fields(post, organization, admin, version, par params, admin ) - assert response.status_code == 400 + assert response.status_code == 201 + assert Credential.objects.count() == 1 - assert Credential.objects.count() == 0 - errors = response.data - if version == 'v2': - errors = response.data['inputs'] - assert errors['username'] == ['required for %s' % aws.name] - assert errors['password'] == ['required for %s' % aws.name] + # username and password must be specified by launch time + j = Job() + j.save() + j.credentials.add(Credential.objects.first()) + assert j.pre_start() == (False, None) + assert 'required fields (password, username)' in j.job_explanation # @@ -1307,15 +1314,15 @@ def test_vmware_create_fail_required_fields(post, organization, admin, version, params, admin ) - assert response.status_code == 400 + assert response.status_code == 201 + assert Credential.objects.count() == 1 - assert Credential.objects.count() == 0 - errors = response.data - if version == 'v2': - errors = response.data['inputs'] - assert errors['username'] == ['required for %s' % vmware.name] - assert errors['password'] == ['required for %s' % vmware.name] - assert errors['host'] == ['required for %s' % vmware.name] + # username, password, and host must be specified by launch time + j = Job() + j.save() + j.credentials.add(Credential.objects.first()) + assert j.pre_start() == (False, None) + assert 'required fields (host, password, username)' in j.job_explanation # @@ -1406,14 +1413,14 @@ def test_openstack_create_fail_required_fields(post, organization, admin, versio params, admin ) - assert response.status_code == 400 - errors = response.data - if version == 'v2': - errors = response.data['inputs'] - assert errors['username'] == ['required for %s' % openstack.name] - assert errors['password'] == ['required for %s' % openstack.name] - assert errors['host'] == ['required for %s' % openstack.name] - assert errors['project'] == ['required for %s' % openstack.name] + assert response.status_code == 201 + + # username, password, host, and project must be specified by launch time + j = Job() + j.save() + j.credentials.add(Credential.objects.first()) + assert j.pre_start() == (False, None) + assert 'required fields (host, password, project, username)' in j.job_explanation @pytest.mark.django_db diff --git a/awx/main/tests/functional/test_credential.py b/awx/main/tests/functional/test_credential.py index 0dd0f8fdce..480d4b8ede 100644 --- a/awx/main/tests/functional/test_credential.py +++ b/awx/main/tests/functional/test_credential.py @@ -184,7 +184,7 @@ def test_ssh_key_data_validation(organization, kind, ssh_key_data, ssh_key_unloc @pytest.mark.django_db @pytest.mark.parametrize('inputs, valid', [ ({'vault_password': 'some-pass'}, True), - ({}, False), + ({}, True), ({'vault_password': 'dev-pass', 'vault_id': 'dev'}, True), ({'vault_password': 'dev-pass', 'vault_id': 'dev@prompt'}, False), # @ not allowed ]) From 81a509424a7c29c54030983797a09d68a62ecd32 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Thu, 28 Feb 2019 11:45:45 -0500 Subject: [PATCH 24/74] prefetch related source credentials in tasks.py --- awx/main/tasks.py | 6 +++--- awx/main/tests/unit/test_tasks.py | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/awx/main/tasks.py b/awx/main/tasks.py index da9dbdb202..ede7e04de9 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -819,7 +819,7 @@ class BaseTask(object): private_data_files['credentials'][credential] = path for credential, data in private_data.get('certificates', {}).items(): name = 'credential_%d-cert.pub' % credential.pk - path = os.path.join(kwargs['private_data_dir'], name) + path = os.path.join(private_data_dir, name) with open(path, 'w') as f: f.write(data) f.close() @@ -1290,7 +1290,7 @@ class RunJob(BaseTask): } ''' private_data = {'credentials': {}} - for credential in job.credentials.all(): + for credential in job.credentials.prefetch_related('input_sources__source_credential').all(): # If we were sent SSH credentials, decrypt them and send them # back (they will be written to a temporary file). if credential.has_input('ssh_key_data'): @@ -1521,7 +1521,7 @@ class RunJob(BaseTask): return self._write_extra_vars_file(private_data_dir, extra_vars, safe_dict) def build_credentials_list(self, job): - return job.credentials.all() + return job.credentials.prefetch_related('input_sources__source_credential').all() def get_password_prompts(self, passwords={}): d = super(RunJob, self).get_password_prompts(passwords) diff --git a/awx/main/tests/unit/test_tasks.py b/awx/main/tests/unit/test_tasks.py index ca4172cc67..07a6959f58 100644 --- a/awx/main/tests/unit/test_tasks.py +++ b/awx/main/tests/unit/test_tasks.py @@ -700,7 +700,8 @@ class TestJobCredentials(TestJobExecution): __iter__ = lambda *args: iter(job._credentials), first = lambda: job._credentials[0] ), - 'spec_set': ['all', 'add', 'filter'] + 'prefetch_related': lambda _: credentials_mock, + 'spec_set': ['all', 'add', 'filter', 'prefetch_related'], }) with mock.patch.object(UnifiedJob, 'credentials', credentials_mock): From 0de8a89293656044e3350600695d8d03b8ac86e9 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Thu, 28 Feb 2019 21:02:07 -0500 Subject: [PATCH 25/74] support input source metadata in plugin test apis --- awx/api/views/__init__.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 4d77b032e5..71355f46f7 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -1421,8 +1421,8 @@ class CredentialCopy(CopyAPIView): class CredentialExternalTest(SubDetailAPIView): """ - Test updates to the input values of an external credential before - saving them. + Test updates to the input values and metadata of an external credential + before saving them. """ view_name = _('External Credential Test') @@ -1432,14 +1432,15 @@ class CredentialExternalTest(SubDetailAPIView): def post(self, request, *args, **kwargs): obj = self.get_object() - test_inputs = {} + backend_kwargs = {} + for field_name, value in obj.inputs.items(): + backend_kwargs[field_name] = obj.get_input(field_name) for field_name, value in request.data.get('inputs', {}).items(): - if value == '$encrypted$': - test_inputs[field_name] = obj.get_input(field_name) - else: - test_inputs[field_name] = value + if value != '$encrypted$': + backend_kwargs[field_name] = value + backend_kwargs.update(request.data.get('metadata', {})) try: - obj.credential_type.plugin.backend(None, **test_inputs) + obj.credential_type.plugin.backend(None, **backend_kwargs) return Response({}, status=status.HTTP_202_ACCEPTED) except Exception as exc: return Response({'inputs': str(exc)}, status=status.HTTP_400_BAD_REQUEST) @@ -1485,9 +1486,10 @@ class CredentialTypeExternalTest(SubDetailAPIView): def post(self, request, *args, **kwargs): obj = self.get_object() - test_inputs = request.data.get('inputs', {}) + backend_kwargs = request.data.get('inputs', {}) + backend_kwargs.update(request.data.get('metadata', {})) try: - obj.plugin.backend(None, **test_inputs) + obj.plugin.backend(None, **backend_kwargs) return Response({}, status=status.HTTP_202_ACCEPTED) except Exception as exc: return Response({'inputs': str(exc)}, status=status.HTTP_400_BAD_REQUEST) From c436dcf8759aa6da45bff9be338f782c86e5bc20 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Tue, 26 Feb 2019 15:17:53 -0500 Subject: [PATCH 26/74] add input source prompting and plugin testing --- .../credentials/add-credentials.controller.js | 243 +++++++++++++++-- .../add-edit-credentials.view.html | 19 +- .../edit-credentials.controller.js | 248 ++++++++++++++++-- .../credentials/external-test.component.js | 37 +++ .../credentials/external-test.partial.html | 41 +++ awx/ui/client/features/credentials/index.js | 7 +- .../input-source-lookup.component.js | 92 +++++++ .../input-source-lookup.partial.html | 174 ++++++++++++ .../lib/components/form/form.directive.js | 7 +- .../lib/components/input/group.directive.js | 7 +- .../lib/components/input/group.partial.html | 4 +- .../lib/components/input/text.directive.js | 13 +- .../lib/components/input/text.partial.html | 43 ++- .../lib/components/tabs/tab.partial.html | 2 +- awx/ui/client/lib/components/tag/_index.less | 4 + awx/ui/client/lib/models/CredentialType.js | 10 +- .../lib/services/base-string.service.js | 2 + .../smart-search/smart-search.controller.js | 3 +- .../src/templates/prompt/prompt.block.less | 11 + 19 files changed, 879 insertions(+), 88 deletions(-) create mode 100644 awx/ui/client/features/credentials/external-test.component.js create mode 100644 awx/ui/client/features/credentials/external-test.partial.html create mode 100644 awx/ui/client/features/credentials/input-source-lookup.component.js create mode 100644 awx/ui/client/features/credentials/input-source-lookup.partial.html diff --git a/awx/ui/client/features/credentials/add-credentials.controller.js b/awx/ui/client/features/credentials/add-credentials.controller.js index 9193712f80..2acad45ae9 100644 --- a/awx/ui/client/features/credentials/add-credentials.controller.js +++ b/awx/ui/client/features/credentials/add-credentials.controller.js @@ -1,3 +1,5 @@ +/* eslint camelcase: 0 */ +/* eslint arrow-body-style: 0 */ function AddCredentialsController ( models, $state, @@ -6,7 +8,9 @@ function AddCredentialsController ( componentsStrings, ConfigService, ngToast, - $filter + Wait, + $filter, + CredentialType, ) { const vm = this || {}; @@ -40,6 +44,44 @@ function AddCredentialsController ( vm.form.credential_type._placeholder = strings.get('inputs.CREDENTIAL_TYPE_PLACEHOLDER'); vm.isTestable = credentialType.get('kind') === 'external'; + vm.inputSources = { + field: null, + credentialId: null, + credentialTypeId: null, + credentialTypeName: null, + tabs: { + credential: { + _active: true, + _disabled: false, + }, + metadata: { + _active: false, + _disabled: false, + } + }, + metadata: {}, + form: { + inputs: { + _get: () => vm.inputSources.metadata, + _reference: 'vm.form.inputs', + _key: 'inputs', + _source: { _value: {} }, + } + }, + items: [], + }; + vm.externalTest = { + metadata: null, + form: { + inputs: { + _get: () => vm.externalTest.metadata, + _reference: 'vm.form.inputs', + _key: 'inputs', + _source: { _value: {} }, + } + }, + }; + const gceFileInputSchema = { id: 'gce_service_account_key', type: 'file', @@ -50,10 +92,10 @@ function AddCredentialsController ( let gceFileInputPreEditValues; vm.form.inputs = { - _get: () => { + _get: ({ getSubmitData }) => { credentialType.mergeInputProperties(); - const fields = credentialType.get('inputs.fields'); + let fields = credentialType.get('inputs.fields'); if (credentialType.get('name') === 'Google Compute Engine') { fields.splice(2, 0, gceFileInputSchema); @@ -64,55 +106,196 @@ function AddCredentialsController ( become._isDynamic = true; become._choices = Array.from(apiConfig.become_methods, method => method[0]); } - vm.isTestable = credentialType.get('kind') === 'external'; + vm.getSubmitData = getSubmitData; + + fields = fields.map((field) => { + if (credentialType.get('kind') !== 'external') { + field.tagMode = true; + } + return field; + }); return fields; }, + _onRemoveTag ({ id }) { + vm.onInputSourceClear(id); + }, + _onInputLookup ({ id }) { + vm.onInputSourceOpen(id); + }, _source: vm.form.credential_type, _reference: 'vm.form.inputs', _key: 'inputs' }; - vm.form.secondary = ({ inputs }) => { - const name = $filter('sanitize')(credentialType.get('name')); - const endpoint = `${credentialType.get('id')}/test/`; + vm.onInputSourceClear = (field) => { + vm.form[field].tagMode = true; + vm.form[field].asTag = false; + }; - return credentialType.http.post({ url: endpoint, data: { inputs }, replace: false }) + vm.setTab = (name) => { + const metaIsActive = name === 'metadata'; + vm.inputSources.tabs.credential._active = !metaIsActive; + vm.inputSources.tabs.credential._disabled = false; + vm.inputSources.tabs.metadata._active = metaIsActive; + vm.inputSources.tabs.metadata._disabled = false; + }; + + vm.unsetTabs = () => { + vm.inputSources.tabs.credential._active = false; + vm.inputSources.tabs.credential._disabled = false; + vm.inputSources.tabs.metadata._active = false; + vm.inputSources.tabs.metadata._disabled = false; + }; + + vm.onInputSourceOpen = (field) => { + vm.inputSources.field = field; + vm.setTab('credential'); + const sourceItem = vm.inputSources.items + .find(({ input_field_name }) => input_field_name === field); + if (sourceItem) { + const { source_credential, summary_fields } = sourceItem; + const { source_credential: { credential_type_id } } = summary_fields; + vm.inputSources.credentialId = source_credential; + vm.inputSources.credentialTypeId = credential_type_id; + vm.inputSources._value = credential_type_id; + } + }; + + vm.onInputSourceClose = () => { + vm.inputSources.field = null; + vm.inputSources.metadata = null; + vm.unsetTabs(); + }; + + vm.onInputSourceNext = () => { + const { field, credentialId, credentialTypeId } = vm.inputSources; + Wait('start'); + new CredentialType('get', credentialTypeId) + .then(model => { + model.mergeInputProperties('metadata'); + vm.inputSources.metadata = model.get('inputs.metadata'); + vm.inputSources.credentialTypeName = model.get('name'); + const [metavals] = vm.inputSources.items + .filter(({ input_field_name }) => input_field_name === field) + .filter(({ source_credential }) => source_credential === credentialId) + .map(({ metadata }) => metadata); + Object.keys(metavals || {}).forEach(key => { + const obj = vm.inputSources.metadata.find(o => o.id === key); + if (obj) obj._value = metavals[key]; + }); + vm.setTab('metadata'); + }) + .finally(() => Wait('stop')); + }; + + vm.onInputSourceSelect = () => { + const { field, credentialId } = vm.inputSources; + vm.inputSources.items = vm.inputSources.items + .filter(({ input_field_name }) => input_field_name !== field) + .concat([{ + input_field_name: field, + source_credential: credentialId, + target_credential: credential.get('id'), + }]); + vm.inputSources.field = null; + vm.inputSources.metadata = null; + vm.unsetTabs(); + }; + + vm.onInputSourceTabSelect = (name) => { + if (name === 'metadata') { + vm.onInputSourceNext(); + } else { + vm.setTab('credential'); + } + }; + + vm.onInputSourceRowClick = ({ id, credential_type }) => { + vm.inputSources.credentialId = id; + vm.inputSources.credentialTypeId = credential_type; + vm.inputSources._value = credential_type; + }; + + vm.onInputSourceTest = () => { + const metadata = Object.assign({}, ...vm.inputSources.form.inputs._group + .filter(({ _value }) => _value !== undefined) + .map(({ id, _value }) => ({ [id]: _value }))); + const name = $filter('sanitize')(vm.inputSources.credentialTypeName); + const endpoint = `${vm.inputSources.credentialId}/test/`; + + return vm.runTest({ name, model: credential, endpoint, data: { metadata } }); + }; + + vm.onExternalTestClick = () => { + credentialType.mergeInputProperties('metadata'); + vm.externalTest.metadata = credentialType.get('inputs.metadata'); + }; + + vm.onExternalTestClose = () => { + vm.externalTest.metadata = null; + }; + + vm.onExternalTest = () => { + const name = $filter('sanitize')(credentialType.get('name')); + const { inputs } = vm.getSubmitData(); + const metadata = Object.assign({}, ...vm.externalTest.form.inputs._group + .filter(({ _value }) => _value !== undefined) + .map(({ id, _value }) => ({ [id]: _value }))); + + let model; + if (credential.get('credential_type') !== credentialType.get('id')) { + model = credentialType; + } else { + model = credential; + } + + const endpoint = `${model.get('id')}/test/`; + return vm.runTest({ name, model, endpoint, data: { inputs, metadata } }); + }; + vm.form.secondary = vm.onExternalTestClick; + + vm.runTest = ({ name, model, endpoint, data: { inputs, metadata } }) => { + return model.http.post({ url: endpoint, data: { inputs, metadata }, replace: false }) .then(() => { ngToast.success({ - content: ` -
-
- -
-
- ${name}: ${strings.get('edit.TEST_PASSED')} -
-
`, + content: vm.buildTestNotificationContent({ + name, + icon: 'fa-check-circle', + msg: strings.get('edit.TEST_PASSED'), + }), dismissButton: false, dismissOnTimeout: true }); }) .catch(({ data }) => { - const msg = data.inputs ? `${$filter('sanitize')(data.inputs)}` : strings.get('edit.TEST_FAILED'); - + const msg = data.inputs + ? `${$filter('sanitize')(data.inputs)}` + : strings.get('edit.TEST_FAILED'); ngToast.danger({ - content: ` -
-
- -
-
- ${name}: ${msg} -
-
`, + content: vm.buildTestNotificationContent({ + name, + msg, + icon: 'fa-exclamation-triangle' + }), dismissButton: false, dismissOnTimeout: true }); }); }; + vm.buildTestNotificationContent = ({ name, msg, icon }) => ( + `
+
+ +
+
+ ${name}: ${msg} +
+
` + ); + vm.form.save = data => { data.user = me.get('id'); @@ -195,7 +378,9 @@ AddCredentialsController.$inject = [ 'ComponentsStrings', 'ConfigService', 'ngToast', - '$filter' + 'Wait', + '$filter', + 'CredentialTypeModel', ]; export default AddCredentialsController; diff --git a/awx/ui/client/features/credentials/add-edit-credentials.view.html b/awx/ui/client/features/credentials/add-edit-credentials.view.html index fae203e152..914c98dcfa 100644 --- a/awx/ui/client/features/credentials/add-edit-credentials.view.html +++ b/awx/ui/client/features/credentials/add-edit-credentials.view.html @@ -43,5 +43,22 @@
- + +
diff --git a/awx/ui/client/features/credentials/edit-credentials.controller.js b/awx/ui/client/features/credentials/edit-credentials.controller.js index 1516b71d96..d3f5bf56f7 100644 --- a/awx/ui/client/features/credentials/edit-credentials.controller.js +++ b/awx/ui/client/features/credentials/edit-credentials.controller.js @@ -1,3 +1,5 @@ +/* eslint camelcase: 0 */ +/* eslint arrow-body-style: 0 */ function EditCredentialsController ( models, $state, @@ -8,10 +10,16 @@ function EditCredentialsController ( ngToast, Wait, $filter, + CredentialType, ) { const vm = this || {}; - - const { me, credential, credentialType, organization, isOrgCredAdmin } = models; + const { + me, + credential, + credentialType, + organization, + isOrgCredAdmin, + } = models; const omit = ['user', 'team', 'inputs']; const isEditable = credential.isEditable(); @@ -88,6 +96,44 @@ function EditCredentialsController ( vm.form.credential_type._placeholder = strings.get('inputs.CREDENTIAL_TYPE_PLACEHOLDER'); vm.isTestable = (isEditable && credentialType.get('kind') === 'external'); + vm.inputSources = { + field: null, + credentialId: null, + credentialTypeId: null, + credentialTypeName: null, + tabs: { + credential: { + _active: true, + _disabled: false, + }, + metadata: { + _active: false, + _disabled: false, + } + }, + metadata: {}, + form: { + inputs: { + _get: () => vm.inputSources.metadata, + _reference: 'vm.form.inputs', + _key: 'inputs', + _source: { _value: {} }, + } + }, + items: credential.get('related.input_sources.results'), + }; + vm.externalTest = { + metadata: null, + form: { + inputs: { + _get: () => vm.externalTest.metadata, + _reference: 'vm.form.inputs', + _key: 'inputs', + _source: { _value: {} }, + } + }, + }; + const gceFileInputSchema = { id: 'gce_service_account_key', type: 'file', @@ -98,7 +144,7 @@ function EditCredentialsController ( let gceFileInputPreEditValues; vm.form.inputs = { - _get () { + _get ({ getSubmitData }) { let fields; credentialType.mergeInputProperties(); @@ -128,54 +174,199 @@ function EditCredentialsController ( } } } + + fields = fields.map((field) => { + if (isEditable && credentialType.get('kind') !== 'external') { + field.tagMode = true; + } + return field; + }); + vm.isTestable = (isEditable && credentialType.get('kind') === 'external'); + vm.getSubmitData = getSubmitData; return fields; }, + _onRemoveTag ({ id }) { + vm.onInputSourceClear(id); + }, + _onInputLookup ({ id }) { + vm.onInputSourceOpen(id); + }, _source: vm.form.credential_type, _reference: 'vm.form.inputs', - _key: 'inputs' + _key: 'inputs', + border: true, + title: true, }; - vm.form.secondary = ({ inputs }) => { - const name = $filter('sanitize')(credentialType.get('name')); - const endpoint = `${credential.get('id')}/test/`; + vm.onInputSourceClear = (field) => { + vm.form[field].tagMode = true; + vm.form[field].asTag = false; + }; - return credential.http.post({ url: endpoint, data: { inputs }, replace: false }) + vm.setTab = (name) => { + const metaIsActive = name === 'metadata'; + vm.inputSources.tabs.credential._active = !metaIsActive; + vm.inputSources.tabs.credential._disabled = false; + vm.inputSources.tabs.metadata._active = metaIsActive; + vm.inputSources.tabs.metadata._disabled = false; + }; + + vm.unsetTabs = () => { + vm.inputSources.tabs.credential._active = false; + vm.inputSources.tabs.credential._disabled = false; + vm.inputSources.tabs.metadata._active = false; + vm.inputSources.tabs.metadata._disabled = false; + }; + + vm.onInputSourceOpen = (field) => { + vm.inputSources.field = field; + vm.setTab('credential'); + const sourceItem = vm.inputSources.items + .find(({ input_field_name }) => input_field_name === field); + if (sourceItem) { + const { source_credential, summary_fields } = sourceItem; + const { source_credential: { credential_type_id } } = summary_fields; + vm.inputSources.credentialId = source_credential; + vm.inputSources.credentialTypeId = credential_type_id; + vm.inputSources._value = credential_type_id; + } + }; + + vm.onInputSourceClose = () => { + vm.inputSources.field = null; + vm.inputSources.metadata = null; + vm.unsetTabs(); + }; + + vm.onInputSourceNext = () => { + const { field, credentialId, credentialTypeId } = vm.inputSources; + Wait('start'); + new CredentialType('get', credentialTypeId) + .then(model => { + model.mergeInputProperties('metadata'); + vm.inputSources.metadata = model.get('inputs.metadata'); + vm.inputSources.credentialTypeName = model.get('name'); + const [metavals] = vm.inputSources.items + .filter(({ input_field_name }) => input_field_name === field) + .filter(({ source_credential }) => source_credential === credentialId) + .map(({ metadata }) => metadata); + Object.keys(metavals || {}).forEach(key => { + const obj = vm.inputSources.metadata.find(o => o.id === key); + if (obj) obj._value = metavals[key]; + }); + vm.setTab('metadata'); + }) + .finally(() => Wait('stop')); + }; + + vm.onInputSourceSelect = () => { + const { field, credentialId } = vm.inputSources; + vm.inputSources.items = vm.inputSources.items + .filter(({ input_field_name }) => input_field_name !== field) + .concat([{ + input_field_name: field, + source_credential: credentialId, + target_credential: credential.get('id'), + }]); + vm.inputSources.field = null; + vm.inputSources.metadata = null; + vm.unsetTabs(); + }; + + vm.onInputSourceTabSelect = (name) => { + if (name === 'metadata') { + vm.onInputSourceNext(); + } else { + vm.setTab('credential'); + } + }; + + vm.onInputSourceRowClick = ({ id, credential_type }) => { + vm.inputSources.credentialId = id; + vm.inputSources.credentialTypeId = credential_type; + vm.inputSources._value = credential_type; + }; + + vm.onInputSourceTest = () => { + const metadata = Object.assign({}, ...vm.inputSources.form.inputs._group + .filter(({ _value }) => _value !== undefined) + .map(({ id, _value }) => ({ [id]: _value }))); + const name = $filter('sanitize')(vm.inputSources.credentialTypeName); + const endpoint = `${vm.inputSources.credentialId}/test/`; + + return vm.runTest({ name, model: credential, endpoint, data: { metadata } }); + }; + + vm.onExternalTestClick = () => { + credentialType.mergeInputProperties('metadata'); + vm.externalTest.metadata = credentialType.get('inputs.metadata'); + }; + + vm.onExternalTestClose = () => { + vm.externalTest.metadata = null; + }; + + vm.onExternalTest = () => { + const name = $filter('sanitize')(credentialType.get('name')); + const { inputs } = vm.getSubmitData(); + const metadata = Object.assign({}, ...vm.externalTest.form.inputs._group + .filter(({ _value }) => _value !== undefined) + .map(({ id, _value }) => ({ [id]: _value }))); + + let model; + if (credential.get('credential_type') !== credentialType.get('id')) { + model = credentialType; + } else { + model = credential; + } + + const endpoint = `${model.get('id')}/test/`; + return vm.runTest({ name, model, endpoint, data: { inputs, metadata } }); + }; + vm.form.secondary = vm.onExternalTestClick; + + vm.runTest = ({ name, model, endpoint, data: { inputs, metadata } }) => { + return model.http.post({ url: endpoint, data: { inputs, metadata }, replace: false }) .then(() => { ngToast.success({ - content: ` -
-
- -
-
- ${name}: ${strings.get('edit.TEST_PASSED')} -
-
`, + content: vm.buildTestNotificationContent({ + name, + icon: 'fa-check-circle', + msg: strings.get('edit.TEST_PASSED'), + }), dismissButton: false, dismissOnTimeout: true }); }) .catch(({ data }) => { - const msg = data.inputs ? `${$filter('sanitize')(data.inputs)}` : strings.get('edit.TEST_FAILED'); - + const msg = data.inputs + ? `${$filter('sanitize')(data.inputs)}` + : strings.get('edit.TEST_FAILED'); ngToast.danger({ - content: ` -
-
- -
-
- ${name}: ${msg} -
-
`, + content: vm.buildTestNotificationContent({ + name, + msg, + icon: 'fa-exclamation-triangle' + }), dismissButton: false, dismissOnTimeout: true }); }); }; + vm.buildTestNotificationContent = ({ name, msg, icon }) => ( + `
+
+ +
+
+ ${name}: ${msg} +
+
` + ); + /** * If a credential's `credential_type` is changed while editing, the inputs associated with * the old type need to be cleared before saving the inputs associated with the new type. @@ -258,6 +449,7 @@ EditCredentialsController.$inject = [ 'ngToast', 'Wait', '$filter', + 'CredentialTypeModel', ]; export default EditCredentialsController; diff --git a/awx/ui/client/features/credentials/external-test.component.js b/awx/ui/client/features/credentials/external-test.component.js new file mode 100644 index 0000000000..0c4fda3659 --- /dev/null +++ b/awx/ui/client/features/credentials/external-test.component.js @@ -0,0 +1,37 @@ +const templateUrl = require('~features/credentials/external-test.partial.html'); + +function ExternalTestModalController ($scope, $element, strings) { + const vm = this || {}; + let overlay; + + vm.strings = strings; + vm.title = 'Test External Credential'; + + vm.$onInit = () => { + const [el] = $element; + overlay = el.querySelector('#external-test-modal'); + vm.show(); + }; + + vm.show = () => { + overlay.style.display = 'block'; + overlay.style.opacity = 1; + }; +} + +ExternalTestModalController.$inject = [ + '$scope', + '$element', + 'CredentialsStrings', +]; + +export default { + templateUrl, + controller: ExternalTestModalController, + controllerAs: 'vm', + bindings: { + onClose: '=', + onSubmit: '=', + form: '=', + }, +}; diff --git a/awx/ui/client/features/credentials/external-test.partial.html b/awx/ui/client/features/credentials/external-test.partial.html new file mode 100644 index 0000000000..3d866c42f1 --- /dev/null +++ b/awx/ui/client/features/credentials/external-test.partial.html @@ -0,0 +1,41 @@ + diff --git a/awx/ui/client/features/credentials/index.js b/awx/ui/client/features/credentials/index.js index 38e291ef8e..2c13f645ed 100644 --- a/awx/ui/client/features/credentials/index.js +++ b/awx/ui/client/features/credentials/index.js @@ -2,6 +2,8 @@ import LegacyCredentials from './legacy.credentials'; import AddController from './add-credentials.controller'; import EditController from './edit-credentials.controller'; import CredentialsStrings from './credentials.strings'; +import InputSourceLookupComponent from './input-source-lookup.component'; +import ExternalTestComponent from './external-test.component'; const MODULE_NAME = 'at.features.credentials'; @@ -40,7 +42,8 @@ function CredentialsResolve ( const dependents = { credentialType: new CredentialType('get', typeId), - organization: new Organization('get', orgId) + organization: new Organization('get', orgId), + credentialInputSources: models.credential.extend('GET', 'input_sources') }; dependents.isOrgCredAdmin = dependents.organization.then((org) => org.search({ role_level: 'credential_admin_role' })); @@ -139,6 +142,8 @@ angular .controller('EditController', EditController) .service('LegacyCredentialsService', LegacyCredentials) .service('CredentialsStrings', CredentialsStrings) + .component('atInputSourceLookup', InputSourceLookupComponent) + .component('atExternalCredentialTest', ExternalTestComponent) .run(CredentialsRun); export default MODULE_NAME; diff --git a/awx/ui/client/features/credentials/input-source-lookup.component.js b/awx/ui/client/features/credentials/input-source-lookup.component.js new file mode 100644 index 0000000000..1ac781734c --- /dev/null +++ b/awx/ui/client/features/credentials/input-source-lookup.component.js @@ -0,0 +1,92 @@ +const templateUrl = require('~features/credentials/input-source-lookup.partial.html'); + +function InputSourceLookupController ($scope, $element, $http, GetBasePath, qs, strings) { + const vm = this || {}; + let overlay; + + vm.strings = strings; + vm.name = 'credential'; + vm.title = 'Set Input Source'; + + vm.$onInit = () => { + const [el] = $element; + overlay = el.querySelector('#input-source-lookup'); + + const defaultParams = { + order_by: 'name', + credential_type__kind: 'external', + page_size: 5 + }; + vm.setDefaultParams(defaultParams); + vm.setData({ results: [], count: 0 }); + $http({ method: 'GET', url: GetBasePath(`${vm.name}s`), params: defaultParams }) + .then(({ data }) => { + vm.setData(data); + vm.show(); + }); + }; + + vm.show = () => { + overlay.style.display = 'block'; + overlay.style.opacity = 1; + }; + + vm.close = () => { + vm.onClose(); + }; + + vm.next = () => { + vm.onNext(); + }; + + vm.select = () => { + vm.onSelect(); + }; + + vm.test = () => { + vm.onTest(); + }; + + vm.setData = ({ results, count }) => { + vm.dataset = { results, count }; + vm.collection = vm.dataset.results; + }; + + vm.setDefaultParams = (params) => { + vm.list = { name: vm.name, iterator: vm.name }; + vm.defaultParams = params; + vm.queryset = params; + }; + + vm.toggle_row = (obj) => { + vm.onRowClick(obj); + }; + + vm.onCredentialTabClick = () => vm.onTabSelect('credential'); +} + +InputSourceLookupController.$inject = [ + '$scope', + '$element', + '$http', + 'GetBasePath', + 'QuerySet', + 'CredentialsStrings', +]; + +export default { + templateUrl, + controller: InputSourceLookupController, + controllerAs: 'vm', + bindings: { + tabs: '=', + onClose: '=', + onNext: '=', + onSelect: '=', + onTabSelect: '=', + onRowClick: '=', + onTest: '=', + selectedId: '=', + form: '=', + }, +}; diff --git a/awx/ui/client/features/credentials/input-source-lookup.partial.html b/awx/ui/client/features/credentials/input-source-lookup.partial.html new file mode 100644 index 0000000000..4998201715 --- /dev/null +++ b/awx/ui/client/features/credentials/input-source-lookup.partial.html @@ -0,0 +1,174 @@ + diff --git a/awx/ui/client/lib/components/form/form.directive.js b/awx/ui/client/lib/components/form/form.directive.js index 9c008b19e6..7aaa90aa1a 100644 --- a/awx/ui/client/lib/components/form/form.directive.js +++ b/awx/ui/client/lib/components/form/form.directive.js @@ -87,13 +87,8 @@ function AtFormController (eventService, strings) { if (!vm.state.isValid) { return; } - - vm.state.disabled = true; - const data = vm.getSubmitData(); - - scope.state.secondary(data) - .finally(() => { vm.state.disabled = false; }); + scope.state.secondary(data); }; vm.submit = () => { diff --git a/awx/ui/client/lib/components/input/group.directive.js b/awx/ui/client/lib/components/input/group.directive.js index 291b534ac9..3e62023255 100644 --- a/awx/ui/client/lib/components/input/group.directive.js +++ b/awx/ui/client/lib/components/input/group.directive.js @@ -48,7 +48,7 @@ function AtInputGroupController ($scope, $compile) { state._value = source._value; - const inputs = state._get(source._value); + const inputs = state._get(form); const group = vm.createComponentConfigs(inputs); vm.insert(group); @@ -66,7 +66,9 @@ function AtInputGroupController ($scope, $compile) { _element: vm.createComponent(input, i), _key: 'inputs', _group: true, - _groupIndex: i + _groupIndex: i, + _onInputLookup: state._onInputLookup, + _onRemoveTag: state._onRemoveTag, }, input)); }); } @@ -160,7 +162,6 @@ function AtInputGroupController ($scope, $compile) { `); $compile(component)(scope.$parent); - return component; }; diff --git a/awx/ui/client/lib/components/input/group.partial.html b/awx/ui/client/lib/components/input/group.partial.html index 45d4a845e0..c32d29bb1e 100644 --- a/awx/ui/client/lib/components/input/group.partial.html +++ b/awx/ui/client/lib/components/input/group.partial.html @@ -1,7 +1,7 @@
-
+
-
+

diff --git a/awx/ui/client/lib/components/input/text.directive.js b/awx/ui/client/lib/components/input/text.directive.js index d28f4c2640..c24c807cd4 100644 --- a/awx/ui/client/lib/components/input/text.directive.js +++ b/awx/ui/client/lib/components/input/text.directive.js @@ -5,7 +5,10 @@ function atInputTextLink (scope, element, attrs, controllers) { const inputController = controllers[1]; if (scope.tab === '1') { - element.find('input')[0].focus(); + const el = element.find('input')[0]; + if (el) { + el.focus(); + } } inputController.init(scope, element, formController); @@ -20,9 +23,15 @@ function AtInputTextController (baseInputController) { baseInputController.call(vm, 'input', _scope_, element, form); scope = _scope_; - vm.check(); scope.$watch('state._value', () => vm.check()); }; + + vm.onLookupClick = () => { + if (scope.state._onInputLookup) { + const { id, label, required, type } = scope.state; + scope.state._onInputLookup({ id, label, required, type }); + } + }; } AtInputTextController.$inject = ['BaseInputController']; diff --git a/awx/ui/client/lib/components/input/text.partial.html b/awx/ui/client/lib/components/input/text.partial.html index 8a1a22137e..45d5c20340 100644 --- a/awx/ui/client/lib/components/input/text.partial.html +++ b/awx/ui/client/lib/components/input/text.partial.html @@ -1,15 +1,40 @@
- - - +
+ + + + + + + +
+
diff --git a/awx/ui/client/lib/components/tabs/tab.partial.html b/awx/ui/client/lib/components/tabs/tab.partial.html index 747e470571..eb007feb01 100644 --- a/awx/ui/client/lib/components/tabs/tab.partial.html +++ b/awx/ui/client/lib/components/tabs/tab.partial.html @@ -2,6 +2,6 @@ ng-attr-disabled="{{ state._disabled || undefined }}" ng-class="{ 'at-Tab--active': state._active, 'at-Tab--disabled': state._disabled }" ng-hide="{{ state._hide }}" - ng-click="vm.go()"> + ng-click="state._go && vm.go();"> diff --git a/awx/ui/client/lib/components/tag/_index.less b/awx/ui/client/lib/components/tag/_index.less index 4d13212442..b91830f51f 100644 --- a/awx/ui/client/lib/components/tag/_index.less +++ b/awx/ui/client/lib/components/tag/_index.less @@ -61,6 +61,10 @@ &--vault:before { content: '\f187'; } + + &--external:before { + content: '\f14c' + } } .TagComponent-button { diff --git a/awx/ui/client/lib/models/CredentialType.js b/awx/ui/client/lib/models/CredentialType.js index 0607350ad8..38c44cd845 100644 --- a/awx/ui/client/lib/models/CredentialType.js +++ b/awx/ui/client/lib/models/CredentialType.js @@ -15,18 +15,18 @@ function categorizeByKind () { })); } -function mergeInputProperties () { - if (!this.has('inputs.fields')) { +function mergeInputProperties (key = 'fields') { + if (!this.has(`inputs.${key}`)) { return undefined; } const required = this.get('inputs.required'); - return this.get('inputs.fields').forEach((field, i) => { + return this.get(`inputs.${key}`).forEach((field, i) => { if (!required || required.indexOf(field.id) === -1) { - this.set(`inputs.fields[${i}].required`, false); + this.set(`inputs.${key}[${i}].required`, false); } else { - this.set(`inputs.fields[${i}].required`, true); + this.set(`inputs.${key}[${i}].required`, true); } }); } diff --git a/awx/ui/client/lib/services/base-string.service.js b/awx/ui/client/lib/services/base-string.service.js index 83a860eeb1..6db4249b49 100644 --- a/awx/ui/client/lib/services/base-string.service.js +++ b/awx/ui/client/lib/services/base-string.service.js @@ -61,7 +61,9 @@ function BaseStringService (namespace) { this.CANCEL = t.s('CANCEL'); this.CLOSE = t.s('CLOSE'); this.SAVE = t.s('SAVE'); + this.SELECT = t.s('SELECT'); this.OK = t.s('OK'); + this.RUN = t.s('RUN'); this.NEXT = t.s('NEXT'); this.SHOW = t.s('SHOW'); this.HIDE = t.s('HIDE'); diff --git a/awx/ui/client/src/shared/smart-search/smart-search.controller.js b/awx/ui/client/src/shared/smart-search/smart-search.controller.js index 2294fa474f..2109b1e03e 100644 --- a/awx/ui/client/src/shared/smart-search/smart-search.controller.js +++ b/awx/ui/client/src/shared/smart-search/smart-search.controller.js @@ -102,8 +102,9 @@ function SmartSearchController ( const rootField = termParts[0].split('.')[0].replace(/^-/, ''); const listName = $scope.list.name; const baseRelatedTypePath = `models.${listName}.base.${rootField}.type`; + const relatedTypePath = `models.${listName}.related`; - const isRelatedSearchTermField = (_.includes($scope.models[listName].related, rootField)); + const isRelatedSearchTermField = (_.includes(_.get($scope, relatedTypePath), rootField)); const isBaseModelRelatedSearchTermField = (_.get($scope, baseRelatedTypePath) === 'field'); return (isRelatedSearchTermField || isBaseModelRelatedSearchTermField); diff --git a/awx/ui/client/src/templates/prompt/prompt.block.less b/awx/ui/client/src/templates/prompt/prompt.block.less index c5670874ed..d9f7e3b6fd 100644 --- a/awx/ui/client/src/templates/prompt/prompt.block.less +++ b/awx/ui/client/src/templates/prompt/prompt.block.less @@ -44,6 +44,17 @@ height: 30px; min-width: 85px; } + +.Prompt-infoButton{ + .at-mixin-ButtonColor('at-color-info', 'at-color-default'); + text-transform: uppercase; + border-radius: 5px; + padding-left:15px; + padding-right: 15px; + height: 30px; + min-width: 85px; + margin-right: 20px; +} .Prompt-defaultButton:hover{ background-color: @btn-bg-hov; color: @btn-txt; From 2824616ba6588e6b64933c2a3d044cb64091c24a Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Thu, 28 Feb 2019 13:10:44 -0500 Subject: [PATCH 27/74] add support for CyberArk Conjur (API v5) --- Makefile | 5 +- awx/main/credential_plugins/conjur.py | 92 +++++++++++++++++++ setup.py | 1 + .../awx.egg-info/entry_points.txt | 1 + tools/docker-credential-plugins-override.yml | 28 ++++++ tools/docker-hashivault-override.yml | 15 --- 6 files changed, 125 insertions(+), 17 deletions(-) create mode 100644 awx/main/credential_plugins/conjur.py create mode 100644 tools/docker-credential-plugins-override.yml delete mode 100644 tools/docker-hashivault-override.yml diff --git a/Makefile b/Makefile index 294d940ba1..e0cfc4b78f 100644 --- a/Makefile +++ b/Makefile @@ -575,8 +575,9 @@ docker-compose: docker-auth docker-compose-cluster: docker-auth CURRENT_UID=$(shell id -u) TAG=$(COMPOSE_TAG) DEV_DOCKER_TAG_BASE=$(DEV_DOCKER_TAG_BASE) docker-compose -f tools/docker-compose-cluster.yml up -docker-compose-hashivault: docker-auth - CURRENT_UID=$(shell id -u) TAG=$(COMPOSE_TAG) DEV_DOCKER_TAG_BASE=$(DEV_DOCKER_TAG_BASE) docker-compose -f tools/docker-compose.yml -f tools/docker-hashivault-override.yml up --no-recreate awx +docker-compose-credential-plugins: docker-auth + echo -e "\033[0;31mTo generate a CyberArk Conjur API key: docker exec -it tools_conjur_1 conjurctl account create quick-start\033[0m" + CURRENT_UID=$(shell id -u) TAG=$(COMPOSE_TAG) DEV_DOCKER_TAG_BASE=$(DEV_DOCKER_TAG_BASE) docker-compose -f tools/docker-compose.yml -f tools/docker-credential-plugins-override.yml up --no-recreate awx docker-compose-test: docker-auth cd tools && CURRENT_UID=$(shell id -u) TAG=$(COMPOSE_TAG) DEV_DOCKER_TAG_BASE=$(DEV_DOCKER_TAG_BASE) docker-compose run --rm --service-ports awx /bin/bash diff --git a/awx/main/credential_plugins/conjur.py b/awx/main/credential_plugins/conjur.py new file mode 100644 index 0000000000..f5b6ae23d0 --- /dev/null +++ b/awx/main/credential_plugins/conjur.py @@ -0,0 +1,92 @@ +from .plugin import CredentialPlugin + +import base64 +import io +from urllib.parse import urljoin, quote_plus + +from django.utils.translation import ugettext_lazy as _ +import requests + + +conjur_inputs = { + 'fields': [{ + 'id': 'url', + 'label': _('Conjur URL'), + 'type': 'string', + }, { + 'id': 'api_key', + 'label': _('API Key'), + 'type': 'string', + 'secret': True, + }, { + 'id': 'account', + 'label': _('Account'), + 'type': 'string', + }, { + 'id': 'username', + 'label': _('Username'), + 'type': 'string', + }, { + 'id': 'cacert', + 'label': _('Public Key Certificate'), + 'type': 'string', + 'multiline': True + }], + 'metadata': [{ + 'id': 'secret_path', + 'label': _('Secret Identifier'), + 'type': 'string', + 'help_text': _('The identifier for the secret e.g., /some/identifier'), + }, { + 'id': 'secret_version', + 'label': _('Secret Version'), + 'type': 'string', + 'help_text': _('Used to specify a specific secret version (if left empty, the latest version will be used).'), + }], + 'required': ['url', 'api_key', 'account', 'username'], +} + + +def conjur_backend(raw, **kwargs): + url = kwargs['url'] + api_key = kwargs['api_key'] + account = quote_plus(kwargs['account']) + username = quote_plus(kwargs['username']) + secret_path = quote_plus(kwargs['secret_path']) + version = kwargs.get('secret_version') + cert = io.StringIO() + cert.write(kwargs.get('cacert', '')) + + # https://www.conjur.org/api.html#authentication-authenticate-post + resp = requests.post( + urljoin(url, '/'.join(['authn', account, username, 'authenticate'])), + headers={'Content-Type': 'text/plain'}, + data=api_key, + verify=cert + ) + resp.raise_for_status() + token = base64.b64encode(resp.content).decode('utf-8') + + # https://www.conjur.org/api.html#secrets-retrieve-a-secret-get + path = urljoin(url, '/'.join([ + 'secrets', + account, + 'variable', + secret_path + ])) + if version: + path = '?'.join([path, version]) + resp = requests.get( + path, + headers={'Authorization': 'Token token="{}"'.format(token)}, + verify=cert + ) + resp.raise_for_status() + return resp.text + + +conjur_plugin = CredentialPlugin( + 'CyberArk Conjur Secret Lookup', + inputs=conjur_inputs, + backend=conjur_backend +) diff --git a/setup.py b/setup.py index 1fadc25dcf..957976a5c5 100755 --- a/setup.py +++ b/setup.py @@ -115,6 +115,7 @@ setup( 'awx-manage = awx:manage', ], 'awx.credential_plugins': [ + 'conjur = awx.main.credential_plugins.conjur:conjur_plugin', 'hashivault_kv = awx.main.credential_plugins.hashivault:hashivault_kv_plugin', 'hashivault_ssh = awx.main.credential_plugins.hashivault:hashivault_ssh_plugin', 'azure_kv = awx.main.credential_plugins.azure_kv:azure_keyvault_plugin', diff --git a/tools/docker-compose/awx.egg-info/entry_points.txt b/tools/docker-compose/awx.egg-info/entry_points.txt index f1832e730c..dfc186681d 100644 --- a/tools/docker-compose/awx.egg-info/entry_points.txt +++ b/tools/docker-compose/awx.egg-info/entry_points.txt @@ -3,6 +3,7 @@ tower-manage = awx:manage awx-manage = awx:manage [awx.credential_plugins] +conjur = awx.main.credential_plugins.conjur:conjur_plugin hashivault_kv = awx.main.credential_plugins.hashivault:hashivault_kv_plugin hashivault_ssh = awx.main.credential_plugins.hashivault:hashivault_ssh_plugin azure_kv = awx.main.credential_plugins.azure_kv:azure_keyvault_plugin diff --git a/tools/docker-credential-plugins-override.yml b/tools/docker-credential-plugins-override.yml new file mode 100644 index 0000000000..07e596e218 --- /dev/null +++ b/tools/docker-credential-plugins-override.yml @@ -0,0 +1,28 @@ +version: '2' +services: + # Primary Tower Development Container link + awx: + links: + - hashivault + - conjur + hashivault: + image: vault:1.0.1 + container_name: tools_hashivault_1 + ports: + - '8200:8200' + cap_add: + - IPC_LOCK + environment: + VAULT_DEV_ROOT_TOKEN_ID: 'vaultdev' + + conjur: + image: cyberark/conjur + command: server -p 8300 + environment: + DATABASE_URL: postgres://postgres@postgres/postgres + CONJUR_DATA_KEY: 'dveUwOI/71x9BPJkIgvQRRBF3SdASc+HP4CUGL7TKvM=' + depends_on: [ postgres ] + links: + - postgres + ports: + - "8300:8300" diff --git a/tools/docker-hashivault-override.yml b/tools/docker-hashivault-override.yml deleted file mode 100644 index 8ada54abcb..0000000000 --- a/tools/docker-hashivault-override.yml +++ /dev/null @@ -1,15 +0,0 @@ -version: '2' -services: - # Primary Tower Development Container link - awx: - links: - - hashivault - hashivault: - image: vault:1.0.1 - container_name: tools_hashivault_1 - ports: - - '8200:8200' - cap_add: - - IPC_LOCK - environment: - VAULT_DEV_ROOT_TOKEN_ID: 'vaultdev' From 0768c6ac1d2a5fa8f94756fbbdfbd0b516660e0c Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Fri, 1 Mar 2019 00:27:03 -0500 Subject: [PATCH 28/74] store the public key for HashiVault signing in the plugin metadata --- awx/api/views/__init__.py | 10 ++++++++-- awx/main/credential_plugins/azure_kv.py | 2 +- awx/main/credential_plugins/conjur.py | 2 +- awx/main/credential_plugins/hashivault.py | 17 +++++++++++------ awx/main/models/credential/__init__.py | 8 +------- docs/credential_plugins.md | 20 ++------------------ 6 files changed, 24 insertions(+), 35 deletions(-) diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 71355f46f7..24b760d8b0 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -1440,8 +1440,11 @@ class CredentialExternalTest(SubDetailAPIView): backend_kwargs[field_name] = value backend_kwargs.update(request.data.get('metadata', {})) try: - obj.credential_type.plugin.backend(None, **backend_kwargs) + obj.credential_type.plugin.backend(**backend_kwargs) return Response({}, status=status.HTTP_202_ACCEPTED) + except requests.exceptions.HTTPError as exc: + message = 'HTTP {}\n{}'.format(exc.response.status_code, exc.response.text) + return Response({'inputs': message}, status=status.HTTP_400_BAD_REQUEST) except Exception as exc: return Response({'inputs': str(exc)}, status=status.HTTP_400_BAD_REQUEST) @@ -1489,8 +1492,11 @@ class CredentialTypeExternalTest(SubDetailAPIView): backend_kwargs = request.data.get('inputs', {}) backend_kwargs.update(request.data.get('metadata', {})) try: - obj.plugin.backend(None, **backend_kwargs) + obj.plugin.backend(**backend_kwargs) return Response({}, status=status.HTTP_202_ACCEPTED) + except requests.exceptions.HTTPError as exc: + message = 'HTTP {}\n{}'.format(exc.response.status_code, exc.response.text) + return Response({'inputs': message}, status=status.HTTP_400_BAD_REQUEST) except Exception as exc: return Response({'inputs': str(exc)}, status=status.HTTP_400_BAD_REQUEST) diff --git a/awx/main/credential_plugins/azure_kv.py b/awx/main/credential_plugins/azure_kv.py index 38c6845715..ee193974d0 100644 --- a/awx/main/credential_plugins/azure_kv.py +++ b/awx/main/credential_plugins/azure_kv.py @@ -39,7 +39,7 @@ azure_keyvault_inputs = { } -def azure_keyvault_backend(raw, **kwargs): +def azure_keyvault_backend(**kwargs): url = kwargs['url'] def auth_callback(server, resource, scope): diff --git a/awx/main/credential_plugins/conjur.py b/awx/main/credential_plugins/conjur.py index f5b6ae23d0..645ff16afa 100644 --- a/awx/main/credential_plugins/conjur.py +++ b/awx/main/credential_plugins/conjur.py @@ -47,7 +47,7 @@ conjur_inputs = { } -def conjur_backend(raw, **kwargs): +def conjur_backend(**kwargs): url = kwargs['url'] api_key = kwargs['api_key'] account = quote_plus(kwargs['account']) diff --git a/awx/main/credential_plugins/hashivault.py b/awx/main/credential_plugins/hashivault.py index 89519492b9..2f058d9dad 100644 --- a/awx/main/credential_plugins/hashivault.py +++ b/awx/main/credential_plugins/hashivault.py @@ -53,7 +53,12 @@ hashi_kv_inputs['metadata'].extend([{ hashi_kv_inputs['required'].extend(['api_version', 'secret_key']) hashi_ssh_inputs = copy.deepcopy(base_inputs) -hashi_ssh_inputs['metadata'].extend([{ +hashi_ssh_inputs['metadata'] = [{ + 'id': 'public_key', + 'label': _('Unsigned Public Key'), + 'type': 'string', + 'multiline': True, +}] + hashi_ssh_inputs['metadata'] + [{ 'id': 'role', 'label': _('Role Name'), 'type': 'string', @@ -63,11 +68,11 @@ hashi_ssh_inputs['metadata'].extend([{ 'label': _('Valid Principals'), 'type': 'string', 'help_text': _('Valid principals (either usernames or hostnames) that the certificate should be signed for.'), -}]) -hashi_ssh_inputs['required'].extend(['role']) +}] +hashi_ssh_inputs['required'].extend(['public_key', 'role']) -def kv_backend(raw, **kwargs): +def kv_backend(**kwargs): token = kwargs['token'] url = urljoin(kwargs['url'], 'v1') secret_path = kwargs['secret_path'] @@ -109,7 +114,7 @@ def kv_backend(raw, **kwargs): return json['data'] -def ssh_backend(raw, **kwargs): +def ssh_backend(**kwargs): token = kwargs['token'] url = urljoin(kwargs['url'], 'v1') secret_path = kwargs['secret_path'] @@ -118,7 +123,7 @@ def ssh_backend(raw, **kwargs): sess = requests.Session() sess.headers['Authorization'] = 'Bearer {}'.format(token) json = { - 'public_key': raw + 'public_key': kwargs['public_key'] } if kwargs.get('valid_principals'): json['valid_principals'] = kwargs['valid_principals'] diff --git a/awx/main/models/credential/__init__.py b/awx/main/models/credential/__init__.py index ea24636eb1..2d9f9892d5 100644 --- a/awx/main/models/credential/__init__.py +++ b/awx/main/models/credential/__init__.py @@ -1362,13 +1362,7 @@ class CredentialInputSource(PrimordialModel): backend_kwargs[field_name] = value backend_kwargs.update(self.metadata) - raw = self.target_credential.inputs.get(self.input_field_name) - if self.input_field_name in self.target_credential.credential_type.secret_fields: - raw = decrypt_field(self.target_credential, self.input_field_name) - return backend( - raw, - **backend_kwargs - ) + return backend(**backend_kwargs) def get_absolute_url(self, request=None): view_name = 'api:credential_input_source_detail' diff --git a/docs/credential_plugins.md b/docs/credential_plugins.md index d00e66bdb2..7a4752d7a7 100644 --- a/docs/credential_plugins.md +++ b/docs/credential_plugins.md @@ -124,22 +124,6 @@ setuptools.setup( ) ``` -Fetching vs. Transforming Credential Data ------------------------------------------ -While _most_ credential plugins will be used to _fetch_ secrets from external -systems, they can also be used to *transform* data from Tower _using_ an -external secret management system. An example use case is generating signed -public keys: - -```python -def my_key_signer(unsigned_value_from_awx, **kwargs): - return some_libary.sign( - url=kwargs['url'], - token=kwargs['token'], - public_data=unsigned_value_from_awx - ) -``` - Programmatic Secret Fetching ---------------------------- If you want to programmatically fetch secrets from a supported external secret @@ -288,7 +272,7 @@ HTTP/1.1 200 OK -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -X POST \ - -d '{"user": N, "credential_type": 1, "name": "My SSH", "inputs": {"username": "example", "ssh_key_data": "RSA KEY DATA", "ssh_public_key_data": "UNSIGNED PUBLIC KEY DATA"}}' + -d '{"user": N, "credential_type": 1, "name": "My SSH", "inputs": {"username": "example", "ssh_key_data": "RSA KEY DATA"}}' HTTP/1.1 201 Created { @@ -320,7 +304,7 @@ HTTP/1.1 201 Created -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -X POST \ - -d '{"source_credential": 2, "input_field_name": "password", "metadata": {"secret_path": "/ssh/", "role": "example-role"}}' + -d '{"source_credential": 2, "input_field_name": "password", "metadata": {"public_key": "UNSIGNED PUBLIC KEY", "secret_path": "/ssh/", "role": "example-role"}}' HTTP/1.1 201 Created ``` From 393ad6b2f46cbb6699acd739679781923dd4348b Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Fri, 1 Mar 2019 14:33:48 -0500 Subject: [PATCH 29/74] add cyberark conjur to tested credential types --- awx/main/tests/functional/test_credential.py | 1 + 1 file changed, 1 insertion(+) diff --git a/awx/main/tests/functional/test_credential.py b/awx/main/tests/functional/test_credential.py index 480d4b8ede..882bc2755e 100644 --- a/awx/main/tests/functional/test_credential.py +++ b/awx/main/tests/functional/test_credential.py @@ -79,6 +79,7 @@ def test_default_cred_types(): 'azure_kv', 'azure_rm', 'cloudforms', + 'conjur', 'gce', 'hashivault_kv', 'hashivault_ssh', From ceef7f57af17fd87030f9257c4d70aa32a9c77d3 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Mon, 4 Mar 2019 09:28:07 -0500 Subject: [PATCH 30/74] add input source creation ui --- .../credentials/add-credentials.controller.js | 250 ++++++++------ .../add-edit-credentials.view.html | 3 +- .../credentials/credentials.strings.js | 14 +- .../edit-credentials.controller.js | 320 ++++++++++++------ .../external-test-modal.component.js | 23 ++ .../external-test-modal.partial.html | 13 + .../credentials/external-test.component.js | 37 -- .../credentials/external-test.partial.html | 41 --- awx/ui/client/features/credentials/index.js | 4 +- .../input-source-lookup.component.js | 68 +--- .../input-source-lookup.partial.html | 240 ++++--------- awx/ui/client/lib/components/_index.less | 1 + .../action/action-button.component.js | 33 ++ .../action/action-button.partial.html | 3 + .../lib/components/easy-modal/_index.less | 27 ++ .../easy-modal/easy-modal.component.js | 29 ++ .../easy-modal/easy-modal.partial.html | 19 ++ .../lib/components/form/form.directive.js | 2 +- awx/ui/client/lib/components/index.js | 6 + .../lookup-list/lookup-list.component.js | 51 +++ .../lookup-list/lookup-list.partial.html | 90 +++++ .../src/templates/prompt/prompt.block.less | 11 - 22 files changed, 759 insertions(+), 526 deletions(-) create mode 100644 awx/ui/client/features/credentials/external-test-modal.component.js create mode 100644 awx/ui/client/features/credentials/external-test-modal.partial.html delete mode 100644 awx/ui/client/features/credentials/external-test.component.js delete mode 100644 awx/ui/client/features/credentials/external-test.partial.html create mode 100644 awx/ui/client/lib/components/action/action-button.component.js create mode 100644 awx/ui/client/lib/components/action/action-button.partial.html create mode 100644 awx/ui/client/lib/components/easy-modal/_index.less create mode 100644 awx/ui/client/lib/components/easy-modal/easy-modal.component.js create mode 100644 awx/ui/client/lib/components/easy-modal/easy-modal.partial.html create mode 100644 awx/ui/client/lib/components/lookup-list/lookup-list.component.js create mode 100644 awx/ui/client/lib/components/lookup-list/lookup-list.partial.html diff --git a/awx/ui/client/features/credentials/add-credentials.controller.js b/awx/ui/client/features/credentials/add-credentials.controller.js index 2acad45ae9..a097b8716e 100644 --- a/awx/ui/client/features/credentials/add-credentials.controller.js +++ b/awx/ui/client/features/credentials/add-credentials.controller.js @@ -11,10 +11,13 @@ function AddCredentialsController ( Wait, $filter, CredentialType, + GetBasePath, + Rest, ) { const vm = this || {}; const { me, credential, credentialType, organization } = models; + const isExternal = credentialType.get('kind') === 'external'; vm.mode = 'add'; vm.strings = strings; @@ -44,44 +47,6 @@ function AddCredentialsController ( vm.form.credential_type._placeholder = strings.get('inputs.CREDENTIAL_TYPE_PLACEHOLDER'); vm.isTestable = credentialType.get('kind') === 'external'; - vm.inputSources = { - field: null, - credentialId: null, - credentialTypeId: null, - credentialTypeName: null, - tabs: { - credential: { - _active: true, - _disabled: false, - }, - metadata: { - _active: false, - _disabled: false, - } - }, - metadata: {}, - form: { - inputs: { - _get: () => vm.inputSources.metadata, - _reference: 'vm.form.inputs', - _key: 'inputs', - _source: { _value: {} }, - } - }, - items: [], - }; - vm.externalTest = { - metadata: null, - form: { - inputs: { - _get: () => vm.externalTest.metadata, - _reference: 'vm.form.inputs', - _key: 'inputs', - _source: { _value: {} }, - } - }, - }; - const gceFileInputSchema = { id: 'gce_service_account_key', type: 'file', @@ -106,12 +71,20 @@ function AddCredentialsController ( become._isDynamic = true; become._choices = Array.from(apiConfig.become_methods, method => method[0]); } - vm.isTestable = credentialType.get('kind') === 'external'; + vm.isTestable = (credentialType.get('kind') === 'external'); vm.getSubmitData = getSubmitData; + vm.inputSources.items = []; + const linkedFieldNames = vm.inputSources.items + .map(({ input_field_name }) => input_field_name); + fields = fields.map((field) => { - if (credentialType.get('kind') !== 'external') { - field.tagMode = true; + field.tagMode = credentialType.get('kind') !== 'external'; + if (linkedFieldNames.includes(field.id)) { + field.asTag = true; + const { summary_fields } = vm.inputSources.items + .find(({ input_field_name }) => input_field_name === field.id); + field._value = summary_fields.source_credential.name; } return field; }); @@ -129,120 +102,184 @@ function AddCredentialsController ( _key: 'inputs' }; + vm.externalTest = { + form: { + inputs: { + _get: () => vm.externalTest.metadataInputs, + _reference: 'vm.form.inputs', + _key: 'inputs', + _source: { _value: {} }, + }, + }, + metadataInputs: null, + }; + vm.inputSources = { + tabs: { + credential: { + _active: true, + _disabled: false, + }, + metadata: { + _active: false, + _disabled: false, + } + }, + form: { + inputs: { + _get: () => vm.inputSources.metadataInputs, + _reference: 'vm.form.inputs', + _key: 'inputs', + _source: { _value: {} }, + }, + }, + field: null, + credentialTypeId: null, + credentialTypeName: null, + credentialId: null, + credentialName: null, + metadataInputs: null, + initialItems: credential.get('related.input_sources.results'), + items: credential.get('related.input_sources.results'), + }; + vm.onInputSourceClear = (field) => { vm.form[field].tagMode = true; vm.form[field].asTag = false; + vm.form[field]._value = ''; + vm.inputSources.items = vm.inputSources.items + .filter(({ input_field_name }) => input_field_name !== field); }; - vm.setTab = (name) => { + function setInputSourceTab (name) { const metaIsActive = name === 'metadata'; vm.inputSources.tabs.credential._active = !metaIsActive; vm.inputSources.tabs.credential._disabled = false; vm.inputSources.tabs.metadata._active = metaIsActive; vm.inputSources.tabs.metadata._disabled = false; - }; + } - vm.unsetTabs = () => { + function unsetInputSourceTabs () { vm.inputSources.tabs.credential._active = false; vm.inputSources.tabs.credential._disabled = false; vm.inputSources.tabs.metadata._active = false; vm.inputSources.tabs.metadata._disabled = false; - }; + } vm.onInputSourceOpen = (field) => { - vm.inputSources.field = field; - vm.setTab('credential'); const sourceItem = vm.inputSources.items .find(({ input_field_name }) => input_field_name === field); if (sourceItem) { const { source_credential, summary_fields } = sourceItem; - const { source_credential: { credential_type_id } } = summary_fields; + const { source_credential: { credential_type_id, name } } = summary_fields; vm.inputSources.credentialId = source_credential; + vm.inputSources.credentialName = name; vm.inputSources.credentialTypeId = credential_type_id; vm.inputSources._value = credential_type_id; } + setInputSourceTab('credential'); + vm.inputSources.field = field; }; vm.onInputSourceClose = () => { vm.inputSources.field = null; - vm.inputSources.metadata = null; - vm.unsetTabs(); + vm.inputSources.credentialId = null; + vm.inputSources.credentialName = null; + vm.inputSources.metadataInputs = null; + unsetInputSourceTabs(); }; + /** + * Extract the current set of input values from the metadata form and reshape them to a + * metadata object that can be sent to the api later or reloaded when re-opening the form. + */ + function getMetadataFormSubmitData ({ inputs }) { + const metadata = Object.assign({}, ...inputs._group + .filter(({ _value }) => _value !== undefined) + .map(({ id, _value }) => ({ [id]: _value }))); + return metadata; + } + vm.onInputSourceNext = () => { const { field, credentialId, credentialTypeId } = vm.inputSources; Wait('start'); new CredentialType('get', credentialTypeId) .then(model => { model.mergeInputProperties('metadata'); - vm.inputSources.metadata = model.get('inputs.metadata'); + vm.inputSources.metadataInputs = model.get('inputs.metadata'); vm.inputSources.credentialTypeName = model.get('name'); const [metavals] = vm.inputSources.items .filter(({ input_field_name }) => input_field_name === field) .filter(({ source_credential }) => source_credential === credentialId) .map(({ metadata }) => metadata); Object.keys(metavals || {}).forEach(key => { - const obj = vm.inputSources.metadata.find(o => o.id === key); + const obj = vm.inputSources.metadataInputs.find(o => o.id === key); if (obj) obj._value = metavals[key]; }); - vm.setTab('metadata'); + setInputSourceTab('metadata'); }) .finally(() => Wait('stop')); }; vm.onInputSourceSelect = () => { - const { field, credentialId } = vm.inputSources; + const { field, credentialId, credentialName, credentialTypeId } = vm.inputSources; + const metadata = getMetadataFormSubmitData(vm.inputSources.form); vm.inputSources.items = vm.inputSources.items .filter(({ input_field_name }) => input_field_name !== field) .concat([{ + metadata, input_field_name: field, source_credential: credentialId, target_credential: credential.get('id'), + summary_fields: { + source_credential: { + name: credentialName, + credential_type_id: credentialTypeId + } + }, }]); vm.inputSources.field = null; - vm.inputSources.metadata = null; - vm.unsetTabs(); + vm.inputSources.metadataInputs = null; + unsetInputSourceTabs(); + vm.form[field]._value = credentialName; + vm.form[field].asTag = true; }; vm.onInputSourceTabSelect = (name) => { if (name === 'metadata') { vm.onInputSourceNext(); } else { - vm.setTab('credential'); + setInputSourceTab('credential'); } }; - vm.onInputSourceRowClick = ({ id, credential_type }) => { + vm.onInputSourceRowClick = ({ id, credential_type, name }) => { vm.inputSources.credentialId = id; + vm.inputSources.credentialName = name; vm.inputSources.credentialTypeId = credential_type; vm.inputSources._value = credential_type; }; vm.onInputSourceTest = () => { - const metadata = Object.assign({}, ...vm.inputSources.form.inputs._group - .filter(({ _value }) => _value !== undefined) - .map(({ id, _value }) => ({ [id]: _value }))); + const metadata = getMetadataFormSubmitData(vm.inputSources.form); const name = $filter('sanitize')(vm.inputSources.credentialTypeName); const endpoint = `${vm.inputSources.credentialId}/test/`; - - return vm.runTest({ name, model: credential, endpoint, data: { metadata } }); + return runTest({ name, model: credential, endpoint, data: { metadata } }); }; - vm.onExternalTestClick = () => { + function onExternalTestOpen () { credentialType.mergeInputProperties('metadata'); - vm.externalTest.metadata = credentialType.get('inputs.metadata'); - }; + vm.externalTest.metadataInputs = credentialType.get('inputs.metadata'); + } + vm.form.secondary = onExternalTestOpen; vm.onExternalTestClose = () => { - vm.externalTest.metadata = null; + vm.externalTest.metadataInputs = null; }; vm.onExternalTest = () => { const name = $filter('sanitize')(credentialType.get('name')); const { inputs } = vm.getSubmitData(); - const metadata = Object.assign({}, ...vm.externalTest.form.inputs._group - .filter(({ _value }) => _value !== undefined) - .map(({ id, _value }) => ({ [id]: _value }))); + const metadata = getMetadataFormSubmitData(vm.externalTest.form); let model; if (credential.get('credential_type') !== credentialType.get('id')) { @@ -252,49 +289,57 @@ function AddCredentialsController ( } const endpoint = `${model.get('id')}/test/`; - return vm.runTest({ name, model, endpoint, data: { inputs, metadata } }); + return runTest({ name, model, endpoint, data: { inputs, metadata } }); }; - vm.form.secondary = vm.onExternalTestClick; - vm.runTest = ({ name, model, endpoint, data: { inputs, metadata } }) => { + vm.filterInputSourceCredentialResults = (data) => { + if (isExternal) { + data.results = data.results.filter(({ id }) => id !== credential.get('id')); + } + return data; + }; + + function runTest ({ name, model, endpoint, data: { inputs, metadata } }) { return model.http.post({ url: endpoint, data: { inputs, metadata }, replace: false }) .then(() => { + const icon = 'fa-check-circle'; + const msg = strings.get('edit.TEST_PASSED'); + const content = buildTestNotificationContent({ name, icon, msg }); ngToast.success({ - content: vm.buildTestNotificationContent({ - name, - icon: 'fa-check-circle', - msg: strings.get('edit.TEST_PASSED'), - }), + content, dismissButton: false, dismissOnTimeout: true }); }) .catch(({ data }) => { - const msg = data.inputs - ? `${$filter('sanitize')(data.inputs)}` - : strings.get('edit.TEST_FAILED'); + const icon = 'fa-exclamation-triangle'; + const msg = data.inputs || strings.get('edit.TEST_FAILED'); + const content = buildTestNotificationContent({ name, icon, msg }); ngToast.danger({ - content: vm.buildTestNotificationContent({ - name, - msg, - icon: 'fa-exclamation-triangle' - }), + content, dismissButton: false, dismissOnTimeout: true }); }); - }; + } - vm.buildTestNotificationContent = ({ name, msg, icon }) => ( - `
+ function buildTestNotificationContent ({ name, msg, icon }) { + const sanitize = $filter('sanitize'); + const content = `
- ${name}: ${msg} + ${sanitize(name)}: ${sanitize(msg)}
-
` - ); +
`; + return content; + } + + function createInputSource (data) { + Rest.setUrl(GetBasePath('credential_input_sources')); + return Rest.post(data); + } vm.form.save = data => { data.user = me.get('id'); @@ -303,14 +348,25 @@ function AddCredentialsController ( delete data.inputs[gceFileInputSchema.id]; } - const filteredInputs = _.omit(data.inputs, (value) => value === ''); + const updatedLinkedFieldNames = vm.inputSources.items + .map(({ input_field_name }) => input_field_name); + const sourcesToAssociate = [...vm.inputSources.items]; + + // remove inputs with empty string values + let filteredInputs = _.omit(data.inputs, (value) => value === ''); + // remove inputs that are to be linked to an external credential + filteredInputs = _.omit(filteredInputs, updatedLinkedFieldNames); data.inputs = filteredInputs; - return credential.request('post', { data }); + return credential.request('post', { data }) + .then(() => { + sourcesToAssociate.forEach(obj => { obj.target_credential = credential.get('id'); }); + return Promise.all(sourcesToAssociate.map(createInputSource)); + }); }; - vm.form.onSaveSuccess = res => { - $state.go('credentials.edit', { credential_id: res.data.id }, { reload: true }); + vm.form.onSaveSuccess = () => { + $state.go('credentials.edit', { credential_id: credential.get('id') }, { reload: true }); }; vm.gceOnFileInputChanged = (value, oldValue) => { @@ -381,6 +437,8 @@ AddCredentialsController.$inject = [ 'Wait', '$filter', 'CredentialTypeModel', + 'GetBasePath', + 'Rest', ]; export default AddCredentialsController; diff --git a/awx/ui/client/features/credentials/add-edit-credentials.view.html b/awx/ui/client/features/credentials/add-edit-credentials.view.html index 914c98dcfa..45c6568552 100644 --- a/awx/ui/client/features/credentials/add-edit-credentials.view.html +++ b/awx/ui/client/features/credentials/add-edit-credentials.view.html @@ -54,9 +54,10 @@ on-tab-select="vm.onInputSourceTabSelect" on-row-click="vm.onInputSourceRowClick" on-test="vm.onInputSourceTest" + results-filter="vm.filterInputSourceCredentialResults" /> vm.inputSources.metadata, - _reference: 'vm.form.inputs', - _key: 'inputs', - _source: { _value: {} }, - } - }, - items: credential.get('related.input_sources.results'), - }; - vm.externalTest = { - metadata: null, - form: { - inputs: { - _get: () => vm.externalTest.metadata, - _reference: 'vm.form.inputs', - _key: 'inputs', - _source: { _value: {} }, - } - }, - }; - const gceFileInputSchema = { id: 'gce_service_account_key', type: 'file', @@ -175,16 +140,30 @@ function EditCredentialsController ( } } + vm.isTestable = (isEditable && credentialType.get('kind') === 'external'); + vm.getSubmitData = getSubmitData; + + vm.inputSources.initialItems = credential.get('related.input_sources.results'); + if (credential.get('credential_type') !== credentialType.get('id')) { + vm.inputSources.items = []; + } else { + vm.inputSources.items = credential.get('related.input_sources.results'); + } + + const linkedFieldNames = vm.inputSources.items + .map(({ input_field_name }) => input_field_name); + fields = fields.map((field) => { - if (isEditable && credentialType.get('kind') !== 'external') { - field.tagMode = true; + field.tagMode = isEditable && credentialType.get('kind') !== 'external'; + if (linkedFieldNames.includes(field.id)) { + field.asTag = true; + const { summary_fields } = vm.inputSources.items + .find(({ input_field_name }) => input_field_name === field.id); + field._value = summary_fields.source_credential.name; } return field; }); - vm.isTestable = (isEditable && credentialType.get('kind') === 'external'); - vm.getSubmitData = getSubmitData; - return fields; }, _onRemoveTag ({ id }) { @@ -200,121 +179,228 @@ function EditCredentialsController ( title: true, }; + vm.externalTest = { + form: { + inputs: { + _get: () => vm.externalTest.metadataInputs, + _reference: 'vm.form.inputs', + _key: 'inputs', + _source: { _value: {} }, + }, + }, + metadataInputs: null, + }; + vm.inputSources = { + tabs: { + credential: { + _active: true, + _disabled: false, + }, + metadata: { + _active: false, + _disabled: false, + } + }, + form: { + inputs: { + _get: () => vm.inputSources.metadataInputs, + _reference: 'vm.form.inputs', + _key: 'inputs', + _source: { _value: {} }, + }, + }, + field: null, + credentialTypeId: null, + credentialTypeName: null, + credentialId: null, + credentialName: null, + metadataInputs: null, + initialItems: credential.get('related.input_sources.results'), + items: credential.get('related.input_sources.results'), + }; + vm.onInputSourceClear = (field) => { vm.form[field].tagMode = true; vm.form[field].asTag = false; + vm.form[field]._value = ''; + vm.inputSources.items = vm.inputSources.items + .filter(({ input_field_name }) => input_field_name !== field); }; - vm.setTab = (name) => { + function setInputSourceTab (name) { const metaIsActive = name === 'metadata'; vm.inputSources.tabs.credential._active = !metaIsActive; vm.inputSources.tabs.credential._disabled = false; vm.inputSources.tabs.metadata._active = metaIsActive; vm.inputSources.tabs.metadata._disabled = false; - }; + } - vm.unsetTabs = () => { + function unsetInputSourceTabs () { vm.inputSources.tabs.credential._active = false; vm.inputSources.tabs.credential._disabled = false; vm.inputSources.tabs.metadata._active = false; vm.inputSources.tabs.metadata._disabled = false; - }; + } vm.onInputSourceOpen = (field) => { - vm.inputSources.field = field; - vm.setTab('credential'); + // We get here when the input source lookup modal for a field is opened. If source + // credential and metadata values for this field already exist in the initial API data + // or from it being set during a prior visit to the lookup, we initialize the lookup with + // these values here before opening it. const sourceItem = vm.inputSources.items .find(({ input_field_name }) => input_field_name === field); if (sourceItem) { const { source_credential, summary_fields } = sourceItem; - const { source_credential: { credential_type_id } } = summary_fields; + const { source_credential: { credential_type_id, name } } = summary_fields; vm.inputSources.credentialId = source_credential; + vm.inputSources.credentialName = name; vm.inputSources.credentialTypeId = credential_type_id; vm.inputSources._value = credential_type_id; } + setInputSourceTab('credential'); + vm.inputSources.field = field; }; vm.onInputSourceClose = () => { + // We get here if the lookup was closed or canceled so we clear the state for the lookup + // and metadata form without storing any changes. vm.inputSources.field = null; - vm.inputSources.metadata = null; - vm.unsetTabs(); + vm.inputSources.credentialId = null; + vm.inputSources.credentialName = null; + vm.inputSources.metadataInputs = null; + unsetInputSourceTabs(); }; + /** + * Extract the current set of input values from the metadata form and reshape them to a + * metadata object that can be sent to the api later or reloaded when re-opening the form. + */ + function getMetadataFormSubmitData ({ inputs }) { + const metadata = Object.assign({}, ...inputs._group + .filter(({ _value }) => _value !== undefined) + .map(({ id, _value }) => ({ [id]: _value }))); + return metadata; + } + vm.onInputSourceNext = () => { const { field, credentialId, credentialTypeId } = vm.inputSources; Wait('start'); new CredentialType('get', credentialTypeId) .then(model => { model.mergeInputProperties('metadata'); - vm.inputSources.metadata = model.get('inputs.metadata'); + vm.inputSources.metadataInputs = model.get('inputs.metadata'); vm.inputSources.credentialTypeName = model.get('name'); + // Pre-populate the input values for the metadata form if state for this specific + // field_name->source_credential link already exists. This occurs one of two ways: + // + // 1. This field->source_credential link already exists in the API and so we're + // reflecting the current state as it exists on the backend. + // 2. The metadata form for this specific field->source_credential combination was + // set during a prior visit to this lookup and so we're reflecting the most + // recent set of (unsaved) metadata values provided by the user for this field. + // + // Note: Prior state for a given credential input field is only set for one source + // credential at a time. Linking a field to a source credential will remove all + // other prior input state for that field. const [metavals] = vm.inputSources.items .filter(({ input_field_name }) => input_field_name === field) .filter(({ source_credential }) => source_credential === credentialId) .map(({ metadata }) => metadata); Object.keys(metavals || {}).forEach(key => { - const obj = vm.inputSources.metadata.find(o => o.id === key); + const obj = vm.inputSources.metadataInputs.find(o => o.id === key); if (obj) obj._value = metavals[key]; }); - vm.setTab('metadata'); + setInputSourceTab('metadata'); }) .finally(() => Wait('stop')); }; vm.onInputSourceSelect = () => { - const { field, credentialId } = vm.inputSources; + const { field, credentialId, credentialName, credentialTypeId } = vm.inputSources; + const metadata = getMetadataFormSubmitData(vm.inputSources.form); + // Remove any input source objects already stored for this field then store the metadata + // and currently selected source credential as a valid credential input source object that + // can be sent to the api later or reloaded into the form if it is reopened. vm.inputSources.items = vm.inputSources.items .filter(({ input_field_name }) => input_field_name !== field) .concat([{ + metadata, input_field_name: field, source_credential: credentialId, target_credential: credential.get('id'), + summary_fields: { + source_credential: { + name: credentialName, + credential_type_id: credentialTypeId + } + }, }]); + // Now that we've extracted and stored the selected source credential and metadata values + // for this field, we clear the state for the source credential lookup and metadata form. vm.inputSources.field = null; - vm.inputSources.metadata = null; - vm.unsetTabs(); + vm.inputSources.metadataInputs = null; + unsetInputSourceTabs(); + // We've linked this field to a credential, so display value as a credential tag + vm.form[field]._value = credentialName; + vm.form[field].asTag = true; }; vm.onInputSourceTabSelect = (name) => { if (name === 'metadata') { + // Clicking on the metadata tab should have identical behavior to clicking the 'next' + // button, so we pass-through to the same handler here. vm.onInputSourceNext(); } else { - vm.setTab('credential'); + setInputSourceTab('credential'); } }; - vm.onInputSourceRowClick = ({ id, credential_type }) => { + vm.onInputSourceRowClick = ({ id, credential_type, name }) => { vm.inputSources.credentialId = id; + vm.inputSources.credentialName = name; vm.inputSources.credentialTypeId = credential_type; vm.inputSources._value = credential_type; }; vm.onInputSourceTest = () => { - const metadata = Object.assign({}, ...vm.inputSources.form.inputs._group - .filter(({ _value }) => _value !== undefined) - .map(({ id, _value }) => ({ [id]: _value }))); + // We get here if the test button on the metadata form for the field of a non-external + // credential was used. All input values for the external credential are already stored + // on the backend, so we are only testing how it works with a set of metadata before + // linking it. + const metadata = getMetadataFormSubmitData(vm.inputSources.form); const name = $filter('sanitize')(vm.inputSources.credentialTypeName); const endpoint = `${vm.inputSources.credentialId}/test/`; - - return vm.runTest({ name, model: credential, endpoint, data: { metadata } }); + return runTest({ name, model: credential, endpoint, data: { metadata } }); }; - vm.onExternalTestClick = () => { + function onExternalTestOpen () { + // We get here if test button on the top-level form for an external credential type was + // used. We load the metadata schema for this particular external credential type and + // use it to generate and open a form for submitting test values. credentialType.mergeInputProperties('metadata'); - vm.externalTest.metadata = credentialType.get('inputs.metadata'); - }; + vm.externalTest.metadataInputs = credentialType.get('inputs.metadata'); + } + vm.form.secondary = onExternalTestOpen; vm.onExternalTestClose = () => { - vm.externalTest.metadata = null; + // We get here if the metadata test form for an external credential type was canceled or + // closed so we clear the form state and close without submitting any data to the test api, + vm.externalTest.metadataInputs = null; }; vm.onExternalTest = () => { const name = $filter('sanitize')(credentialType.get('name')); const { inputs } = vm.getSubmitData(); - const metadata = Object.assign({}, ...vm.externalTest.form.inputs._group - .filter(({ _value }) => _value !== undefined) - .map(({ id, _value }) => ({ [id]: _value }))); - + const metadata = getMetadataFormSubmitData(vm.externalTest.form); + // We get here if the test button on the top-level form for an external credential type was + // used. We need to see if the currently selected credential type is the one loaded from + // the api when we initialized the view or if its type was changed on the form and hasn't + // been saved. If the credential type hasn't been changed, it means some of the input + // values for the credential may be stored in the backend and not in the form, so we need + // to use the test endpoint for the credential. If the credential type has been changed, + // the user must provide a complete set of input values for the credential to save their + // changes, so we use the generic test endpoint for the credental type as if we were + // testing a completely new and unsaved credential. let model; if (credential.get('credential_type') !== credentialType.get('id')) { model = credentialType; @@ -323,49 +409,65 @@ function EditCredentialsController ( } const endpoint = `${model.get('id')}/test/`; - return vm.runTest({ name, model, endpoint, data: { inputs, metadata } }); + return runTest({ name, model, endpoint, data: { inputs, metadata } }); }; - vm.form.secondary = vm.onExternalTestClick; - vm.runTest = ({ name, model, endpoint, data: { inputs, metadata } }) => { + vm.filterInputSourceCredentialResults = (data) => { + // If an external credential is changed to have a non-external `credential_type` while + // editing, we avoid showing a self-reference in the list of selectable external + // credentials for input fields by filtering it out here. + if (isExternal) { + data.results = data.results.filter(({ id }) => id !== credential.get('id')); + } + return data; + }; + + function runTest ({ name, model, endpoint, data: { inputs, metadata } }) { return model.http.post({ url: endpoint, data: { inputs, metadata }, replace: false }) .then(() => { + const icon = 'fa-check-circle'; + const msg = strings.get('edit.TEST_PASSED'); + const content = buildTestNotificationContent({ name, icon, msg }); ngToast.success({ - content: vm.buildTestNotificationContent({ - name, - icon: 'fa-check-circle', - msg: strings.get('edit.TEST_PASSED'), - }), + content, dismissButton: false, dismissOnTimeout: true }); }) .catch(({ data }) => { - const msg = data.inputs - ? `${$filter('sanitize')(data.inputs)}` - : strings.get('edit.TEST_FAILED'); + const icon = 'fa-exclamation-triangle'; + const msg = data.inputs || strings.get('edit.TEST_FAILED'); + const content = buildTestNotificationContent({ name, icon, msg }); ngToast.danger({ - content: vm.buildTestNotificationContent({ - name, - msg, - icon: 'fa-exclamation-triangle' - }), + content, dismissButton: false, dismissOnTimeout: true }); }); - }; + } - vm.buildTestNotificationContent = ({ name, msg, icon }) => ( - `
+ function buildTestNotificationContent ({ name, msg, icon }) { + const sanitize = $filter('sanitize'); + const content = `
- ${name}: ${msg} + ${sanitize(name)}: ${sanitize(msg)}
-
` - ); +
`; + return content; + } + + function deleteInputSource ({ id }) { + Rest.setUrl(`${GetBasePath('credential_input_sources')}${id}/`); + return Rest.destroy(); + } + + function createInputSource (data) { + Rest.setUrl(GetBasePath('credential_input_sources')); + return Rest.post(data); + } /** * If a credential's `credential_type` is changed while editing, the inputs associated with @@ -380,10 +482,32 @@ function EditCredentialsController ( delete data.inputs[gceFileInputSchema.id]; } - const filteredInputs = _.omit(data.inputs, (value) => value === ''); + const initialLinkedFieldNames = vm.inputSources.initialItems + .map(({ input_field_name }) => input_field_name); + const updatedLinkedFieldNames = vm.inputSources.items + .map(({ input_field_name }) => input_field_name); + + const fieldsToDisassociate = [...initialLinkedFieldNames] + .filter(name => !updatedLinkedFieldNames.includes(name)); + const fieldsToAssociate = [...updatedLinkedFieldNames] + .filter(name => !initialLinkedFieldNames.includes(name)); + + const sourcesToDisassociate = [...fieldsToDisassociate] + .map(name => vm.inputSources.initialItems + .find(({ input_field_name }) => input_field_name === name)); + const sourcesToAssociate = [...fieldsToAssociate] + .map(name => vm.inputSources.items + .find(({ input_field_name }) => input_field_name === name)); + + // remove inputs with empty string values + let filteredInputs = _.omit(data.inputs, (value) => value === ''); + // remove inputs that are to be linked to an external credential + filteredInputs = _.omit(filteredInputs, updatedLinkedFieldNames); data.inputs = filteredInputs; - return credential.request('put', { data }); + return Promise.all(sourcesToDisassociate.map(deleteInputSource)) + .then(() => credential.request('put', { data })) + .then(() => Promise.all(sourcesToAssociate.map(createInputSource))); }; vm.form.onSaveSuccess = () => { @@ -450,6 +574,8 @@ EditCredentialsController.$inject = [ 'Wait', '$filter', 'CredentialTypeModel', + 'GetBasePath', + 'Rest', ]; export default EditCredentialsController; diff --git a/awx/ui/client/features/credentials/external-test-modal.component.js b/awx/ui/client/features/credentials/external-test-modal.component.js new file mode 100644 index 0000000000..8600cc45f1 --- /dev/null +++ b/awx/ui/client/features/credentials/external-test-modal.component.js @@ -0,0 +1,23 @@ +const templateUrl = require('~features/credentials/external-test-modal.partial.html'); + +function ExternalTestModalController (strings) { + const vm = this || {}; + + vm.strings = strings; + vm.title = strings.get('externalTest.TITLE'); +} + +ExternalTestModalController.$inject = [ + 'CredentialsStrings', +]; + +export default { + templateUrl, + controller: ExternalTestModalController, + controllerAs: 'vm', + bindings: { + onClose: '=', + onSubmit: '=', + form: '=', + }, +}; diff --git a/awx/ui/client/features/credentials/external-test-modal.partial.html b/awx/ui/client/features/credentials/external-test-modal.partial.html new file mode 100644 index 0000000000..c44ef4487e --- /dev/null +++ b/awx/ui/client/features/credentials/external-test-modal.partial.html @@ -0,0 +1,13 @@ + + + + + + {{::vm.strings.get('CLOSE')}} + + + {{::vm.strings.get('RUN')}} + + + + diff --git a/awx/ui/client/features/credentials/external-test.component.js b/awx/ui/client/features/credentials/external-test.component.js deleted file mode 100644 index 0c4fda3659..0000000000 --- a/awx/ui/client/features/credentials/external-test.component.js +++ /dev/null @@ -1,37 +0,0 @@ -const templateUrl = require('~features/credentials/external-test.partial.html'); - -function ExternalTestModalController ($scope, $element, strings) { - const vm = this || {}; - let overlay; - - vm.strings = strings; - vm.title = 'Test External Credential'; - - vm.$onInit = () => { - const [el] = $element; - overlay = el.querySelector('#external-test-modal'); - vm.show(); - }; - - vm.show = () => { - overlay.style.display = 'block'; - overlay.style.opacity = 1; - }; -} - -ExternalTestModalController.$inject = [ - '$scope', - '$element', - 'CredentialsStrings', -]; - -export default { - templateUrl, - controller: ExternalTestModalController, - controllerAs: 'vm', - bindings: { - onClose: '=', - onSubmit: '=', - form: '=', - }, -}; diff --git a/awx/ui/client/features/credentials/external-test.partial.html b/awx/ui/client/features/credentials/external-test.partial.html deleted file mode 100644 index 3d866c42f1..0000000000 --- a/awx/ui/client/features/credentials/external-test.partial.html +++ /dev/null @@ -1,41 +0,0 @@ - diff --git a/awx/ui/client/features/credentials/index.js b/awx/ui/client/features/credentials/index.js index 2c13f645ed..d4d49da8ba 100644 --- a/awx/ui/client/features/credentials/index.js +++ b/awx/ui/client/features/credentials/index.js @@ -3,7 +3,7 @@ import AddController from './add-credentials.controller'; import EditController from './edit-credentials.controller'; import CredentialsStrings from './credentials.strings'; import InputSourceLookupComponent from './input-source-lookup.component'; -import ExternalTestComponent from './external-test.component'; +import ExternalTestModalComponent from './external-test-modal.component'; const MODULE_NAME = 'at.features.credentials'; @@ -143,7 +143,7 @@ angular .service('LegacyCredentialsService', LegacyCredentials) .service('CredentialsStrings', CredentialsStrings) .component('atInputSourceLookup', InputSourceLookupComponent) - .component('atExternalCredentialTest', ExternalTestComponent) + .component('atExternalCredentialTest', ExternalTestModalComponent) .run(CredentialsRun); export default MODULE_NAME; diff --git a/awx/ui/client/features/credentials/input-source-lookup.component.js b/awx/ui/client/features/credentials/input-source-lookup.component.js index 1ac781734c..beea7e72ec 100644 --- a/awx/ui/client/features/credentials/input-source-lookup.component.js +++ b/awx/ui/client/features/credentials/input-source-lookup.component.js @@ -1,76 +1,13 @@ const templateUrl = require('~features/credentials/input-source-lookup.partial.html'); -function InputSourceLookupController ($scope, $element, $http, GetBasePath, qs, strings) { +function InputSourceLookupController (strings) { const vm = this || {}; - let overlay; vm.strings = strings; - vm.name = 'credential'; - vm.title = 'Set Input Source'; - - vm.$onInit = () => { - const [el] = $element; - overlay = el.querySelector('#input-source-lookup'); - - const defaultParams = { - order_by: 'name', - credential_type__kind: 'external', - page_size: 5 - }; - vm.setDefaultParams(defaultParams); - vm.setData({ results: [], count: 0 }); - $http({ method: 'GET', url: GetBasePath(`${vm.name}s`), params: defaultParams }) - .then(({ data }) => { - vm.setData(data); - vm.show(); - }); - }; - - vm.show = () => { - overlay.style.display = 'block'; - overlay.style.opacity = 1; - }; - - vm.close = () => { - vm.onClose(); - }; - - vm.next = () => { - vm.onNext(); - }; - - vm.select = () => { - vm.onSelect(); - }; - - vm.test = () => { - vm.onTest(); - }; - - vm.setData = ({ results, count }) => { - vm.dataset = { results, count }; - vm.collection = vm.dataset.results; - }; - - vm.setDefaultParams = (params) => { - vm.list = { name: vm.name, iterator: vm.name }; - vm.defaultParams = params; - vm.queryset = params; - }; - - vm.toggle_row = (obj) => { - vm.onRowClick(obj); - }; - - vm.onCredentialTabClick = () => vm.onTabSelect('credential'); + vm.title = strings.get('inputSources.TITLE'); } InputSourceLookupController.$inject = [ - '$scope', - '$element', - '$http', - 'GetBasePath', - 'QuerySet', 'CredentialsStrings', ]; @@ -88,5 +25,6 @@ export default { onTest: '=', selectedId: '=', form: '=', + resultsFilter: '=', }, }; diff --git a/awx/ui/client/features/credentials/input-source-lookup.partial.html b/awx/ui/client/features/credentials/input-source-lookup.partial.html index 4998201715..336a8e62c0 100644 --- a/awx/ui/client/features/credentials/input-source-lookup.partial.html +++ b/awx/ui/client/features/credentials/input-source-lookup.partial.html @@ -1,174 +1,66 @@ - + + + + {{::vm.strings.get('inputSources.CREDENTIAL')}} + + + {{::vm.strings.get('inputSources.METADATA')}} + + + + + + + + + {{::vm.strings.get('TEST')}} + + + {{::vm.strings.get('CANCEL')}} + + + {{::vm.strings.get('NEXT')}} + + + {{::vm.strings.get('OK')}} + + + + diff --git a/awx/ui/client/lib/components/_index.less b/awx/ui/client/lib/components/_index.less index ba606cad79..b065777948 100644 --- a/awx/ui/client/lib/components/_index.less +++ b/awx/ui/client/lib/components/_index.less @@ -4,6 +4,7 @@ @import 'layout/_index'; @import 'list/_index'; @import 'modal/_index'; +@import 'easy-modal/_index'; @import 'panel/_index'; @import 'popover/_index'; @import 'relaunchButton/_index'; diff --git a/awx/ui/client/lib/components/action/action-button.component.js b/awx/ui/client/lib/components/action/action-button.component.js new file mode 100644 index 0000000000..d77bf218db --- /dev/null +++ b/awx/ui/client/lib/components/action/action-button.component.js @@ -0,0 +1,33 @@ +const templateUrl = require('~components/action/action-button.partial.html'); + +function ActionButtonController () { + const vm = this || {}; + vm.$onInit = () => { + const { variant } = vm; + + if (variant === 'primary') { + vm.color = 'success'; + vm.fill = ''; + } + + if (variant === 'secondary') { + vm.color = 'info'; + vm.fill = ''; + } + + if (variant === 'tertiary') { + vm.color = 'default'; + vm.fill = 'Hollow'; + } + }; +} + +export default { + templateUrl, + controller: ActionButtonController, + controllerAs: 'vm', + transclude: true, + bindings: { + variant: '@', + }, +}; diff --git a/awx/ui/client/lib/components/action/action-button.partial.html b/awx/ui/client/lib/components/action/action-button.partial.html new file mode 100644 index 0000000000..b205af475e --- /dev/null +++ b/awx/ui/client/lib/components/action/action-button.partial.html @@ -0,0 +1,3 @@ + diff --git a/awx/ui/client/lib/components/easy-modal/_index.less b/awx/ui/client/lib/components/easy-modal/_index.less new file mode 100644 index 0000000000..942803309b --- /dev/null +++ b/awx/ui/client/lib/components/easy-modal/_index.less @@ -0,0 +1,27 @@ +.at-EasyModal-body { + font-size: @at-font-size; + padding: @at-padding-panel 0; +} + +.at-EasyModal-dismiss { + .at-mixin-ButtonIcon(); + font-size: @at-font-size-modal-dismiss; + color: @at-color-icon-dismiss; + text-align: right; +} + +.at-EasyModal-heading { + margin: 0; + overflow: visible; + + & > .at-EasyModal-dismiss { + margin: 0; + } +} + +.at-EasyModal-title { + margin: 0; + padding: 0; + + .at-mixin-Heading(@at-font-size-modal-heading); +} diff --git a/awx/ui/client/lib/components/easy-modal/easy-modal.component.js b/awx/ui/client/lib/components/easy-modal/easy-modal.component.js new file mode 100644 index 0000000000..cf78db312c --- /dev/null +++ b/awx/ui/client/lib/components/easy-modal/easy-modal.component.js @@ -0,0 +1,29 @@ +const templateUrl = require('~components/easy-modal/easy-modal.partial.html'); + +const overlaySelector = '.at-EasyModal'; + +function EasyModalController ($element) { + const vm = this || {}; + + vm.$onInit = () => { + const [el] = $element; + const overlay = el.querySelector(overlaySelector); + overlay.style.display = 'block'; + overlay.style.opacity = 1; + }; +} + +EasyModalController.$inject = [ + '$element', +]; + +export default { + templateUrl, + controller: EasyModalController, + controllerAs: 'vm', + transclude: true, + bindings: { + title: '=', + onClose: '=', + }, +}; diff --git a/awx/ui/client/lib/components/easy-modal/easy-modal.partial.html b/awx/ui/client/lib/components/easy-modal/easy-modal.partial.html new file mode 100644 index 0000000000..2c44ab0ae0 --- /dev/null +++ b/awx/ui/client/lib/components/easy-modal/easy-modal.partial.html @@ -0,0 +1,19 @@ + diff --git a/awx/ui/client/lib/components/form/form.directive.js b/awx/ui/client/lib/components/form/form.directive.js index 7aaa90aa1a..a270b0835e 100644 --- a/awx/ui/client/lib/components/form/form.directive.js +++ b/awx/ui/client/lib/components/form/form.directive.js @@ -30,7 +30,6 @@ function AtFormController (eventService, strings) { ({ modal } = scope[scope.ns]); vm.state.disabled = scope.state.disabled; - vm.setListeners(); }; @@ -203,6 +202,7 @@ function AtFormController (eventService, strings) { if (isValid !== vm.state.isValid) { vm.state.isValid = isValid; + scope.state.isValid = vm.state.isValid; } }; diff --git a/awx/ui/client/lib/components/index.js b/awx/ui/client/lib/components/index.js index 078bd16a06..b9dca8673c 100644 --- a/awx/ui/client/lib/components/index.js +++ b/awx/ui/client/lib/components/index.js @@ -1,8 +1,10 @@ import atLibServices from '~services'; import actionGroup from '~components/action/action-group.directive'; +import actionButton from '~components/action/action-button.component'; import divider from '~components/utility/divider.directive'; import dynamicSelect from '~components/input/dynamic-select.directive'; +import easyModal from '~components/easy-modal/easy-modal.component'; import form from '~components/form/form.directive'; import formAction from '~components/form/action.directive'; import inputCheckbox from '~components/input/checkbox.directive'; @@ -20,6 +22,7 @@ import inputTextareaSecret from '~components/input/textarea-secret.directive'; import launchTemplate from '~components/launchTemplateButton/launchTemplateButton.component'; import layout from '~components/layout/layout.directive'; import list from '~components/list/list.directive'; +import lookupList from '~components/lookup-list/lookup-list.component'; import modal from '~components/modal/modal.directive'; import panel from '~components/panel/panel.directive'; import panelBody from '~components/panel/body.directive'; @@ -53,8 +56,10 @@ angular atCodeMirror ]) .directive('atActionGroup', actionGroup) + .component('atActionButton', actionButton) .directive('atDivider', divider) .directive('atDynamicSelect', dynamicSelect) + .component('atEasyModal', easyModal) .directive('atForm', form) .directive('atFormAction', formAction) .directive('atInputCheckbox', inputCheckbox) @@ -72,6 +77,7 @@ angular .component('atLaunchTemplate', launchTemplate) .directive('atLayout', layout) .directive('atList', list) + .component('atLookupList', lookupList) .directive('atListToolbar', toolbar) .component('atRelaunch', relaunch) .directive('atRow', row) diff --git a/awx/ui/client/lib/components/lookup-list/lookup-list.component.js b/awx/ui/client/lib/components/lookup-list/lookup-list.component.js new file mode 100644 index 0000000000..207456ba84 --- /dev/null +++ b/awx/ui/client/lib/components/lookup-list/lookup-list.component.js @@ -0,0 +1,51 @@ +const templateUrl = require('~components/lookup-list/lookup-list.partial.html'); + +function LookupListController (GetBasePath, Rest, strings) { + const vm = this || {}; + + vm.strings = strings; + + vm.$onInit = () => { + const params = vm.baseParams; + setBaseParams(params); + setData({ results: [], count: 0 }); + + const resultsFilter = vm.resultsFilter || (data => data); + Rest.setUrl(GetBasePath(`${vm.resourceName}s`)); + Rest.get({ params }) + .then(({ data }) => { + setData(resultsFilter(data)); + }); + }; + + function setData ({ results, count }) { + vm.dataset = { results, count }; + vm.collection = vm.dataset.results; + } + + function setBaseParams (params) { + vm.list = { name: vm.resourceName, iterator: vm.resourceName }; + vm.defaultParams = params; + vm.queryset = params; + } +} + +LookupListController.$inject = [ + 'GetBasePath', + 'Rest', + 'ComponentsStrings', +]; + +export default { + templateUrl, + controller: LookupListController, + controllerAs: 'vm', + bindings: { + onSelect: '=', + onRowClick: '=', + selectedId: '=', + resourceName: '@', + baseParams: '=', + resultsFilter: '=', + }, +}; diff --git a/awx/ui/client/lib/components/lookup-list/lookup-list.partial.html b/awx/ui/client/lib/components/lookup-list/lookup-list.partial.html new file mode 100644 index 0000000000..a2fcee30c1 --- /dev/null +++ b/awx/ui/client/lib/components/lookup-list/lookup-list.partial.html @@ -0,0 +1,90 @@ +
+
+
+ + +
+
+
+ {{::vm.strings.get('NO_MATCH')}} +
+
+
+ {{::vm.strings.get('NO_ITEMS')}} +
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+ {{ obj.name }} +
+
+
+
+
+
+ + +
diff --git a/awx/ui/client/src/templates/prompt/prompt.block.less b/awx/ui/client/src/templates/prompt/prompt.block.less index d9f7e3b6fd..c5670874ed 100644 --- a/awx/ui/client/src/templates/prompt/prompt.block.less +++ b/awx/ui/client/src/templates/prompt/prompt.block.less @@ -44,17 +44,6 @@ height: 30px; min-width: 85px; } - -.Prompt-infoButton{ - .at-mixin-ButtonColor('at-color-info', 'at-color-default'); - text-transform: uppercase; - border-radius: 5px; - padding-left:15px; - padding-right: 15px; - height: 30px; - min-width: 85px; - margin-right: 20px; -} .Prompt-defaultButton:hover{ background-color: @btn-bg-hov; color: @btn-txt; From 7f55a1da0d00315a904ee00be69f9b3264f1086e Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Mon, 4 Mar 2019 12:32:06 -0500 Subject: [PATCH 31/74] move input value initialization to models --- .../credentials/add-credentials.controller.js | 27 ++--------- .../edit-credentials.controller.js | 47 +++---------------- awx/ui/client/lib/models/Credential.js | 46 +++++++++++++++++- 3 files changed, 55 insertions(+), 65 deletions(-) diff --git a/awx/ui/client/features/credentials/add-credentials.controller.js b/awx/ui/client/features/credentials/add-credentials.controller.js index a097b8716e..f65753993e 100644 --- a/awx/ui/client/features/credentials/add-credentials.controller.js +++ b/awx/ui/client/features/credentials/add-credentials.controller.js @@ -58,36 +58,19 @@ function AddCredentialsController ( vm.form.inputs = { _get: ({ getSubmitData }) => { - credentialType.mergeInputProperties(); + const apiConfig = ConfigService.get(); - let fields = credentialType.get('inputs.fields'); + credentialType.mergeInputProperties(); + const fields = credential.assignInputGroupValues(apiConfig, credentialType); if (credentialType.get('name') === 'Google Compute Engine') { fields.splice(2, 0, gceFileInputSchema); $scope.$watch(`vm.form.${gceFileInputSchema.id}._value`, vm.gceOnFileInputChanged); - } else if (credentialType.get('name') === 'Machine') { - const apiConfig = ConfigService.get(); - const become = fields.find((field) => field.id === 'become_method'); - become._isDynamic = true; - become._choices = Array.from(apiConfig.become_methods, method => method[0]); } - vm.isTestable = (credentialType.get('kind') === 'external'); + vm.getSubmitData = getSubmitData; - + vm.isTestable = credentialType.get('kind') === 'external'; vm.inputSources.items = []; - const linkedFieldNames = vm.inputSources.items - .map(({ input_field_name }) => input_field_name); - - fields = fields.map((field) => { - field.tagMode = credentialType.get('kind') !== 'external'; - if (linkedFieldNames.includes(field.id)) { - field.asTag = true; - const { summary_fields } = vm.inputSources.items - .find(({ input_field_name }) => input_field_name === field.id); - field._value = summary_fields.source_credential.name; - } - return field; - }); return fields; }, diff --git a/awx/ui/client/features/credentials/edit-credentials.controller.js b/awx/ui/client/features/credentials/edit-credentials.controller.js index 694d34bb38..9237fefdb0 100644 --- a/awx/ui/client/features/credentials/edit-credentials.controller.js +++ b/awx/ui/client/features/credentials/edit-credentials.controller.js @@ -110,59 +110,24 @@ function EditCredentialsController ( vm.form.inputs = { _get ({ getSubmitData }) { - let fields; + const apiConfig = ConfigService.get(); credentialType.mergeInputProperties(); - - if (credentialType.get('id') === credential.get('credential_type')) { - fields = credential.assignInputGroupValues(credentialType.get('inputs.fields')); - } else { - fields = credentialType.get('inputs.fields'); - } + const fields = credential.assignInputGroupValues(apiConfig, credentialType); if (credentialType.get('name') === 'Google Compute Engine') { fields.splice(2, 0, gceFileInputSchema); - $scope.$watch(`vm.form.${gceFileInputSchema.id}._value`, vm.gceOnFileInputChanged); $scope.$watch('vm.form.ssh_key_data._isBeingReplaced', vm.gceOnReplaceKeyChanged); - } else if (credentialType.get('name') === 'Machine') { - const apiConfig = ConfigService.get(); - const become = fields.find((field) => field.id === 'become_method'); - become._isDynamic = true; - become._choices = Array.from(apiConfig.become_methods, method => method[0]); - // Add the value to the choices if it doesn't exist in the preset list - if (become._value && become._value !== '') { - const optionMatches = become._choices - .findIndex((option) => option === become._value); - if (optionMatches === -1) { - become._choices.push(become._value); - } - } } - vm.isTestable = (isEditable && credentialType.get('kind') === 'external'); - vm.getSubmitData = getSubmitData; - vm.inputSources.initialItems = credential.get('related.input_sources.results'); - if (credential.get('credential_type') !== credentialType.get('id')) { - vm.inputSources.items = []; - } else { + vm.inputSources.items = []; + if (credential.get('credential_type') === credentialType.get('id')) { vm.inputSources.items = credential.get('related.input_sources.results'); } - - const linkedFieldNames = vm.inputSources.items - .map(({ input_field_name }) => input_field_name); - - fields = fields.map((field) => { - field.tagMode = isEditable && credentialType.get('kind') !== 'external'; - if (linkedFieldNames.includes(field.id)) { - field.asTag = true; - const { summary_fields } = vm.inputSources.items - .find(({ input_field_name }) => input_field_name === field.id); - field._value = summary_fields.source_credential.name; - } - return field; - }); + vm.isTestable = (isEditable && credentialType.get('kind') === 'external'); + vm.getSubmitData = getSubmitData; return fields; }, diff --git a/awx/ui/client/lib/models/Credential.js b/awx/ui/client/lib/models/Credential.js index 93d29bb002..2692fd76aa 100644 --- a/awx/ui/client/lib/models/Credential.js +++ b/awx/ui/client/lib/models/Credential.js @@ -1,3 +1,4 @@ +/* eslint camelcase: 0 */ const ENCRYPTED_VALUE = '$encrypted$'; let Base; @@ -29,12 +30,23 @@ function createFormSchema (method, config) { return schema; } -function assignInputGroupValues (inputs) { +function assignInputGroupValues (apiConfig, credentialType) { + let inputs = credentialType.get('inputs.fields'); + if (!inputs) { return []; } - return inputs.map(input => { + if (this.has('credential_type')) { + if (credentialType.get('id') !== this.get('credential_type')) { + inputs.forEach(field => { + field.tagMode = this.isEditable() && credentialType.get('kind') !== 'external'; + }); + return inputs; + } + } + + inputs = inputs.map(input => { const value = this.get(`inputs.${input.id}`); input._value = value; @@ -42,6 +54,36 @@ function assignInputGroupValues (inputs) { return input; }); + + if (credentialType.get('name') === 'Machine') { + const become = inputs.find((field) => field.id === 'become_method'); + become._isDynamic = true; + become._choices = Array.from(apiConfig.become_methods, method => method[0]); + // Add the value to the choices if it doesn't exist in the preset list + if (become._value && become._value !== '') { + const optionMatches = become._choices + .findIndex((option) => option === become._value); + if (optionMatches === -1) { + become._choices.push(become._value); + } + } + } + + const linkedFieldNames = (this.get('related.input_sources.results') || []) + .map(({ input_field_name }) => input_field_name); + + inputs = inputs.map((field) => { + field.tagMode = this.isEditable() && credentialType.get('kind') !== 'external'; + if (linkedFieldNames.includes(field.id)) { + field.asTag = true; + const { summary_fields } = this.get('related.input_sources.results') + .find(({ input_field_name }) => input_field_name === field.id); + field._value = summary_fields.source_credential.name; + } + return field; + }); + + return inputs; } function setDependentResources (id) { From 61eeb630f85eefad0d70cb7931eab8b5d2511bc5 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Mon, 4 Mar 2019 13:11:15 -0500 Subject: [PATCH 32/74] move org edit permission check to route resolve --- .../credentials/edit-credentials.controller.js | 11 ++--------- awx/ui/client/features/credentials/index.js | 10 +++++++++- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/awx/ui/client/features/credentials/edit-credentials.controller.js b/awx/ui/client/features/credentials/edit-credentials.controller.js index 9237fefdb0..30f5082b53 100644 --- a/awx/ui/client/features/credentials/edit-credentials.controller.js +++ b/awx/ui/client/features/credentials/edit-credentials.controller.js @@ -20,7 +20,7 @@ function EditCredentialsController ( credential, credentialType, organization, - isOrgCredAdmin, + isOrgEditableByUser, } = models; const omit = ['user', 'team', 'inputs']; @@ -75,14 +75,7 @@ function EditCredentialsController ( vm.form.disabled = !isEditable; } - const isOrgAdmin = _.some(me.get('related.admin_of_organizations.results'), (org) => org.id === organization.get('id')); - const isSuperuser = me.get('is_superuser'); - const isCurrentAuthor = Boolean(credential.get('summary_fields.created_by.id') === me.get('id')); - vm.form.organization._disabled = true; - - if (isSuperuser || isOrgAdmin || isOrgCredAdmin || (credential.get('organization') === null && isCurrentAuthor)) { - vm.form.organization._disabled = false; - } + vm.form.organization._disabled = !isOrgEditableByUser; vm.form.organization._resource = 'organization'; vm.form.organization._model = organization; diff --git a/awx/ui/client/features/credentials/index.js b/awx/ui/client/features/credentials/index.js index d4d49da8ba..c9546563d4 100644 --- a/awx/ui/client/features/credentials/index.js +++ b/awx/ui/client/features/credentials/index.js @@ -45,13 +45,21 @@ function CredentialsResolve ( organization: new Organization('get', orgId), credentialInputSources: models.credential.extend('GET', 'input_sources') }; + dependents.isOrgCredAdmin = dependents.organization.then((org) => org.search({ role_level: 'credential_admin_role' })); return $q.all(dependents) .then(related => { models.credentialType = related.credentialType; models.organization = related.organization; - models.isOrgCredAdmin = related.isOrgCredAdmin; + + const isOrgAdmin = _.some(models.me.get('related.admin_of_organizations.results'), (org) => org.id === models.organization.get('id')); + const isSuperuser = models.me.get('is_superuser'); + const isCurrentAuthor = Boolean(models.credential.get('summary_fields.created_by.id') === models.me.get('id')); + + models.isOrgEditableByUser = (isSuperuser || isOrgAdmin + || related.isOrgCredAdmin + || (models.credential.get('organization') === null && isCurrentAuthor)); return models; }); From 47f31b41fb25b2cd0cebf12117bea1ef14fac185 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Mon, 4 Mar 2019 14:13:36 -0500 Subject: [PATCH 33/74] combine add and edit controllers --- .../credentials/add-credentials.controller.js | 427 ------------------ ....js => add-edit-credentials.controller.js} | 192 +++++--- awx/ui/client/features/credentials/index.js | 10 +- 3 files changed, 128 insertions(+), 501 deletions(-) delete mode 100644 awx/ui/client/features/credentials/add-credentials.controller.js rename awx/ui/client/features/credentials/{edit-credentials.controller.js => add-edit-credentials.controller.js} (80%) diff --git a/awx/ui/client/features/credentials/add-credentials.controller.js b/awx/ui/client/features/credentials/add-credentials.controller.js deleted file mode 100644 index f65753993e..0000000000 --- a/awx/ui/client/features/credentials/add-credentials.controller.js +++ /dev/null @@ -1,427 +0,0 @@ -/* eslint camelcase: 0 */ -/* eslint arrow-body-style: 0 */ -function AddCredentialsController ( - models, - $state, - $scope, - strings, - componentsStrings, - ConfigService, - ngToast, - Wait, - $filter, - CredentialType, - GetBasePath, - Rest, -) { - const vm = this || {}; - - const { me, credential, credentialType, organization } = models; - const isExternal = credentialType.get('kind') === 'external'; - - vm.mode = 'add'; - vm.strings = strings; - vm.panelTitle = strings.get('add.PANEL_TITLE'); - - vm.tab = { - details: { _active: true }, - permissions: { _disabled: true } - }; - - vm.form = credential.createFormSchema('post', { - omit: ['user', 'team', 'inputs'] - }); - - vm.form._formName = 'credential'; - - vm.form.disabled = !credential.isCreatable(); - - vm.form.organization._resource = 'organization'; - vm.form.organization._route = 'credentials.add.organization'; - vm.form.organization._model = organization; - vm.form.organization._placeholder = strings.get('inputs.ORGANIZATION_PLACEHOLDER'); - - vm.form.credential_type._resource = 'credential_type'; - vm.form.credential_type._route = 'credentials.add.credentialType'; - vm.form.credential_type._model = credentialType; - vm.form.credential_type._placeholder = strings.get('inputs.CREDENTIAL_TYPE_PLACEHOLDER'); - vm.isTestable = credentialType.get('kind') === 'external'; - - const gceFileInputSchema = { - id: 'gce_service_account_key', - type: 'file', - label: strings.get('inputs.GCE_FILE_INPUT_LABEL'), - help_text: strings.get('inputs.GCE_FILE_INPUT_HELP_TEXT'), - }; - - let gceFileInputPreEditValues; - - vm.form.inputs = { - _get: ({ getSubmitData }) => { - const apiConfig = ConfigService.get(); - - credentialType.mergeInputProperties(); - const fields = credential.assignInputGroupValues(apiConfig, credentialType); - - if (credentialType.get('name') === 'Google Compute Engine') { - fields.splice(2, 0, gceFileInputSchema); - $scope.$watch(`vm.form.${gceFileInputSchema.id}._value`, vm.gceOnFileInputChanged); - } - - vm.getSubmitData = getSubmitData; - vm.isTestable = credentialType.get('kind') === 'external'; - vm.inputSources.items = []; - - return fields; - }, - _onRemoveTag ({ id }) { - vm.onInputSourceClear(id); - }, - _onInputLookup ({ id }) { - vm.onInputSourceOpen(id); - }, - _source: vm.form.credential_type, - _reference: 'vm.form.inputs', - _key: 'inputs' - }; - - vm.externalTest = { - form: { - inputs: { - _get: () => vm.externalTest.metadataInputs, - _reference: 'vm.form.inputs', - _key: 'inputs', - _source: { _value: {} }, - }, - }, - metadataInputs: null, - }; - vm.inputSources = { - tabs: { - credential: { - _active: true, - _disabled: false, - }, - metadata: { - _active: false, - _disabled: false, - } - }, - form: { - inputs: { - _get: () => vm.inputSources.metadataInputs, - _reference: 'vm.form.inputs', - _key: 'inputs', - _source: { _value: {} }, - }, - }, - field: null, - credentialTypeId: null, - credentialTypeName: null, - credentialId: null, - credentialName: null, - metadataInputs: null, - initialItems: credential.get('related.input_sources.results'), - items: credential.get('related.input_sources.results'), - }; - - vm.onInputSourceClear = (field) => { - vm.form[field].tagMode = true; - vm.form[field].asTag = false; - vm.form[field]._value = ''; - vm.inputSources.items = vm.inputSources.items - .filter(({ input_field_name }) => input_field_name !== field); - }; - - function setInputSourceTab (name) { - const metaIsActive = name === 'metadata'; - vm.inputSources.tabs.credential._active = !metaIsActive; - vm.inputSources.tabs.credential._disabled = false; - vm.inputSources.tabs.metadata._active = metaIsActive; - vm.inputSources.tabs.metadata._disabled = false; - } - - function unsetInputSourceTabs () { - vm.inputSources.tabs.credential._active = false; - vm.inputSources.tabs.credential._disabled = false; - vm.inputSources.tabs.metadata._active = false; - vm.inputSources.tabs.metadata._disabled = false; - } - - vm.onInputSourceOpen = (field) => { - const sourceItem = vm.inputSources.items - .find(({ input_field_name }) => input_field_name === field); - if (sourceItem) { - const { source_credential, summary_fields } = sourceItem; - const { source_credential: { credential_type_id, name } } = summary_fields; - vm.inputSources.credentialId = source_credential; - vm.inputSources.credentialName = name; - vm.inputSources.credentialTypeId = credential_type_id; - vm.inputSources._value = credential_type_id; - } - setInputSourceTab('credential'); - vm.inputSources.field = field; - }; - - vm.onInputSourceClose = () => { - vm.inputSources.field = null; - vm.inputSources.credentialId = null; - vm.inputSources.credentialName = null; - vm.inputSources.metadataInputs = null; - unsetInputSourceTabs(); - }; - - /** - * Extract the current set of input values from the metadata form and reshape them to a - * metadata object that can be sent to the api later or reloaded when re-opening the form. - */ - function getMetadataFormSubmitData ({ inputs }) { - const metadata = Object.assign({}, ...inputs._group - .filter(({ _value }) => _value !== undefined) - .map(({ id, _value }) => ({ [id]: _value }))); - return metadata; - } - - vm.onInputSourceNext = () => { - const { field, credentialId, credentialTypeId } = vm.inputSources; - Wait('start'); - new CredentialType('get', credentialTypeId) - .then(model => { - model.mergeInputProperties('metadata'); - vm.inputSources.metadataInputs = model.get('inputs.metadata'); - vm.inputSources.credentialTypeName = model.get('name'); - const [metavals] = vm.inputSources.items - .filter(({ input_field_name }) => input_field_name === field) - .filter(({ source_credential }) => source_credential === credentialId) - .map(({ metadata }) => metadata); - Object.keys(metavals || {}).forEach(key => { - const obj = vm.inputSources.metadataInputs.find(o => o.id === key); - if (obj) obj._value = metavals[key]; - }); - setInputSourceTab('metadata'); - }) - .finally(() => Wait('stop')); - }; - - vm.onInputSourceSelect = () => { - const { field, credentialId, credentialName, credentialTypeId } = vm.inputSources; - const metadata = getMetadataFormSubmitData(vm.inputSources.form); - vm.inputSources.items = vm.inputSources.items - .filter(({ input_field_name }) => input_field_name !== field) - .concat([{ - metadata, - input_field_name: field, - source_credential: credentialId, - target_credential: credential.get('id'), - summary_fields: { - source_credential: { - name: credentialName, - credential_type_id: credentialTypeId - } - }, - }]); - vm.inputSources.field = null; - vm.inputSources.metadataInputs = null; - unsetInputSourceTabs(); - vm.form[field]._value = credentialName; - vm.form[field].asTag = true; - }; - - vm.onInputSourceTabSelect = (name) => { - if (name === 'metadata') { - vm.onInputSourceNext(); - } else { - setInputSourceTab('credential'); - } - }; - - vm.onInputSourceRowClick = ({ id, credential_type, name }) => { - vm.inputSources.credentialId = id; - vm.inputSources.credentialName = name; - vm.inputSources.credentialTypeId = credential_type; - vm.inputSources._value = credential_type; - }; - - vm.onInputSourceTest = () => { - const metadata = getMetadataFormSubmitData(vm.inputSources.form); - const name = $filter('sanitize')(vm.inputSources.credentialTypeName); - const endpoint = `${vm.inputSources.credentialId}/test/`; - return runTest({ name, model: credential, endpoint, data: { metadata } }); - }; - - function onExternalTestOpen () { - credentialType.mergeInputProperties('metadata'); - vm.externalTest.metadataInputs = credentialType.get('inputs.metadata'); - } - vm.form.secondary = onExternalTestOpen; - - vm.onExternalTestClose = () => { - vm.externalTest.metadataInputs = null; - }; - - vm.onExternalTest = () => { - const name = $filter('sanitize')(credentialType.get('name')); - const { inputs } = vm.getSubmitData(); - const metadata = getMetadataFormSubmitData(vm.externalTest.form); - - let model; - if (credential.get('credential_type') !== credentialType.get('id')) { - model = credentialType; - } else { - model = credential; - } - - const endpoint = `${model.get('id')}/test/`; - return runTest({ name, model, endpoint, data: { inputs, metadata } }); - }; - - vm.filterInputSourceCredentialResults = (data) => { - if (isExternal) { - data.results = data.results.filter(({ id }) => id !== credential.get('id')); - } - return data; - }; - - function runTest ({ name, model, endpoint, data: { inputs, metadata } }) { - return model.http.post({ url: endpoint, data: { inputs, metadata }, replace: false }) - .then(() => { - const icon = 'fa-check-circle'; - const msg = strings.get('edit.TEST_PASSED'); - const content = buildTestNotificationContent({ name, icon, msg }); - ngToast.success({ - content, - dismissButton: false, - dismissOnTimeout: true - }); - }) - .catch(({ data }) => { - const icon = 'fa-exclamation-triangle'; - const msg = data.inputs || strings.get('edit.TEST_FAILED'); - const content = buildTestNotificationContent({ name, icon, msg }); - ngToast.danger({ - content, - dismissButton: false, - dismissOnTimeout: true - }); - }); - } - - function buildTestNotificationContent ({ name, msg, icon }) { - const sanitize = $filter('sanitize'); - const content = `
-
- -
-
- ${sanitize(name)}: ${sanitize(msg)} -
-
`; - return content; - } - - function createInputSource (data) { - Rest.setUrl(GetBasePath('credential_input_sources')); - return Rest.post(data); - } - - vm.form.save = data => { - data.user = me.get('id'); - - if (_.get(data.inputs, gceFileInputSchema.id)) { - delete data.inputs[gceFileInputSchema.id]; - } - - const updatedLinkedFieldNames = vm.inputSources.items - .map(({ input_field_name }) => input_field_name); - const sourcesToAssociate = [...vm.inputSources.items]; - - // remove inputs with empty string values - let filteredInputs = _.omit(data.inputs, (value) => value === ''); - // remove inputs that are to be linked to an external credential - filteredInputs = _.omit(filteredInputs, updatedLinkedFieldNames); - data.inputs = filteredInputs; - - return credential.request('post', { data }) - .then(() => { - sourcesToAssociate.forEach(obj => { obj.target_credential = credential.get('id'); }); - return Promise.all(sourcesToAssociate.map(createInputSource)); - }); - }; - - vm.form.onSaveSuccess = () => { - $state.go('credentials.edit', { credential_id: credential.get('id') }, { reload: true }); - }; - - vm.gceOnFileInputChanged = (value, oldValue) => { - if (value === oldValue) return; - - const gceFileIsLoaded = !!value; - const gceFileInputState = vm.form[gceFileInputSchema.id]; - const { obj, error } = vm.gceParseFileInput(value); - - gceFileInputState._isValid = !error; - gceFileInputState._message = error ? componentsStrings.get('message.INVALID_INPUT') : ''; - - vm.form.project._disabled = gceFileIsLoaded; - vm.form.username._disabled = gceFileIsLoaded; - vm.form.ssh_key_data._disabled = gceFileIsLoaded; - vm.form.ssh_key_data._displayHint = !vm.form.ssh_key_data._disabled; - - if (gceFileIsLoaded) { - gceFileInputPreEditValues = Object.assign({}, { - project: vm.form.project._value, - ssh_key_data: vm.form.ssh_key_data._value, - username: vm.form.username._value - }); - vm.form.project._value = _.get(obj, 'project_id', ''); - vm.form.ssh_key_data._value = _.get(obj, 'private_key', ''); - vm.form.username._value = _.get(obj, 'client_email', ''); - } else { - vm.form.project._value = gceFileInputPreEditValues.project; - vm.form.ssh_key_data._value = gceFileInputPreEditValues.ssh_key_data; - vm.form.username._value = gceFileInputPreEditValues.username; - } - }; - - vm.gceParseFileInput = value => { - let obj; - let error; - - try { - obj = angular.fromJson(value); - } catch (err) { - error = err; - } - - return { obj, error }; - }; - - $scope.$watch('organization', () => { - if ($scope.organization) { - vm.form.organization._idFromModal = $scope.organization; - } - }); - - $scope.$watch('credential_type', () => { - if ($scope.credential_type) { - vm.form.credential_type._idFromModal = $scope.credential_type; - } - }); -} - -AddCredentialsController.$inject = [ - 'resolvedModels', - '$state', - '$scope', - 'CredentialsStrings', - 'ComponentsStrings', - 'ConfigService', - 'ngToast', - 'Wait', - '$filter', - 'CredentialTypeModel', - 'GetBasePath', - 'Rest', -]; - -export default AddCredentialsController; diff --git a/awx/ui/client/features/credentials/edit-credentials.controller.js b/awx/ui/client/features/credentials/add-edit-credentials.controller.js similarity index 80% rename from awx/ui/client/features/credentials/edit-credentials.controller.js rename to awx/ui/client/features/credentials/add-edit-credentials.controller.js index 30f5082b53..f912d1919f 100644 --- a/awx/ui/client/features/credentials/edit-credentials.controller.js +++ b/awx/ui/client/features/credentials/add-edit-credentials.controller.js @@ -1,6 +1,6 @@ /* eslint camelcase: 0 */ /* eslint arrow-body-style: 0 */ -function EditCredentialsController ( +function AddEditCredentialsController ( models, $state, $scope, @@ -26,32 +26,84 @@ function EditCredentialsController ( const omit = ['user', 'team', 'inputs']; const isEditable = credential.isEditable(); const isExternal = credentialType.get('kind') === 'external'; + const mode = $state.current.name.startsWith('credentials.add') ? 'add' : 'edit'; - vm.mode = 'edit'; + vm.mode = mode; vm.strings = strings; - vm.panelTitle = credential.get('name'); - vm.tab = { - details: { - _active: true, - _go: 'credentials.edit', - _params: { credential_id: credential.get('id') } - }, - permissions: { - _go: 'credentials.edit.permissions', - _params: { credential_id: credential.get('id') } - } - }; + if (mode === 'edit') { + vm.panelTitle = credential.get('name'); + vm.tab = { + details: { + _active: true, + _go: 'credentials.edit', + _params: { credential_id: credential.get('id') } + }, + permissions: { + _go: 'credentials.edit.permissions', + _params: { credential_id: credential.get('id') } + } + }; - $scope.$watch('$state.current.name', (value) => { - if (/credentials.edit($|\.organization$|\.credentialType$)/.test(value)) { - vm.tab.details._active = true; - vm.tab.permissions._active = false; + if (isEditable) { + vm.form = credential.createFormSchema('put', { omit }); } else { - vm.tab.permissions._active = true; - vm.tab.details._active = false; + vm.form = credential.createFormSchema({ omit }); + vm.form.disabled = !isEditable; } - }); + + vm.form.organization._disabled = !isOrgEditableByUser; + // Only exists for permissions compatibility + $scope.credential_obj = credential.get(); + + vm.form.organization._resource = 'organization'; + vm.form.organization._model = organization; + vm.form.organization._route = 'credentials.edit.organization'; + vm.form.organization._value = credential.get('summary_fields.organization.id'); + vm.form.organization._displayValue = credential.get('summary_fields.organization.name'); + vm.form.organization._placeholder = strings.get('inputs.ORGANIZATION_PLACEHOLDER'); + + vm.form.credential_type._resource = 'credential_type'; + vm.form.credential_type._model = credentialType; + vm.form.credential_type._route = 'credentials.edit.credentialType'; + vm.form.credential_type._placeholder = strings.get('inputs.CREDENTIAL_TYPE_PLACEHOLDER'); + vm.form.credential_type._value = credentialType.get('id'); + vm.form.credential_type._displayValue = credentialType.get('name'); + vm.isTestable = (isEditable && credentialType.get('kind') === 'external'); + + $scope.$watch('$state.current.name', (value) => { + if (/credentials.edit($|\.organization$|\.credentialType$)/.test(value)) { + vm.tab.details._active = true; + vm.tab.permissions._active = false; + } else { + vm.tab.permissions._active = true; + vm.tab.details._active = false; + } + }); + } else if (mode === 'add') { + vm.panelTitle = strings.get('add.PANEL_TITLE'); + vm.tab = { + details: { _active: true }, + permissions: { _disabled: true } + }; + vm.form = credential.createFormSchema('post', { + omit: ['user', 'team', 'inputs'] + }); + + vm.form._formName = 'credential'; + vm.form.disabled = !credential.isCreatable(); + + vm.form.organization._resource = 'organization'; + vm.form.organization._route = 'credentials.add.organization'; + vm.form.organization._model = organization; + vm.form.organization._placeholder = strings.get('inputs.ORGANIZATION_PLACEHOLDER'); + + vm.form.credential_type._resource = 'credential_type'; + vm.form.credential_type._route = 'credentials.add.credentialType'; + vm.form.credential_type._model = credentialType; + vm.form.credential_type._placeholder = strings.get('inputs.CREDENTIAL_TYPE_PLACEHOLDER'); + vm.isTestable = credentialType.get('kind') === 'external'; + } $scope.$watch('organization', () => { if ($scope.organization) { @@ -65,33 +117,6 @@ function EditCredentialsController ( } }); - // Only exists for permissions compatibility - $scope.credential_obj = credential.get(); - - if (isEditable) { - vm.form = credential.createFormSchema('put', { omit }); - } else { - vm.form = credential.createFormSchema({ omit }); - vm.form.disabled = !isEditable; - } - - vm.form.organization._disabled = !isOrgEditableByUser; - - vm.form.organization._resource = 'organization'; - vm.form.organization._model = organization; - vm.form.organization._route = 'credentials.edit.organization'; - vm.form.organization._value = credential.get('summary_fields.organization.id'); - vm.form.organization._displayValue = credential.get('summary_fields.organization.name'); - vm.form.organization._placeholder = strings.get('inputs.ORGANIZATION_PLACEHOLDER'); - - vm.form.credential_type._resource = 'credential_type'; - vm.form.credential_type._model = credentialType; - vm.form.credential_type._route = 'credentials.edit.credentialType'; - vm.form.credential_type._value = credentialType.get('id'); - vm.form.credential_type._displayValue = credentialType.get('name'); - vm.form.credential_type._placeholder = strings.get('inputs.CREDENTIAL_TYPE_PLACEHOLDER'); - vm.isTestable = (isEditable && credentialType.get('kind') === 'external'); - const gceFileInputSchema = { id: 'gce_service_account_key', type: 'file', @@ -110,8 +135,8 @@ function EditCredentialsController ( if (credentialType.get('name') === 'Google Compute Engine') { fields.splice(2, 0, gceFileInputSchema); - $scope.$watch(`vm.form.${gceFileInputSchema.id}._value`, vm.gceOnFileInputChanged); - $scope.$watch('vm.form.ssh_key_data._isBeingReplaced', vm.gceOnReplaceKeyChanged); + $scope.$watch(`vm.form.${gceFileInputSchema.id}._value`, gceOnFileInputChanged); + $scope.$watch('vm.form.ssh_key_data._isBeingReplaced', gceOnReplaceKeyChanged); } vm.inputSources.initialItems = credential.get('related.input_sources.results'); @@ -177,14 +202,6 @@ function EditCredentialsController ( items: credential.get('related.input_sources.results'), }; - vm.onInputSourceClear = (field) => { - vm.form[field].tagMode = true; - vm.form[field].asTag = false; - vm.form[field]._value = ''; - vm.inputSources.items = vm.inputSources.items - .filter(({ input_field_name }) => input_field_name !== field); - }; - function setInputSourceTab (name) { const metaIsActive = name === 'metadata'; vm.inputSources.tabs.credential._active = !metaIsActive; @@ -200,6 +217,14 @@ function EditCredentialsController ( vm.inputSources.tabs.metadata._disabled = false; } + vm.onInputSourceClear = (field) => { + vm.form[field].tagMode = true; + vm.form[field].asTag = false; + vm.form[field]._value = ''; + vm.inputSources.items = vm.inputSources.items + .filter(({ input_field_name }) => input_field_name !== field); + }; + vm.onInputSourceOpen = (field) => { // We get here when the input source lookup modal for a field is opened. If source // credential and metadata values for this field already exist in the initial API data @@ -427,12 +452,36 @@ function EditCredentialsController ( return Rest.post(data); } + function create (data) { + data.user = me.get('id'); + + if (_.get(data.inputs, gceFileInputSchema.id)) { + delete data.inputs[gceFileInputSchema.id]; + } + + const updatedLinkedFieldNames = vm.inputSources.items + .map(({ input_field_name }) => input_field_name); + const sourcesToAssociate = [...vm.inputSources.items]; + + // remove inputs with empty string values + let filteredInputs = _.omit(data.inputs, (value) => value === ''); + // remove inputs that are to be linked to an external credential + filteredInputs = _.omit(filteredInputs, updatedLinkedFieldNames); + data.inputs = filteredInputs; + + return credential.request('post', { data }) + .then(() => { + sourcesToAssociate.forEach(obj => { obj.target_credential = credential.get('id'); }); + return Promise.all(sourcesToAssociate.map(createInputSource)); + }); + } + /** * If a credential's `credential_type` is changed while editing, the inputs associated with * the old type need to be cleared before saving the inputs associated with the new type. * Otherwise inputs are merged together making the request invalid. */ - vm.form.save = data => { + function update (data) { data.user = me.get('id'); credential.unset('inputs'); @@ -466,22 +515,29 @@ function EditCredentialsController ( return Promise.all(sourcesToDisassociate.map(deleteInputSource)) .then(() => credential.request('put', { data })) .then(() => Promise.all(sourcesToAssociate.map(createInputSource))); + } + + vm.form.save = data => { + if (mode === 'edit') { + return update(data); + } + return create(data); }; vm.form.onSaveSuccess = () => { $state.go('credentials.edit', { credential_id: credential.get('id') }, { reload: true }); }; - vm.gceOnReplaceKeyChanged = value => { + function gceOnReplaceKeyChanged (value) { vm.form[gceFileInputSchema.id]._disabled = !value; - }; + } - vm.gceOnFileInputChanged = (value, oldValue) => { + function gceOnFileInputChanged (value, oldValue) { if (value === oldValue) return; const gceFileIsLoaded = !!value; const gceFileInputState = vm.form[gceFileInputSchema.id]; - const { obj, error } = vm.gceParseFileInput(value); + const { obj, error } = gceParseFileInput(value); gceFileInputState._isValid = !error; gceFileInputState._message = error ? componentsStrings.get('message.INVALID_INPUT') : ''; @@ -505,9 +561,9 @@ function EditCredentialsController ( vm.form.ssh_key_data._value = gceFileInputPreEditValues.ssh_key_data; vm.form.username._value = gceFileInputPreEditValues.username; } - }; + } - vm.gceParseFileInput = value => { + function gceParseFileInput (value) { let obj; let error; @@ -518,10 +574,10 @@ function EditCredentialsController ( } return { obj, error }; - }; + } } -EditCredentialsController.$inject = [ +AddEditCredentialsController.$inject = [ 'resolvedModels', '$state', '$scope', @@ -536,4 +592,4 @@ EditCredentialsController.$inject = [ 'Rest', ]; -export default EditCredentialsController; +export default AddEditCredentialsController; diff --git a/awx/ui/client/features/credentials/index.js b/awx/ui/client/features/credentials/index.js index c9546563d4..cb9b8147ff 100644 --- a/awx/ui/client/features/credentials/index.js +++ b/awx/ui/client/features/credentials/index.js @@ -1,6 +1,5 @@ import LegacyCredentials from './legacy.credentials'; -import AddController from './add-credentials.controller'; -import EditController from './edit-credentials.controller'; +import AddEditController from './add-edit-credentials.controller'; import CredentialsStrings from './credentials.strings'; import InputSourceLookupComponent from './input-source-lookup.component'; import ExternalTestModalComponent from './external-test-modal.component'; @@ -97,7 +96,7 @@ function CredentialsRun ($stateExtender, legacy, strings) { views: { 'add@credentials': { templateUrl: addEditTemplate, - controller: AddController, + controller: AddEditController, controllerAs: 'vm' } }, @@ -120,7 +119,7 @@ function CredentialsRun ($stateExtender, legacy, strings) { views: { 'edit@credentials': { templateUrl: addEditTemplate, - controller: EditController, + controller: AddEditController, controllerAs: 'vm' } }, @@ -146,8 +145,7 @@ CredentialsRun.$inject = [ angular .module(MODULE_NAME, []) - .controller('AddController', AddController) - .controller('EditController', EditController) + .controller('AddEditController', AddEditController) .service('LegacyCredentialsService', LegacyCredentials) .service('CredentialsStrings', CredentialsStrings) .component('atInputSourceLookup', InputSourceLookupComponent) From 736bd2ed671ddb945aac837141b84f155415a8a5 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Tue, 5 Mar 2019 09:30:20 -0500 Subject: [PATCH 34/74] add validation for required values and metadata fields --- .../external-test-modal.partial.html | 15 ++++-- .../input-source-lookup.partial.html | 3 ++ .../action/action-button.component.js | 33 ------------- .../action/action-button.directive.js | 48 +++++++++++++++++++ .../lib/components/form/form.directive.js | 5 +- awx/ui/client/lib/components/index.js | 4 +- 6 files changed, 68 insertions(+), 40 deletions(-) delete mode 100644 awx/ui/client/lib/components/action/action-button.component.js create mode 100644 awx/ui/client/lib/components/action/action-button.directive.js diff --git a/awx/ui/client/features/credentials/external-test-modal.partial.html b/awx/ui/client/features/credentials/external-test-modal.partial.html index c44ef4487e..57ba47bc3f 100644 --- a/awx/ui/client/features/credentials/external-test-modal.partial.html +++ b/awx/ui/client/features/credentials/external-test-modal.partial.html @@ -2,11 +2,18 @@ - - {{::vm.strings.get('CLOSE')}} + + {{::vm.strings.get('CLOSE')}} - - {{::vm.strings.get('RUN')}} + + {{::vm.strings.get('RUN')}} diff --git a/awx/ui/client/features/credentials/input-source-lookup.partial.html b/awx/ui/client/features/credentials/input-source-lookup.partial.html index 336a8e62c0..b7c420fcf0 100644 --- a/awx/ui/client/features/credentials/input-source-lookup.partial.html +++ b/awx/ui/client/features/credentials/input-source-lookup.partial.html @@ -37,6 +37,7 @@ {{::vm.strings.get('TEST')}} @@ -50,6 +51,7 @@ {{::vm.strings.get('NEXT')}} @@ -57,6 +59,7 @@ {{::vm.strings.get('OK')}} diff --git a/awx/ui/client/lib/components/action/action-button.component.js b/awx/ui/client/lib/components/action/action-button.component.js deleted file mode 100644 index d77bf218db..0000000000 --- a/awx/ui/client/lib/components/action/action-button.component.js +++ /dev/null @@ -1,33 +0,0 @@ -const templateUrl = require('~components/action/action-button.partial.html'); - -function ActionButtonController () { - const vm = this || {}; - vm.$onInit = () => { - const { variant } = vm; - - if (variant === 'primary') { - vm.color = 'success'; - vm.fill = ''; - } - - if (variant === 'secondary') { - vm.color = 'info'; - vm.fill = ''; - } - - if (variant === 'tertiary') { - vm.color = 'default'; - vm.fill = 'Hollow'; - } - }; -} - -export default { - templateUrl, - controller: ActionButtonController, - controllerAs: 'vm', - transclude: true, - bindings: { - variant: '@', - }, -}; diff --git a/awx/ui/client/lib/components/action/action-button.directive.js b/awx/ui/client/lib/components/action/action-button.directive.js new file mode 100644 index 0000000000..733faa6f97 --- /dev/null +++ b/awx/ui/client/lib/components/action/action-button.directive.js @@ -0,0 +1,48 @@ +const templateUrl = require('~components/action/action-button.partial.html'); + +function link (scope, element, attrs, controllers) { + const [actionButtonController] = controllers; + + actionButtonController.init(scope); +} + +function ActionButtonController () { + const vm = this || {}; + + vm.init = (scope) => { + const { variant } = scope; + + if (variant === 'primary') { + vm.color = 'success'; + vm.fill = ''; + } + + if (variant === 'secondary') { + vm.color = 'info'; + vm.fill = ''; + } + + if (variant === 'tertiary') { + vm.color = 'default'; + vm.fill = 'Hollow'; + } + }; +} + +function atActionButton () { + return { + restrict: 'E', + transclude: true, + replace: true, + templateUrl, + require: ['atActionButton'], + controller: ActionButtonController, + controllerAs: 'vm', + link, + scope: { + variant: '@', + } + }; +} + +export default atActionButton; diff --git a/awx/ui/client/lib/components/form/form.directive.js b/awx/ui/client/lib/components/form/form.directive.js index a270b0835e..e6bb782493 100644 --- a/awx/ui/client/lib/components/form/form.directive.js +++ b/awx/ui/client/lib/components/form/form.directive.js @@ -202,7 +202,10 @@ function AtFormController (eventService, strings) { if (isValid !== vm.state.isValid) { vm.state.isValid = isValid; - scope.state.isValid = vm.state.isValid; + } + + if (isValid !== scope.state.isValid) { + scope.state.isValid = isValid; } }; diff --git a/awx/ui/client/lib/components/index.js b/awx/ui/client/lib/components/index.js index b9dca8673c..666fccf480 100644 --- a/awx/ui/client/lib/components/index.js +++ b/awx/ui/client/lib/components/index.js @@ -1,7 +1,7 @@ import atLibServices from '~services'; import actionGroup from '~components/action/action-group.directive'; -import actionButton from '~components/action/action-button.component'; +import actionButton from '~components/action/action-button.directive'; import divider from '~components/utility/divider.directive'; import dynamicSelect from '~components/input/dynamic-select.directive'; import easyModal from '~components/easy-modal/easy-modal.component'; @@ -56,7 +56,7 @@ angular atCodeMirror ]) .directive('atActionGroup', actionGroup) - .component('atActionButton', actionButton) + .directive('atActionButton', actionButton) .directive('atDivider', divider) .directive('atDynamicSelect', dynamicSelect) .component('atEasyModal', easyModal) From 6d0f2948aadac241a5a3256b7c98a880ba92ef33 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Tue, 5 Mar 2019 10:28:08 -0500 Subject: [PATCH 35/74] don't show lookup until data is fetched --- .../credentials/input-source-lookup.component.js | 10 +++++++++- .../credentials/input-source-lookup.partial.html | 3 ++- .../components/lookup-list/lookup-list.component.js | 4 +++- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/awx/ui/client/features/credentials/input-source-lookup.component.js b/awx/ui/client/features/credentials/input-source-lookup.component.js index beea7e72ec..50f78abe38 100644 --- a/awx/ui/client/features/credentials/input-source-lookup.component.js +++ b/awx/ui/client/features/credentials/input-source-lookup.component.js @@ -1,14 +1,22 @@ const templateUrl = require('~features/credentials/input-source-lookup.partial.html'); -function InputSourceLookupController (strings) { +function InputSourceLookupController (strings, wait) { const vm = this || {}; vm.strings = strings; vm.title = strings.get('inputSources.TITLE'); + + vm.$onInit = () => wait('start'); + + vm.onReady = () => { + vm.isReady = true; + wait('stop'); + }; } InputSourceLookupController.$inject = [ 'CredentialsStrings', + 'Wait', ]; export default { diff --git a/awx/ui/client/features/credentials/input-source-lookup.partial.html b/awx/ui/client/features/credentials/input-source-lookup.partial.html index b7c420fcf0..0f5a0f8e94 100644 --- a/awx/ui/client/features/credentials/input-source-lookup.partial.html +++ b/awx/ui/client/features/credentials/input-source-lookup.partial.html @@ -1,4 +1,4 @@ - + diff --git a/awx/ui/client/lib/components/lookup-list/lookup-list.component.js b/awx/ui/client/lib/components/lookup-list/lookup-list.component.js index 207456ba84..4a78625414 100644 --- a/awx/ui/client/lib/components/lookup-list/lookup-list.component.js +++ b/awx/ui/client/lib/components/lookup-list/lookup-list.component.js @@ -15,7 +15,8 @@ function LookupListController (GetBasePath, Rest, strings) { Rest.get({ params }) .then(({ data }) => { setData(resultsFilter(data)); - }); + }) + .finally(() => vm.onReady()); }; function setData ({ results, count }) { @@ -43,6 +44,7 @@ export default { bindings: { onSelect: '=', onRowClick: '=', + onReady: '=', selectedId: '=', resourceName: '@', baseParams: '=', From 5c855b5bd1059d969c30c0efa3d35f0a20384619 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Tue, 5 Mar 2019 13:58:10 -0500 Subject: [PATCH 36/74] add selected credential tray to input source lookup --- awx/ui/client/features/_index.less | 1 + .../client/features/credentials/_index.less | 21 +++++++++++++++++++ .../add-edit-credentials.controller.js | 2 +- .../add-edit-credentials.view.html | 3 ++- .../credentials/credentials.strings.js | 2 ++ .../input-source-lookup.component.js | 3 ++- .../input-source-lookup.partial.html | 21 +++++++++++++++++-- .../lookup-list/lookup-list.component.js | 3 +-- .../lookup-list/lookup-list.partial.html | 4 ++-- 9 files changed, 51 insertions(+), 9 deletions(-) create mode 100644 awx/ui/client/features/credentials/_index.less diff --git a/awx/ui/client/features/_index.less b/awx/ui/client/features/_index.less index b7165c94c9..2769b29de7 100644 --- a/awx/ui/client/features/_index.less +++ b/awx/ui/client/features/_index.less @@ -1,5 +1,6 @@ @import 'portalMode/_index'; @import 'output/_index'; +@import 'credentials/_index'; /** @define Popup Modal after create new token and applicaiton and save form */ .PopupModal { diff --git a/awx/ui/client/features/credentials/_index.less b/awx/ui/client/features/credentials/_index.less new file mode 100644 index 0000000000..51d407ca2d --- /dev/null +++ b/awx/ui/client/features/credentials/_index.less @@ -0,0 +1,21 @@ +.InputSourceLookup-selectedItem { + display: flex; + flex: 0 0 100%; + align-items: center; + min-height: 50px; + margin-top: 16px; + border-radius: 5px; + background-color: @default-no-items-bord; + border: 1px solid @default-border; +} + +.InputSourceLookup-selectedItemLabel { + color: @default-interface-txt; + text-transform: uppercase; + margin-right: 10px; + margin-left: 10px; +} + +.InputSourceLookup-selectedItemText { + font-style: italic; +} diff --git a/awx/ui/client/features/credentials/add-edit-credentials.controller.js b/awx/ui/client/features/credentials/add-edit-credentials.controller.js index f912d1919f..8c47d40637 100644 --- a/awx/ui/client/features/credentials/add-edit-credentials.controller.js +++ b/awx/ui/client/features/credentials/add-edit-credentials.controller.js @@ -338,7 +338,7 @@ function AddEditCredentialsController ( } }; - vm.onInputSourceRowClick = ({ id, credential_type, name }) => { + vm.onInputSourceItemSelect = ({ id, credential_type, name }) => { vm.inputSources.credentialId = id; vm.inputSources.credentialName = name; vm.inputSources.credentialTypeId = credential_type; diff --git a/awx/ui/client/features/credentials/add-edit-credentials.view.html b/awx/ui/client/features/credentials/add-edit-credentials.view.html index 45c6568552..b642a6da07 100644 --- a/awx/ui/client/features/credentials/add-edit-credentials.view.html +++ b/awx/ui/client/features/credentials/add-edit-credentials.view.html @@ -46,13 +46,14 @@ diff --git a/awx/ui/client/features/credentials/credentials.strings.js b/awx/ui/client/features/credentials/credentials.strings.js index d740a91d6d..55e19ca152 100644 --- a/awx/ui/client/features/credentials/credentials.strings.js +++ b/awx/ui/client/features/credentials/credentials.strings.js @@ -32,6 +32,8 @@ function CredentialsStrings (BaseString) { METADATA: t.s('METADATA'), NO_MATCH: t.s('No records matched your search.'), NO_RECORDS: t.s('No external credentials available.'), + SELECTED: t.s('selected'), + NONE_SELECTED: t.s('No credential selected'), }; ns.add = { diff --git a/awx/ui/client/features/credentials/input-source-lookup.component.js b/awx/ui/client/features/credentials/input-source-lookup.component.js index 50f78abe38..6d6d8841de 100644 --- a/awx/ui/client/features/credentials/input-source-lookup.component.js +++ b/awx/ui/client/features/credentials/input-source-lookup.component.js @@ -29,9 +29,10 @@ export default { onNext: '=', onSelect: '=', onTabSelect: '=', - onRowClick: '=', + onItemSelect: '=', onTest: '=', selectedId: '=', + selectedName: '=', form: '=', resultsFilter: '=', }, diff --git a/awx/ui/client/features/credentials/input-source-lookup.partial.html b/awx/ui/client/features/credentials/input-source-lookup.partial.html index 0f5a0f8e94..844fb5887d 100644 --- a/awx/ui/client/features/credentials/input-source-lookup.partial.html +++ b/awx/ui/client/features/credentials/input-source-lookup.partial.html @@ -13,6 +13,24 @@ {{::vm.strings.get('inputSources.METADATA')}} +
+
+ {{::vm.strings.get('inputSources.SELECTED')}} +
+ + +
+ {{::vm.strings.get('inputSources.NONE_SELECTED')}} +
+
+

{{ obj.name }}
From 1344706095a3d5d7189257e20295a5977a6955f7 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Tue, 5 Mar 2019 16:35:32 -0500 Subject: [PATCH 37/74] add wrapper for text input tags --- awx/ui/client/lib/components/input/_index.less | 6 ++++++ .../client/lib/components/input/text.partial.html | 14 ++++++++------ awx/ui/client/lib/components/tag/_index.less | 1 + 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/awx/ui/client/lib/components/input/_index.less b/awx/ui/client/lib/components/input/_index.less index 47f0fc35fc..46ffdc7151 100644 --- a/awx/ui/client/lib/components/input/_index.less +++ b/awx/ui/client/lib/components/input/_index.less @@ -328,3 +328,9 @@ margin: 0; } } + +.at-InputTagContainer { + display: flex; + width: 100%; + flex-wrap: wrap; +} diff --git a/awx/ui/client/lib/components/input/text.partial.html b/awx/ui/client/lib/components/input/text.partial.html index 45d5c20340..e2505ff242 100644 --- a/awx/ui/client/lib/components/input/text.partial.html +++ b/awx/ui/client/lib/components/input/text.partial.html @@ -11,12 +11,14 @@ - +
+ +
Date: Wed, 6 Mar 2019 11:34:10 -0500 Subject: [PATCH 38/74] enable input source linking for password fields --- .../add-edit-credentials.controller.js | 10 +++- .../lib/components/input/secret.directive.js | 23 ++++++--- .../lib/components/input/secret.partial.html | 48 ++++++++++++++----- .../lib/components/input/text.partial.html | 4 +- .../input/textarea-secret.directive.js | 1 + awx/ui/client/lib/models/Credential.js | 2 +- 6 files changed, 66 insertions(+), 22 deletions(-) diff --git a/awx/ui/client/features/credentials/add-edit-credentials.controller.js b/awx/ui/client/features/credentials/add-edit-credentials.controller.js index 8c47d40637..306c60bfab 100644 --- a/awx/ui/client/features/credentials/add-edit-credentials.controller.js +++ b/awx/ui/client/features/credentials/add-edit-credentials.controller.js @@ -221,6 +221,7 @@ function AddEditCredentialsController ( vm.form[field].tagMode = true; vm.form[field].asTag = false; vm.form[field]._value = ''; + vm.form[field]._tagValue = ''; vm.inputSources.items = vm.inputSources.items .filter(({ input_field_name }) => input_field_name !== field); }; @@ -239,7 +240,13 @@ function AddEditCredentialsController ( vm.inputSources.credentialName = name; vm.inputSources.credentialTypeId = credential_type_id; vm.inputSources._value = credential_type_id; + } else { + vm.inputSources.credentialId = null; + vm.inputSources.credentialName = null; + vm.inputSources.credentialTypeId = null; + vm.inputSources._value = null; } + setInputSourceTab('credential'); vm.inputSources.field = field; }; @@ -324,7 +331,8 @@ function AddEditCredentialsController ( vm.inputSources.metadataInputs = null; unsetInputSourceTabs(); // We've linked this field to a credential, so display value as a credential tag - vm.form[field]._value = credentialName; + vm.form[field]._value = ''; + vm.form[field]._tagValue = credentialName; vm.form[field].asTag = true; }; diff --git a/awx/ui/client/lib/components/input/secret.directive.js b/awx/ui/client/lib/components/input/secret.directive.js index 01b16d412b..1d1384fcfc 100644 --- a/awx/ui/client/lib/components/input/secret.directive.js +++ b/awx/ui/client/lib/components/input/secret.directive.js @@ -21,17 +21,13 @@ function AtInputSecretController (baseInputController) { scope = _scope_; scope.type = 'password'; + scope.state._show = false; if (!scope.state._value || scope.state._promptOnLaunch) { scope.mode = 'input'; - scope.state._buttonText = vm.strings.get('SHOW'); - - vm.toggle = vm.toggleShowHide; } else { scope.mode = 'encrypted'; - scope.state._buttonText = vm.strings.get('REPLACE'); scope.state._placeholder = vm.strings.get('ENCRYPTED'); - vm.toggle = vm.toggleRevertReplace; } vm.check(); @@ -41,15 +37,28 @@ function AtInputSecretController (baseInputController) { scope.state._isBeingReplaced = !scope.state._isBeingReplaced; vm.onRevertReplaceToggle(); + + if (scope.state._isBeingReplaced) { + if (scope.type !== 'password') { + vm.toggleShowHide(); + } + } }; vm.toggleShowHide = () => { if (scope.type === 'password') { scope.type = 'text'; - scope.state._buttonText = vm.strings.get('HIDE'); + scope.state._show = true; } else { scope.type = 'password'; - scope.state._buttonText = vm.strings.get('SHOW'); + scope.state._show = false; + } + }; + + vm.onLookupClick = () => { + if (scope.state._onInputLookup) { + const { id, label, required, type } = scope.state; + scope.state._onInputLookup({ id, label, required, type }); } }; } diff --git a/awx/ui/client/lib/components/input/secret.partial.html b/awx/ui/client/lib/components/input/secret.partial.html index fa529f08a9..3a0fc29c89 100644 --- a/awx/ui/client/lib/components/input/secret.partial.html +++ b/awx/ui/client/lib/components/input/secret.partial.html @@ -3,18 +3,25 @@
- - - +
+ +
+ + + ng-disabled="state._disabled || form.disabled" + /> + + + + + +
diff --git a/awx/ui/client/lib/components/input/text.partial.html b/awx/ui/client/lib/components/input/text.partial.html index e2505ff242..bf080a1f41 100644 --- a/awx/ui/client/lib/components/input/text.partial.html +++ b/awx/ui/client/lib/components/input/text.partial.html @@ -13,9 +13,9 @@
diff --git a/awx/ui/client/lib/components/input/textarea-secret.directive.js b/awx/ui/client/lib/components/input/textarea-secret.directive.js index 2bcbe027d5..54f13d045f 100644 --- a/awx/ui/client/lib/components/input/textarea-secret.directive.js +++ b/awx/ui/client/lib/components/input/textarea-secret.directive.js @@ -45,6 +45,7 @@ function AtInputTextareaSecretController (baseInputController, eventService) { }; vm.onIsBeingReplacedChanged = () => { + if (!scope.state) return; if (!scope.state._touched) return; vm.onRevertReplaceToggle(); diff --git a/awx/ui/client/lib/models/Credential.js b/awx/ui/client/lib/models/Credential.js index 2692fd76aa..3611302f7a 100644 --- a/awx/ui/client/lib/models/Credential.js +++ b/awx/ui/client/lib/models/Credential.js @@ -78,7 +78,7 @@ function assignInputGroupValues (apiConfig, credentialType) { field.asTag = true; const { summary_fields } = this.get('related.input_sources.results') .find(({ input_field_name }) => input_field_name === field.id); - field._value = summary_fields.source_credential.name; + field._tagValue = summary_fields.source_credential.name; } return field; }); From e14f17687c298c596090b5dcf376fb19e902997f Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Wed, 6 Mar 2019 18:30:08 -0500 Subject: [PATCH 39/74] disable prompt-on-launch when input source is set --- awx/ui/client/lib/components/input/label.partial.html | 1 + awx/ui/client/lib/components/input/secret.partial.html | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/awx/ui/client/lib/components/input/label.partial.html b/awx/ui/client/lib/components/input/label.partial.html index c8d4802ffd..a01895e361 100644 --- a/awx/ui/client/lib/components/input/label.partial.html +++ b/awx/ui/client/lib/components/input/label.partial.html @@ -6,6 +6,7 @@
- +
+ +
{ - baseInputController.call(vm, 'input', scope, element, form); + vm.init = (_scope_, element, form) => { + baseInputController.call(vm, 'input', _scope_, element, form); + scope = _scope_; vm.check(); }; + + vm.onLookupClick = () => { + if (scope.state._onInputLookup) { + const { id, label, required, type } = scope.state; + scope.state._onInputLookup({ id, label, required, type }); + } + }; } AtInputTextareaController.$inject = ['BaseInputController']; diff --git a/awx/ui/client/lib/components/input/textarea.partial.html b/awx/ui/client/lib/components/input/textarea.partial.html index bc0738dc9f..4eb276f35f 100644 --- a/awx/ui/client/lib/components/input/textarea.partial.html +++ b/awx/ui/client/lib/components/input/textarea.partial.html @@ -1,17 +1,40 @@
- - - +
+
+ +
+
+
+ +
+
+
-
From 43456d13c492d92b6ea18468a9fc3b9be95520ca Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Wed, 13 Mar 2019 18:35:50 -0400 Subject: [PATCH 42/74] don't replace input source unless changed --- .../add-edit-credentials.controller.js | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/awx/ui/client/features/credentials/add-edit-credentials.controller.js b/awx/ui/client/features/credentials/add-edit-credentials.controller.js index 306c60bfab..5da6b761dc 100644 --- a/awx/ui/client/features/credentials/add-edit-credentials.controller.js +++ b/awx/ui/client/features/credentials/add-edit-credentials.controller.js @@ -141,6 +141,7 @@ function AddEditCredentialsController ( vm.inputSources.initialItems = credential.get('related.input_sources.results'); vm.inputSources.items = []; + vm.inputSources.changedInputFields = []; if (credential.get('credential_type') === credentialType.get('id')) { vm.inputSources.items = credential.get('related.input_sources.results'); } @@ -198,6 +199,7 @@ function AddEditCredentialsController ( credentialId: null, credentialName: null, metadataInputs: null, + changedInputFields: [], initialItems: credential.get('related.input_sources.results'), items: credential.get('related.input_sources.results'), }; @@ -224,6 +226,7 @@ function AddEditCredentialsController ( vm.form[field]._tagValue = ''; vm.inputSources.items = vm.inputSources.items .filter(({ input_field_name }) => input_field_name !== field); + vm.inputSources.changedInputFields.push(field); }; vm.onInputSourceOpen = (field) => { @@ -325,6 +328,8 @@ function AddEditCredentialsController ( } }, }]); + // Record that this field was changed + vm.inputSources.changedInputFields.push(field); // Now that we've extracted and stored the selected source credential and metadata values // for this field, we clear the state for the source credential lookup and metadata form. vm.inputSources.field = null; @@ -502,15 +507,17 @@ function AddEditCredentialsController ( const updatedLinkedFieldNames = vm.inputSources.items .map(({ input_field_name }) => input_field_name); - const fieldsToDisassociate = [...initialLinkedFieldNames] - .filter(name => !updatedLinkedFieldNames.includes(name)); - const fieldsToAssociate = [...updatedLinkedFieldNames] - .filter(name => !initialLinkedFieldNames.includes(name)); + const fieldsToDisassociate = initialLinkedFieldNames + .filter(name => !updatedLinkedFieldNames.includes(name)) + .concat(updatedLinkedFieldNames) + .filter(name => vm.inputSources.changedInputFields.includes(name)); + const fieldsToAssociate = updatedLinkedFieldNames + .filter(name => vm.inputSources.changedInputFields.includes(name)); - const sourcesToDisassociate = [...fieldsToDisassociate] + const sourcesToDisassociate = fieldsToDisassociate .map(name => vm.inputSources.initialItems .find(({ input_field_name }) => input_field_name === name)); - const sourcesToAssociate = [...fieldsToAssociate] + const sourcesToAssociate = fieldsToAssociate .map(name => vm.inputSources.items .find(({ input_field_name }) => input_field_name === name)); From 1eda939ce2210cd691d8cd93f9bca21a20a6c72a Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Wed, 13 Mar 2019 19:08:12 -0400 Subject: [PATCH 43/74] add tips for secret controls --- awx/ui/client/lib/components/components.strings.js | 8 ++++---- awx/ui/client/lib/components/input/secret.directive.js | 4 ++++ awx/ui/client/lib/components/input/secret.partial.html | 10 ++++++++-- .../lib/components/input/textarea-secret.partial.html | 5 ++++- 4 files changed, 20 insertions(+), 7 deletions(-) diff --git a/awx/ui/client/lib/components/components.strings.js b/awx/ui/client/lib/components/components.strings.js index ce5de265b3..4f506bc480 100644 --- a/awx/ui/client/lib/components/components.strings.js +++ b/awx/ui/client/lib/components/components.strings.js @@ -4,12 +4,12 @@ function ComponentsStrings (BaseString) { const { t } = this; const ns = this.components; - ns.REPLACE = t.s('REPLACE'); - ns.REVERT = t.s('REVERT'); + ns.REPLACE = t.s('Replace'); + ns.REVERT = t.s('Revert'); ns.ENCRYPTED = t.s('ENCRYPTED'); ns.OPTIONS = t.s('OPTIONS'); - ns.SHOW = t.s('SHOW'); - ns.HIDE = t.s('HIDE'); + ns.SHOW = t.s('Show'); + ns.HIDE = t.s('Hide'); ns.message = { REQUIRED_INPUT_MISSING: t.s('Please enter a value.'), diff --git a/awx/ui/client/lib/components/input/secret.directive.js b/awx/ui/client/lib/components/input/secret.directive.js index 1d1384fcfc..de16f12269 100644 --- a/awx/ui/client/lib/components/input/secret.directive.js +++ b/awx/ui/client/lib/components/input/secret.directive.js @@ -22,12 +22,14 @@ function AtInputSecretController (baseInputController) { scope = _scope_; scope.type = 'password'; scope.state._show = false; + scope.state._showHideText = vm.strings.get('SHOW'); if (!scope.state._value || scope.state._promptOnLaunch) { scope.mode = 'input'; } else { scope.mode = 'encrypted'; scope.state._placeholder = vm.strings.get('ENCRYPTED'); + scope.state._buttonText = vm.strings.get('REPLACE'); } vm.check(); @@ -49,9 +51,11 @@ function AtInputSecretController (baseInputController) { if (scope.type === 'password') { scope.type = 'text'; scope.state._show = true; + scope.state._showHideText = vm.strings.get('HIDE'); } else { scope.type = 'password'; scope.state._show = false; + scope.state._showHideText = vm.strings.get('SHOW'); } }; diff --git a/awx/ui/client/lib/components/input/secret.partial.html b/awx/ui/client/lib/components/input/secret.partial.html index 9518965cc7..23ef5f6d98 100644 --- a/awx/ui/client/lib/components/input/secret.partial.html +++ b/awx/ui/client/lib/components/input/secret.partial.html @@ -35,7 +35,10 @@ @@ -44,7 +47,10 @@ diff --git a/awx/ui/client/lib/components/input/textarea-secret.partial.html b/awx/ui/client/lib/components/input/textarea-secret.partial.html index 0b5db6c0db..0e4aebe44e 100644 --- a/awx/ui/client/lib/components/input/textarea-secret.partial.html +++ b/awx/ui/client/lib/components/input/textarea-secret.partial.html @@ -23,7 +23,7 @@ ng-show="state.asTag" class="form-control at-Input at-InputTaggedTextarea" ng-class="{ - 'at-Input--rejected': state._rejected, + 'at-Input--rejected': state._rejected, }" >
@@ -50,6 +50,9 @@ class="btn at-ButtonHollow--white at-Input-button--long-sm" ng-disabled="state.asTag || (!state._enableToggle && (state._disabled || form.disabled))" ng-click="state._isBeingReplaced = !state._isBeingReplaced" + aw-tool-tip="{{ state._buttonText }}" + data-tip-watch="state._buttonText" + data-placement="top" > From 5b7984339042cf3f4b0b0d1eb699bed86faf31fe Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Mon, 18 Mar 2019 07:39:27 -0400 Subject: [PATCH 44/74] use a shared variable for layout declarations --- awx/ui/client/features/credentials/_index.less | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/awx/ui/client/features/credentials/_index.less b/awx/ui/client/features/credentials/_index.less index 51d407ca2d..f968043d3c 100644 --- a/awx/ui/client/features/credentials/_index.less +++ b/awx/ui/client/features/credentials/_index.less @@ -12,8 +12,7 @@ .InputSourceLookup-selectedItemLabel { color: @default-interface-txt; text-transform: uppercase; - margin-right: 10px; - margin-left: 10px; + margin: 0 @at-space-2x; } .InputSourceLookup-selectedItemText { From 05226333ffaa0d29d22b80d8d7c3ac5380176272 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Mon, 18 Mar 2019 07:42:24 -0400 Subject: [PATCH 45/74] move tag max height declaration to input tag wrapper We don't want to apply max height to all tags, just the ones we embed within text/textarea input fields. --- awx/ui/client/lib/components/input/_index.less | 14 +++++++++----- awx/ui/client/lib/components/tag/_index.less | 1 - 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/awx/ui/client/lib/components/input/_index.less b/awx/ui/client/lib/components/input/_index.less index a6182e9168..68be7f59d0 100644 --- a/awx/ui/client/lib/components/input/_index.less +++ b/awx/ui/client/lib/components/input/_index.less @@ -346,10 +346,14 @@ display: flex; width: 100%; flex-wrap: wrap; -} -.at-InputTagContainer .TagComponent-name { - align-self: auto; - word-break: break-all; - font-family: 'Open Sans', sans-serif; + .TagComponent { + max-height: @at-space-4x; + } + + .TagComponent-name { + align-self: auto; + word-break: break-all; + font-family: 'Open Sans', sans-serif; + } } diff --git a/awx/ui/client/lib/components/tag/_index.less b/awx/ui/client/lib/components/tag/_index.less index 99186a1f9a..b91830f51f 100644 --- a/awx/ui/client/lib/components/tag/_index.less +++ b/awx/ui/client/lib/components/tag/_index.less @@ -8,7 +8,6 @@ flex-direction: row; align-content: center; min-height: @at-space-4x; - max-height: @at-space-4x; overflow: hidden; max-width: 200px; margin: @at-space 0; From ea9ed31f9de693975ca67b793a00b8000da1e51a Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Mon, 18 Mar 2019 07:44:54 -0400 Subject: [PATCH 46/74] refactor metadata conversion function to use reduce --- .../credentials/add-edit-credentials.controller.js | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/awx/ui/client/features/credentials/add-edit-credentials.controller.js b/awx/ui/client/features/credentials/add-edit-credentials.controller.js index 5da6b761dc..08954e5cf8 100644 --- a/awx/ui/client/features/credentials/add-edit-credentials.controller.js +++ b/awx/ui/client/features/credentials/add-edit-credentials.controller.js @@ -269,10 +269,12 @@ function AddEditCredentialsController ( * metadata object that can be sent to the api later or reloaded when re-opening the form. */ function getMetadataFormSubmitData ({ inputs }) { - const metadata = Object.assign({}, ...inputs._group - .filter(({ _value }) => _value !== undefined) - .map(({ id, _value }) => ({ [id]: _value }))); - return metadata; + return inputs._group.reduce((metadata, { id, _value }) => { + if (_value !== undefined) { + metadata[id] = _value; + } + return metadata; + }, {}); } vm.onInputSourceNext = () => { @@ -287,7 +289,7 @@ function AddEditCredentialsController ( // field_name->source_credential link already exists. This occurs one of two ways: // // 1. This field->source_credential link already exists in the API and so we're - // reflecting the current state as it exists on the backend. + // showing the current state as it exists on the backend. // 2. The metadata form for this specific field->source_credential combination was // set during a prior visit to this lookup and so we're reflecting the most // recent set of (unsaved) metadata values provided by the user for this field. @@ -312,7 +314,7 @@ function AddEditCredentialsController ( const { field, credentialId, credentialName, credentialTypeId } = vm.inputSources; const metadata = getMetadataFormSubmitData(vm.inputSources.form); // Remove any input source objects already stored for this field then store the metadata - // and currently selected source credential as a valid credential input source object that + // and currently selected source credential as a credential input source object that // can be sent to the api later or reloaded into the form if it is reopened. vm.inputSources.items = vm.inputSources.items .filter(({ input_field_name }) => input_field_name !== field) From dfaf19cdf36a41fb8f25275efc69ce8136e64da2 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Mon, 18 Mar 2019 07:52:13 -0400 Subject: [PATCH 47/74] use default action button class when fill and color props aren't given --- awx/ui/client/lib/components/action/action-button.partial.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/ui/client/lib/components/action/action-button.partial.html b/awx/ui/client/lib/components/action/action-button.partial.html index b205af475e..d9f9283351 100644 --- a/awx/ui/client/lib/components/action/action-button.partial.html +++ b/awx/ui/client/lib/components/action/action-button.partial.html @@ -1,3 +1,3 @@ - From 8180a2060afee3fd638a3fa51f892821a6971269 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Mon, 18 Mar 2019 08:00:44 -0400 Subject: [PATCH 48/74] rename at-easy-modal to at-dialog --- .../credentials/external-test-modal.partial.html | 4 ++-- .../credentials/input-source-lookup.partial.html | 4 ++-- awx/ui/client/lib/components/_index.less | 2 +- .../lib/components/{easy-modal => dialog}/_index.less | 10 +++++----- .../dialog.component.js} | 10 +++++----- .../dialog.partial.html} | 10 +++++----- awx/ui/client/lib/components/index.js | 4 ++-- 7 files changed, 22 insertions(+), 22 deletions(-) rename awx/ui/client/lib/components/{easy-modal => dialog}/_index.less (74%) rename awx/ui/client/lib/components/{easy-modal/easy-modal.component.js => dialog/dialog.component.js} (63%) rename awx/ui/client/lib/components/{easy-modal/easy-modal.partial.html => dialog/dialog.partial.html} (59%) diff --git a/awx/ui/client/features/credentials/external-test-modal.partial.html b/awx/ui/client/features/credentials/external-test-modal.partial.html index 57ba47bc3f..5f35774655 100644 --- a/awx/ui/client/features/credentials/external-test-modal.partial.html +++ b/awx/ui/client/features/credentials/external-test-modal.partial.html @@ -1,4 +1,4 @@ - + @@ -17,4 +17,4 @@ - + diff --git a/awx/ui/client/features/credentials/input-source-lookup.partial.html b/awx/ui/client/features/credentials/input-source-lookup.partial.html index 74de2429f2..3c5b0478e2 100644 --- a/awx/ui/client/features/credentials/input-source-lookup.partial.html +++ b/awx/ui/client/features/credentials/input-source-lookup.partial.html @@ -1,4 +1,4 @@ - + - + diff --git a/awx/ui/client/lib/components/_index.less b/awx/ui/client/lib/components/_index.less index b065777948..a0be9a31a7 100644 --- a/awx/ui/client/lib/components/_index.less +++ b/awx/ui/client/lib/components/_index.less @@ -1,10 +1,10 @@ @import 'action/_index'; +@import 'dialog/_index'; @import 'input/_index'; @import 'launchTemplateButton/_index'; @import 'layout/_index'; @import 'list/_index'; @import 'modal/_index'; -@import 'easy-modal/_index'; @import 'panel/_index'; @import 'popover/_index'; @import 'relaunchButton/_index'; diff --git a/awx/ui/client/lib/components/easy-modal/_index.less b/awx/ui/client/lib/components/dialog/_index.less similarity index 74% rename from awx/ui/client/lib/components/easy-modal/_index.less rename to awx/ui/client/lib/components/dialog/_index.less index 942803309b..a793d65a63 100644 --- a/awx/ui/client/lib/components/easy-modal/_index.less +++ b/awx/ui/client/lib/components/dialog/_index.less @@ -1,25 +1,25 @@ -.at-EasyModal-body { +.at-Dialog-body { font-size: @at-font-size; padding: @at-padding-panel 0; } -.at-EasyModal-dismiss { +.at-Dialog-dismiss { .at-mixin-ButtonIcon(); font-size: @at-font-size-modal-dismiss; color: @at-color-icon-dismiss; text-align: right; } -.at-EasyModal-heading { +.at-Dialog-heading { margin: 0; overflow: visible; - & > .at-EasyModal-dismiss { + & > .at-Dialog-dismiss { margin: 0; } } -.at-EasyModal-title { +.at-Dialog-title { margin: 0; padding: 0; diff --git a/awx/ui/client/lib/components/easy-modal/easy-modal.component.js b/awx/ui/client/lib/components/dialog/dialog.component.js similarity index 63% rename from awx/ui/client/lib/components/easy-modal/easy-modal.component.js rename to awx/ui/client/lib/components/dialog/dialog.component.js index cf78db312c..33edcf1783 100644 --- a/awx/ui/client/lib/components/easy-modal/easy-modal.component.js +++ b/awx/ui/client/lib/components/dialog/dialog.component.js @@ -1,8 +1,8 @@ -const templateUrl = require('~components/easy-modal/easy-modal.partial.html'); +const templateUrl = require('~components/dialog/dialog.partial.html'); -const overlaySelector = '.at-EasyModal'; +const overlaySelector = '.at-Dialog'; -function EasyModalController ($element) { +function DialogController ($element) { const vm = this || {}; vm.$onInit = () => { @@ -13,13 +13,13 @@ function EasyModalController ($element) { }; } -EasyModalController.$inject = [ +DialogController.$inject = [ '$element', ]; export default { templateUrl, - controller: EasyModalController, + controller: DialogController, controllerAs: 'vm', transclude: true, bindings: { diff --git a/awx/ui/client/lib/components/easy-modal/easy-modal.partial.html b/awx/ui/client/lib/components/dialog/dialog.partial.html similarity index 59% rename from awx/ui/client/lib/components/easy-modal/easy-modal.partial.html rename to awx/ui/client/lib/components/dialog/dialog.partial.html index 2c44ab0ae0..5173207f84 100644 --- a/awx/ui/client/lib/components/easy-modal/easy-modal.partial.html +++ b/awx/ui/client/lib/components/dialog/dialog.partial.html @@ -1,14 +1,14 @@ -