mirror of
https://github.com/ansible/awx.git
synced 2026-01-14 03:10:42 -03:30
Merge pull request #3098 from ansible/credential_plugins
Credential Plugins Reviewed-by: https://github.com/softwarefactory-project-zuul[bot]
This commit is contained in:
commit
3f73176ef2
4
Makefile
4
Makefile
@ -575,6 +575,10 @@ docker-compose: docker-auth
|
||||
docker-compose-cluster: docker-auth
|
||||
CURRENT_UID=$(shell id -u) TAG=$(COMPOSE_TAG) DEV_DOCKER_TAG_BASE=$(DEV_DOCKER_TAG_BASE) docker-compose -f tools/docker-compose-cluster.yml up
|
||||
|
||||
docker-compose-credential-plugins: docker-auth
|
||||
echo -e "\033[0;31mTo generate a CyberArk Conjur API key: docker exec -it tools_conjur_1 conjurctl account create quick-start\033[0m"
|
||||
CURRENT_UID=$(shell id -u) TAG=$(COMPOSE_TAG) DEV_DOCKER_TAG_BASE=$(DEV_DOCKER_TAG_BASE) docker-compose -f tools/docker-compose.yml -f tools/docker-credential-plugins-override.yml up --no-recreate awx
|
||||
|
||||
docker-compose-test: docker-auth
|
||||
cd tools && CURRENT_UID=$(shell id -u) TAG=$(COMPOSE_TAG) DEV_DOCKER_TAG_BASE=$(DEV_DOCKER_TAG_BASE) docker-compose run --rm --service-ports awx /bin/bash
|
||||
|
||||
|
||||
@ -46,7 +46,7 @@ from awx.main.constants import (
|
||||
CENSOR_VALUE,
|
||||
)
|
||||
from awx.main.models import (
|
||||
ActivityStream, AdHocCommand, AdHocCommandEvent, Credential,
|
||||
ActivityStream, AdHocCommand, AdHocCommandEvent, Credential, CredentialInputSource,
|
||||
CredentialType, CustomInventoryScript, Fact, Group, Host, Instance,
|
||||
InstanceGroup, Inventory, InventorySource, InventoryUpdate,
|
||||
InventoryUpdateEvent, Job, JobEvent, JobHostSummary, JobLaunchConfig,
|
||||
@ -133,6 +133,8 @@ SUMMARIZABLE_FK_FIELDS = {
|
||||
'notification_template': DEFAULT_SUMMARY_FIELDS,
|
||||
'instance_group': {'id', 'name', 'controller_id'},
|
||||
'insights_credential': DEFAULT_SUMMARY_FIELDS,
|
||||
'source_credential': DEFAULT_SUMMARY_FIELDS + ('kind', 'cloud', 'credential_type_id'),
|
||||
'target_credential': DEFAULT_SUMMARY_FIELDS + ('kind', 'cloud', 'credential_type_id'),
|
||||
}
|
||||
|
||||
|
||||
@ -2582,7 +2584,7 @@ class V2CredentialFields(BaseSerializer, metaclass=BaseSerializerMetaclass):
|
||||
|
||||
|
||||
class CredentialSerializer(BaseSerializer):
|
||||
show_capabilities = ['edit', 'delete', 'copy']
|
||||
show_capabilities = ['edit', 'delete', 'copy', 'use']
|
||||
capabilities_prefetch = ['admin', 'use']
|
||||
|
||||
class Meta:
|
||||
@ -2629,6 +2631,7 @@ class CredentialSerializer(BaseSerializer):
|
||||
))
|
||||
if self.version > 1:
|
||||
res['copy'] = self.reverse('api:credential_copy', kwargs={'pk': obj.pk})
|
||||
res['input_sources'] = self.reverse('api:credential_input_source_sublist', kwargs={'pk': obj.pk})
|
||||
|
||||
# TODO: remove when API v1 is removed
|
||||
if self.version > 1:
|
||||
@ -2815,6 +2818,32 @@ class CredentialSerializerCreate(CredentialSerializer):
|
||||
return credential
|
||||
|
||||
|
||||
class CredentialInputSourceSerializer(BaseSerializer):
|
||||
show_capabilities = ['delete']
|
||||
|
||||
class Meta:
|
||||
model = CredentialInputSource
|
||||
fields = (
|
||||
'*',
|
||||
'input_field_name',
|
||||
'metadata',
|
||||
'target_credential',
|
||||
'source_credential',
|
||||
'-name',
|
||||
)
|
||||
extra_kwargs = {
|
||||
'input_field_name': {'required': True},
|
||||
'target_credential': {'required': True},
|
||||
'source_credential': {'required': True},
|
||||
}
|
||||
|
||||
def get_related(self, obj):
|
||||
res = super(CredentialInputSourceSerializer, self).get_related(obj)
|
||||
res['source_credential'] = obj.source_credential.get_absolute_url(request=self.context.get('request'))
|
||||
res['target_credential'] = obj.target_credential.get_absolute_url(request=self.context.get('request'))
|
||||
return res
|
||||
|
||||
|
||||
class UserCredentialSerializerCreate(CredentialSerializerCreate):
|
||||
|
||||
class Meta:
|
||||
|
||||
@ -12,6 +12,8 @@ from awx.api.views import (
|
||||
CredentialOwnerUsersList,
|
||||
CredentialOwnerTeamsList,
|
||||
CredentialCopy,
|
||||
CredentialInputSourceSubList,
|
||||
CredentialExternalTest,
|
||||
)
|
||||
|
||||
|
||||
@ -24,6 +26,8 @@ urls = [
|
||||
url(r'^(?P<pk>[0-9]+)/owner_users/$', CredentialOwnerUsersList.as_view(), name='credential_owner_users_list'),
|
||||
url(r'^(?P<pk>[0-9]+)/owner_teams/$', CredentialOwnerTeamsList.as_view(), name='credential_owner_teams_list'),
|
||||
url(r'^(?P<pk>[0-9]+)/copy/$', CredentialCopy.as_view(), name='credential_copy'),
|
||||
url(r'^(?P<pk>[0-9]+)/input_sources/$', CredentialInputSourceSubList.as_view(), name='credential_input_source_sublist'),
|
||||
url(r'^(?P<pk>[0-9]+)/test/$', CredentialExternalTest.as_view(), name='credential_external_test'),
|
||||
]
|
||||
|
||||
__all__ = ['urls']
|
||||
|
||||
17
awx/api/urls/credential_input_source.py
Normal file
17
awx/api/urls/credential_input_source.py
Normal file
@ -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<pk>[0-9]+)/$', CredentialInputSourceDetail.as_view(), name='credential_input_source_detail'),
|
||||
]
|
||||
|
||||
__all__ = ['urls']
|
||||
@ -8,6 +8,7 @@ from awx.api.views import (
|
||||
CredentialTypeDetail,
|
||||
CredentialTypeCredentialList,
|
||||
CredentialTypeActivityStreamList,
|
||||
CredentialTypeExternalTest,
|
||||
)
|
||||
|
||||
|
||||
@ -16,6 +17,7 @@ urls = [
|
||||
url(r'^(?P<pk>[0-9]+)/$', CredentialTypeDetail.as_view(), name='credential_type_detail'),
|
||||
url(r'^(?P<pk>[0-9]+)/credentials/$', CredentialTypeCredentialList.as_view(), name='credential_type_credential_list'),
|
||||
url(r'^(?P<pk>[0-9]+)/activity_stream/$', CredentialTypeActivityStreamList.as_view(), name='credential_type_activity_stream_list'),
|
||||
url(r'^(?P<pk>[0-9]+)/test/$', CredentialTypeExternalTest.as_view(), name='credential_type_external_test'),
|
||||
]
|
||||
|
||||
__all__ = ['urls']
|
||||
|
||||
@ -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<pk>[0-9]+)/ansible_facts/$', HostAnsibleFactsDetail.as_view(), name='host_ansible_facts_detail'),
|
||||
url(r'^jobs/(?P<pk>[0-9]+)/extra_credentials/$', JobExtraCredentialsList.as_view(), name='job_extra_credentials_list'),
|
||||
url(r'^jobs/(?P<pk>[0-9]+)/credentials/$', JobCredentialsList.as_view(), name='job_credentials_list'),
|
||||
|
||||
@ -1419,6 +1419,88 @@ class CredentialCopy(CopyAPIView):
|
||||
copy_return_serializer_class = serializers.CredentialSerializer
|
||||
|
||||
|
||||
class CredentialExternalTest(SubDetailAPIView):
|
||||
"""
|
||||
Test updates to the input values and metadata of an external credential
|
||||
before saving them.
|
||||
"""
|
||||
|
||||
view_name = _('External Credential Test')
|
||||
|
||||
model = models.Credential
|
||||
serializer_class = serializers.EmptySerializer
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
obj = self.get_object()
|
||||
backend_kwargs = {}
|
||||
for field_name, value in obj.inputs.items():
|
||||
backend_kwargs[field_name] = obj.get_input(field_name)
|
||||
for field_name, value in request.data.get('inputs', {}).items():
|
||||
if value != '$encrypted$':
|
||||
backend_kwargs[field_name] = value
|
||||
backend_kwargs.update(request.data.get('metadata', {}))
|
||||
try:
|
||||
obj.credential_type.plugin.backend(**backend_kwargs)
|
||||
return Response({}, status=status.HTTP_202_ACCEPTED)
|
||||
except requests.exceptions.HTTPError as exc:
|
||||
message = 'HTTP {}\n{}'.format(exc.response.status_code, exc.response.text)
|
||||
return Response({'inputs': message}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except Exception as exc:
|
||||
return Response({'inputs': str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class CredentialInputSourceDetail(RetrieveUpdateDestroyAPIView):
|
||||
|
||||
view_name = _("Credential Input Source Detail")
|
||||
|
||||
model = models.CredentialInputSource
|
||||
serializer_class = serializers.CredentialInputSourceSerializer
|
||||
|
||||
|
||||
class CredentialInputSourceList(ListCreateAPIView):
|
||||
|
||||
view_name = _("Credential Input Sources")
|
||||
|
||||
model = models.CredentialInputSource
|
||||
serializer_class = serializers.CredentialInputSourceSerializer
|
||||
|
||||
|
||||
class CredentialInputSourceSubList(SubListCreateAPIView):
|
||||
|
||||
view_name = _("Credential Input Sources")
|
||||
|
||||
model = models.CredentialInputSource
|
||||
serializer_class = serializers.CredentialInputSourceSerializer
|
||||
parent_model = models.Credential
|
||||
relationship = 'input_sources'
|
||||
parent_key = 'target_credential'
|
||||
|
||||
|
||||
class CredentialTypeExternalTest(SubDetailAPIView):
|
||||
"""
|
||||
Test a complete set of input values for an external credential before
|
||||
saving it.
|
||||
"""
|
||||
|
||||
view_name = _('External Credential Type Test')
|
||||
|
||||
model = models.CredentialType
|
||||
serializer_class = serializers.EmptySerializer
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
obj = self.get_object()
|
||||
backend_kwargs = request.data.get('inputs', {})
|
||||
backend_kwargs.update(request.data.get('metadata', {}))
|
||||
try:
|
||||
obj.plugin.backend(**backend_kwargs)
|
||||
return Response({}, status=status.HTTP_202_ACCEPTED)
|
||||
except requests.exceptions.HTTPError as exc:
|
||||
message = 'HTTP {}\n{}'.format(exc.response.status_code, exc.response.text)
|
||||
return Response({'inputs': message}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except Exception as exc:
|
||||
return Response({'inputs': str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class HostRelatedSearchMixin(object):
|
||||
|
||||
@property
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -30,8 +30,8 @@ from awx.main.utils import (
|
||||
)
|
||||
from awx.main.models import (
|
||||
ActivityStream, AdHocCommand, AdHocCommandEvent, Credential, CredentialType,
|
||||
CustomInventoryScript, Group, Host, Instance, InstanceGroup, Inventory,
|
||||
InventorySource, InventoryUpdate, InventoryUpdateEvent, Job, JobEvent,
|
||||
CredentialInputSource, CustomInventoryScript, Group, Host, Instance, InstanceGroup,
|
||||
Inventory, InventorySource, InventoryUpdate, InventoryUpdateEvent, Job, JobEvent,
|
||||
JobHostSummary, JobLaunchConfig, JobTemplate, Label, Notification,
|
||||
NotificationTemplate, Organization, Project, ProjectUpdate,
|
||||
ProjectUpdateEvent, Role, Schedule, SystemJob, SystemJobEvent,
|
||||
@ -426,7 +426,7 @@ class BaseAccess(object):
|
||||
if display_method == 'schedule':
|
||||
user_capabilities['schedule'] = user_capabilities['start']
|
||||
continue
|
||||
elif display_method == 'delete' and not isinstance(obj, (User, UnifiedJob, CustomInventoryScript)):
|
||||
elif display_method == 'delete' and not isinstance(obj, (User, UnifiedJob, CustomInventoryScript, CredentialInputSource)):
|
||||
user_capabilities['delete'] = user_capabilities['edit']
|
||||
continue
|
||||
elif display_method == 'copy' and isinstance(obj, (Group, Host)):
|
||||
@ -1162,6 +1162,55 @@ class CredentialAccess(BaseAccess):
|
||||
# return True
|
||||
return self.can_change(obj, None)
|
||||
|
||||
def get_user_capabilities(self, obj, **kwargs):
|
||||
user_capabilities = super(CredentialAccess, self).get_user_capabilities(obj, **kwargs)
|
||||
user_capabilities['use'] = self.can_use(obj)
|
||||
return user_capabilities
|
||||
|
||||
|
||||
class CredentialInputSourceAccess(BaseAccess):
|
||||
'''
|
||||
I can see a CredentialInputSource when:
|
||||
- I can see the associated target_credential
|
||||
I can create/change a CredentialInputSource when:
|
||||
- I'm an admin of the associated target_credential
|
||||
- I have use access to the associated source credential
|
||||
I can delete a CredentialInputSource when:
|
||||
- I'm an admin of the associated target_credential
|
||||
'''
|
||||
|
||||
model = CredentialInputSource
|
||||
select_related = ('target_credential', 'source_credential')
|
||||
|
||||
def filtered_queryset(self):
|
||||
return CredentialInputSource.objects.filter(
|
||||
target_credential__in=Credential.accessible_pk_qs(self.user, 'read_role'))
|
||||
|
||||
@check_superuser
|
||||
def can_read(self, obj):
|
||||
return self.user in obj.target_credential.read_role
|
||||
|
||||
@check_superuser
|
||||
def can_add(self, data):
|
||||
return (
|
||||
self.check_related('target_credential', Credential, data, role_field='admin_role') and
|
||||
self.check_related('source_credential', Credential, data, role_field='use_role')
|
||||
)
|
||||
|
||||
@check_superuser
|
||||
def can_change(self, obj, data):
|
||||
if self.can_add(data) is False:
|
||||
return False
|
||||
|
||||
return (
|
||||
self.user in obj.target_credential.admin_role and
|
||||
self.user in obj.source_credential.use_role
|
||||
)
|
||||
|
||||
@check_superuser
|
||||
def can_delete(self, obj):
|
||||
return self.user in obj.target_credential.admin_role
|
||||
|
||||
|
||||
class TeamAccess(BaseAccess):
|
||||
'''
|
||||
|
||||
0
awx/main/credential_plugins/__init__.py
Normal file
0
awx/main/credential_plugins/__init__.py
Normal file
124
awx/main/credential_plugins/aim.py
Normal file
124
awx/main/credential_plugins/aim.py
Normal file
@ -0,0 +1,124 @@
|
||||
from .plugin import CredentialPlugin
|
||||
|
||||
import os
|
||||
import stat
|
||||
import tempfile
|
||||
import threading
|
||||
from urllib.parse import quote, urlencode, urljoin
|
||||
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
import requests
|
||||
|
||||
|
||||
aim_inputs = {
|
||||
'fields': [{
|
||||
'id': 'url',
|
||||
'label': _('CyberArk AIM URL'),
|
||||
'type': 'string',
|
||||
}, {
|
||||
'id': 'app_id',
|
||||
'label': _('Application ID'),
|
||||
'type': 'string',
|
||||
'secret': True,
|
||||
}, {
|
||||
'id': 'client_key',
|
||||
'label': _('Client Key'),
|
||||
'type': 'string',
|
||||
'secret': True,
|
||||
'multiline': True,
|
||||
}, {
|
||||
'id': 'client_cert',
|
||||
'label': _('Client Certificate'),
|
||||
'type': 'string',
|
||||
'secret': True,
|
||||
'multiline': True,
|
||||
}, {
|
||||
'id': 'verify',
|
||||
'label': _('Verify SSL Certificates'),
|
||||
'type': 'boolean',
|
||||
'default': True,
|
||||
}],
|
||||
'metadata': [{
|
||||
'id': 'object_query',
|
||||
'label': _('Object Query'),
|
||||
'type': 'string',
|
||||
'help_text': _('Lookup query for the object. Ex: "Safe=TestSafe;Object=testAccountName123"'),
|
||||
}, {
|
||||
'id': 'object_query_format',
|
||||
'label': _('Object Query Format'),
|
||||
'type': 'string',
|
||||
'default': 'Exact',
|
||||
'choices': ['Exact', 'Regexp']
|
||||
}, {
|
||||
'id': 'reason',
|
||||
'label': _('Reason'),
|
||||
'type': 'string',
|
||||
'help_text': _('Object request reason. This is only needed if it is required by the object\'s policy.')
|
||||
}],
|
||||
'required': ['url', 'app_id', 'object_query'],
|
||||
}
|
||||
|
||||
|
||||
def create_temporary_fifo(data):
|
||||
"""Open fifo named pipe in a new thread using a temporary file path. The
|
||||
thread blocks until data is read from the pipe.
|
||||
|
||||
Returns the path to the fifo.
|
||||
|
||||
:param data(bytes): Data to write to the pipe.
|
||||
"""
|
||||
path = os.path.join(tempfile.mkdtemp(), next(tempfile._get_candidate_names()))
|
||||
os.mkfifo(path, stat.S_IRUSR | stat.S_IWUSR)
|
||||
|
||||
threading.Thread(
|
||||
target=lambda p, d: open(p, 'wb').write(d),
|
||||
args=(path, data)
|
||||
).start()
|
||||
return path
|
||||
|
||||
|
||||
def aim_backend(**kwargs):
|
||||
url = kwargs['url']
|
||||
client_cert = kwargs.get('client_cert', None)
|
||||
client_key = kwargs.get('client_key', None)
|
||||
verify = kwargs['verify']
|
||||
app_id = kwargs['app_id']
|
||||
object_query = kwargs['object_query']
|
||||
object_query_format = kwargs['object_query_format']
|
||||
reason = kwargs.get('reason', None)
|
||||
|
||||
query_params = {
|
||||
'AppId': app_id,
|
||||
'Query': object_query,
|
||||
'QueryFormat': object_query_format,
|
||||
}
|
||||
if reason:
|
||||
query_params['reason'] = reason
|
||||
|
||||
request_qs = '?' + urlencode(query_params, quote_via=quote)
|
||||
request_url = urljoin(url, '/'.join(['AIMWebService', 'api', 'Accounts']))
|
||||
|
||||
cert = None
|
||||
if client_cert and client_key:
|
||||
cert = (
|
||||
create_temporary_fifo(client_cert.encode()),
|
||||
create_temporary_fifo(client_key.encode())
|
||||
)
|
||||
elif client_cert:
|
||||
cert = create_temporary_fifo(client_cert.encode())
|
||||
|
||||
res = requests.get(
|
||||
request_url + request_qs,
|
||||
timeout=30,
|
||||
cert=cert,
|
||||
verify=verify,
|
||||
)
|
||||
res.raise_for_status()
|
||||
return res.json()['Content']
|
||||
|
||||
|
||||
aim_plugin = CredentialPlugin(
|
||||
'CyberArk AIM Secret Lookup',
|
||||
inputs=aim_inputs,
|
||||
backend=aim_backend
|
||||
)
|
||||
64
awx/main/credential_plugins/azure_kv.py
Normal file
64
awx/main/credential_plugins/azure_kv.py
Normal file
@ -0,0 +1,64 @@
|
||||
from .plugin import CredentialPlugin
|
||||
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from azure.keyvault import KeyVaultClient, KeyVaultAuthentication
|
||||
from azure.common.credentials import ServicePrincipalCredentials
|
||||
|
||||
|
||||
azure_keyvault_inputs = {
|
||||
'fields': [{
|
||||
'id': 'url',
|
||||
'label': _('Vault URL (DNS Name)'),
|
||||
'type': 'string',
|
||||
}, {
|
||||
'id': 'client',
|
||||
'label': _('Client ID'),
|
||||
'type': 'string'
|
||||
}, {
|
||||
'id': 'secret',
|
||||
'label': _('Client Secret'),
|
||||
'type': 'string',
|
||||
'secret': True,
|
||||
}, {
|
||||
'id': 'tenant',
|
||||
'label': _('Tenant ID'),
|
||||
'type': 'string'
|
||||
}],
|
||||
'metadata': [{
|
||||
'id': 'secret_field',
|
||||
'label': _('Secret Name'),
|
||||
'type': 'string',
|
||||
'help_text': _('The name of the secret to look up.'),
|
||||
}, {
|
||||
'id': 'secret_version',
|
||||
'label': _('Secret Version'),
|
||||
'type': 'string',
|
||||
'help_text': _('Used to specify a specific secret version (if left empty, the latest version will be used).'),
|
||||
}],
|
||||
'required': ['url', 'client', 'secret', 'tenant', 'secret_field'],
|
||||
}
|
||||
|
||||
|
||||
def azure_keyvault_backend(**kwargs):
|
||||
url = kwargs['url']
|
||||
|
||||
def auth_callback(server, resource, scope):
|
||||
credentials = ServicePrincipalCredentials(
|
||||
url = url,
|
||||
client_id = kwargs['client'],
|
||||
secret = kwargs['secret'],
|
||||
tenant = kwargs['tenant'],
|
||||
resource = "https://vault.azure.net",
|
||||
)
|
||||
token = credentials.token
|
||||
return token['token_type'], token['access_token']
|
||||
|
||||
kv = KeyVaultClient(KeyVaultAuthentication(auth_callback))
|
||||
return kv.get_secret(url, kwargs['secret_field'], kwargs.get('secret_version', '')).value
|
||||
|
||||
|
||||
azure_keyvault_plugin = CredentialPlugin(
|
||||
'Microsoft Azure Key Vault',
|
||||
inputs=azure_keyvault_inputs,
|
||||
backend=azure_keyvault_backend
|
||||
)
|
||||
120
awx/main/credential_plugins/conjur.py
Normal file
120
awx/main/credential_plugins/conjur.py
Normal file
@ -0,0 +1,120 @@
|
||||
from .plugin import CredentialPlugin
|
||||
|
||||
import base64
|
||||
import os
|
||||
import stat
|
||||
import tempfile
|
||||
import threading
|
||||
from urllib.parse import urljoin, quote_plus
|
||||
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
import requests
|
||||
|
||||
|
||||
conjur_inputs = {
|
||||
'fields': [{
|
||||
'id': 'url',
|
||||
'label': _('Conjur URL'),
|
||||
'type': 'string',
|
||||
}, {
|
||||
'id': 'api_key',
|
||||
'label': _('API Key'),
|
||||
'type': 'string',
|
||||
'secret': True,
|
||||
}, {
|
||||
'id': 'account',
|
||||
'label': _('Account'),
|
||||
'type': 'string',
|
||||
}, {
|
||||
'id': 'username',
|
||||
'label': _('Username'),
|
||||
'type': 'string',
|
||||
}, {
|
||||
'id': 'cacert',
|
||||
'label': _('Public Key Certificate'),
|
||||
'type': 'string',
|
||||
'multiline': True
|
||||
}],
|
||||
'metadata': [{
|
||||
'id': 'secret_path',
|
||||
'label': _('Secret Identifier'),
|
||||
'type': 'string',
|
||||
'help_text': _('The identifier for the secret e.g., /some/identifier'),
|
||||
}, {
|
||||
'id': 'secret_version',
|
||||
'label': _('Secret Version'),
|
||||
'type': 'string',
|
||||
'help_text': _('Used to specify a specific secret version (if left empty, the latest version will be used).'),
|
||||
}],
|
||||
'required': ['url', 'api_key', 'account', 'username'],
|
||||
}
|
||||
|
||||
|
||||
def create_temporary_fifo(data):
|
||||
"""Open fifo named pipe in a new thread using a temporary file path. The
|
||||
thread blocks until data is read from the pipe.
|
||||
|
||||
Returns the path to the fifo.
|
||||
|
||||
:param data(bytes): Data to write to the pipe.
|
||||
"""
|
||||
path = os.path.join(tempfile.mkdtemp(), next(tempfile._get_candidate_names()))
|
||||
os.mkfifo(path, stat.S_IRUSR | stat.S_IWUSR)
|
||||
|
||||
threading.Thread(
|
||||
target=lambda p, d: open(p, 'wb').write(d),
|
||||
args=(path, data)
|
||||
).start()
|
||||
return path
|
||||
|
||||
|
||||
def conjur_backend(**kwargs):
|
||||
url = kwargs['url']
|
||||
api_key = kwargs['api_key']
|
||||
account = quote_plus(kwargs['account'])
|
||||
username = quote_plus(kwargs['username'])
|
||||
secret_path = quote_plus(kwargs['secret_path'])
|
||||
version = kwargs.get('secret_version')
|
||||
cacert = kwargs.get('cacert', None)
|
||||
|
||||
auth_kwargs = {
|
||||
'headers': {'Content-Type': 'text/plain'},
|
||||
'data': api_key
|
||||
}
|
||||
if cacert:
|
||||
auth_kwargs['verify'] = create_temporary_fifo(cacert.encode())
|
||||
|
||||
# https://www.conjur.org/api.html#authentication-authenticate-post
|
||||
resp = requests.post(
|
||||
urljoin(url, '/'.join(['authn', account, username, 'authenticate'])),
|
||||
**auth_kwargs
|
||||
)
|
||||
resp.raise_for_status()
|
||||
token = base64.b64encode(resp.content).decode('utf-8')
|
||||
|
||||
lookup_kwargs = {
|
||||
'headers': {'Authorization': 'Token token="{}"'.format(token)},
|
||||
}
|
||||
if cacert:
|
||||
lookup_kwargs['verify'] = create_temporary_fifo(cacert.encode())
|
||||
|
||||
# https://www.conjur.org/api.html#secrets-retrieve-a-secret-get
|
||||
path = urljoin(url, '/'.join([
|
||||
'secrets',
|
||||
account,
|
||||
'variable',
|
||||
secret_path
|
||||
]))
|
||||
if version:
|
||||
path = '?'.join([path, version])
|
||||
|
||||
resp = requests.get(path, timeout=30, **lookup_kwargs)
|
||||
resp.raise_for_status()
|
||||
return resp.text
|
||||
|
||||
|
||||
conjur_plugin = CredentialPlugin(
|
||||
'CyberArk Conjur Secret Lookup',
|
||||
inputs=conjur_inputs,
|
||||
backend=conjur_backend
|
||||
)
|
||||
151
awx/main/credential_plugins/hashivault.py
Normal file
151
awx/main/credential_plugins/hashivault.py
Normal file
@ -0,0 +1,151 @@
|
||||
import copy
|
||||
import os
|
||||
import pathlib
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from .plugin import CredentialPlugin
|
||||
|
||||
import requests
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
|
||||
base_inputs = {
|
||||
'fields': [{
|
||||
'id': 'url',
|
||||
'label': _('Server URL'),
|
||||
'type': 'string',
|
||||
'help_text': _('The URL to the HashiCorp Vault'),
|
||||
}, {
|
||||
'id': 'token',
|
||||
'label': _('Token'),
|
||||
'type': 'string',
|
||||
'secret': True,
|
||||
'help_text': _('The access token used to authenticate to the Vault server'),
|
||||
}],
|
||||
'metadata': [{
|
||||
'id': 'secret_path',
|
||||
'label': _('Path to Secret'),
|
||||
'type': 'string',
|
||||
'help_text': _('The path to the secret e.g., /some-engine/some-secret/'),
|
||||
}],
|
||||
'required': ['url', 'token', 'secret_path'],
|
||||
}
|
||||
|
||||
hashi_kv_inputs = copy.deepcopy(base_inputs)
|
||||
hashi_kv_inputs['fields'].append({
|
||||
'id': 'api_version',
|
||||
'label': _('API Version'),
|
||||
'choices': ['v1', 'v2'],
|
||||
'help_text': _('API v1 is for static key/value lookups. API v2 is for versioned key/value lookups.'),
|
||||
'default': 'v1',
|
||||
})
|
||||
hashi_kv_inputs['metadata'].extend([{
|
||||
'id': 'secret_key',
|
||||
'label': _('Key Name'),
|
||||
'type': 'string',
|
||||
'help_text': _('The name of the key to look up in the secret.'),
|
||||
}, {
|
||||
'id': 'secret_version',
|
||||
'label': _('Secret Version (v2 only)'),
|
||||
'type': 'string',
|
||||
'help_text': _('Used to specify a specific secret version (if left empty, the latest version will be used).'),
|
||||
}])
|
||||
hashi_kv_inputs['required'].extend(['api_version', 'secret_key'])
|
||||
|
||||
hashi_ssh_inputs = copy.deepcopy(base_inputs)
|
||||
hashi_ssh_inputs['metadata'] = [{
|
||||
'id': 'public_key',
|
||||
'label': _('Unsigned Public Key'),
|
||||
'type': 'string',
|
||||
'multiline': True,
|
||||
}] + hashi_ssh_inputs['metadata'] + [{
|
||||
'id': 'role',
|
||||
'label': _('Role Name'),
|
||||
'type': 'string',
|
||||
'help_text': _('The name of the role used to sign.')
|
||||
}, {
|
||||
'id': 'valid_principals',
|
||||
'label': _('Valid Principals'),
|
||||
'type': 'string',
|
||||
'help_text': _('Valid principals (either usernames or hostnames) that the certificate should be signed for.'),
|
||||
}]
|
||||
hashi_ssh_inputs['required'].extend(['public_key', 'role'])
|
||||
|
||||
|
||||
def kv_backend(**kwargs):
|
||||
token = kwargs['token']
|
||||
url = urljoin(kwargs['url'], 'v1')
|
||||
secret_path = kwargs['secret_path']
|
||||
secret_key = kwargs.get('secret_key', None)
|
||||
|
||||
api_version = kwargs['api_version']
|
||||
|
||||
sess = requests.Session()
|
||||
sess.headers['Authorization'] = 'Bearer {}'.format(token)
|
||||
if api_version == 'v2':
|
||||
params = {}
|
||||
if kwargs.get('secret_version'):
|
||||
params['version'] = kwargs['secret_version']
|
||||
try:
|
||||
mount_point, *path = pathlib.Path(secret_path.lstrip(os.sep)).parts
|
||||
'/'.join(*path)
|
||||
except Exception:
|
||||
mount_point, path = secret_path, []
|
||||
# https://www.vaultproject.io/api/secret/kv/kv-v2.html#read-secret-version
|
||||
response = sess.get(
|
||||
'/'.join([url, mount_point, 'data'] + path).rstrip('/'),
|
||||
params=params,
|
||||
timeout=30
|
||||
)
|
||||
response.raise_for_status()
|
||||
json = response.json()['data']
|
||||
else:
|
||||
# https://www.vaultproject.io/api/secret/kv/kv-v1.html#read-secret
|
||||
response = sess.get('/'.join([url, secret_path]).rstrip('/'), timeout=30)
|
||||
response.raise_for_status()
|
||||
json = response.json()
|
||||
|
||||
if secret_key:
|
||||
try:
|
||||
return json['data'][secret_key]
|
||||
except KeyError:
|
||||
raise RuntimeError(
|
||||
'{} is not present at {}'.format(secret_key, secret_path)
|
||||
)
|
||||
return json['data']
|
||||
|
||||
|
||||
def ssh_backend(**kwargs):
|
||||
token = kwargs['token']
|
||||
url = urljoin(kwargs['url'], 'v1')
|
||||
secret_path = kwargs['secret_path']
|
||||
role = kwargs['role']
|
||||
|
||||
sess = requests.Session()
|
||||
sess.headers['Authorization'] = 'Bearer {}'.format(token)
|
||||
json = {
|
||||
'public_key': kwargs['public_key']
|
||||
}
|
||||
if kwargs.get('valid_principals'):
|
||||
json['valid_principals'] = kwargs['valid_principals']
|
||||
# https://www.vaultproject.io/api/secret/ssh/index.html#sign-ssh-key
|
||||
resp = sess.post(
|
||||
'/'.join([url, secret_path, 'sign', role]).rstrip('/'),
|
||||
json=json,
|
||||
timeout=30
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()['data']['signed_key']
|
||||
|
||||
|
||||
hashivault_kv_plugin = CredentialPlugin(
|
||||
'HashiCorp Vault Secret Lookup',
|
||||
inputs=hashi_kv_inputs,
|
||||
backend=kv_backend
|
||||
)
|
||||
|
||||
hashivault_ssh_plugin = CredentialPlugin(
|
||||
'HashiCorp Vault Signed SSH',
|
||||
inputs=hashi_ssh_inputs,
|
||||
backend=ssh_backend
|
||||
)
|
||||
3
awx/main/credential_plugins/plugin.py
Normal file
3
awx/main/credential_plugins/plugin.py
Normal file
@ -0,0 +1,3 @@
|
||||
from collections import namedtuple
|
||||
|
||||
CredentialPlugin = namedtuple('CredentialPlugin', ['name', 'inputs', 'backend'])
|
||||
@ -480,6 +480,69 @@ def format_ssh_private_key(value):
|
||||
return True
|
||||
|
||||
|
||||
class DynamicCredentialInputField(JSONSchemaField):
|
||||
"""
|
||||
Used to validate JSON for
|
||||
`awx.main.models.credential:CredentialInputSource().metadata`.
|
||||
|
||||
Metadata for input sources is represented as a dictionary e.g.,
|
||||
{'secret_path': '/kv/somebody', 'secret_key': 'password'}
|
||||
|
||||
For the data to be valid, the keys of this dictionary should correspond
|
||||
with the metadata field (and datatypes) defined in the associated
|
||||
target CredentialType e.g.,
|
||||
"""
|
||||
|
||||
def schema(self, credential_type):
|
||||
# determine the defined fields for the associated credential type
|
||||
properties = {}
|
||||
for field in credential_type.inputs.get('metadata', []):
|
||||
field = field.copy()
|
||||
properties[field['id']] = field
|
||||
if field.get('choices', []):
|
||||
field['enum'] = list(field['choices'])[:]
|
||||
return {
|
||||
'type': 'object',
|
||||
'properties': properties,
|
||||
'additionalProperties': False,
|
||||
}
|
||||
|
||||
def validate(self, value, model_instance):
|
||||
if not isinstance(value, dict):
|
||||
return super(DynamicCredentialInputField, self).validate(value, model_instance)
|
||||
|
||||
super(JSONSchemaField, self).validate(value, model_instance)
|
||||
credential_type = model_instance.source_credential.credential_type
|
||||
errors = {}
|
||||
for error in Draft4Validator(
|
||||
self.schema(credential_type),
|
||||
format_checker=self.format_checker
|
||||
).iter_errors(value):
|
||||
if error.validator == 'pattern' and 'error' in error.schema:
|
||||
error.message = error.schema['error'].format(instance=error.instance)
|
||||
if 'id' not in error.schema:
|
||||
# If the error is not for a specific field, it's specific to
|
||||
# `inputs` in general
|
||||
raise django_exceptions.ValidationError(
|
||||
error.message,
|
||||
code='invalid',
|
||||
params={'value': value},
|
||||
)
|
||||
errors[error.schema['id']] = [error.message]
|
||||
|
||||
defined_metadata = [field.get('id') for field in credential_type.inputs.get('metadata', [])]
|
||||
for field in credential_type.inputs.get('required', []):
|
||||
if field in defined_metadata and not value.get(field, None):
|
||||
errors[field] = [_('required for %s') % (
|
||||
credential_type.name
|
||||
)]
|
||||
|
||||
if errors:
|
||||
raise serializers.ValidationError({
|
||||
'metadata': errors
|
||||
})
|
||||
|
||||
|
||||
class CredentialInputField(JSONSchemaField):
|
||||
"""
|
||||
Used to validate JSON for
|
||||
@ -592,18 +655,13 @@ class CredentialInputField(JSONSchemaField):
|
||||
)
|
||||
errors[error.schema['id']] = [error.message]
|
||||
|
||||
inputs = model_instance.credential_type.inputs
|
||||
for field in inputs.get('required', []):
|
||||
if not value.get(field, None):
|
||||
errors[field] = [_('required for %s') % (
|
||||
model_instance.credential_type.name
|
||||
)]
|
||||
defined_fields = model_instance.credential_type.defined_fields
|
||||
|
||||
# `ssh_key_unlock` requirements are very specific and can't be
|
||||
# represented without complicated JSON schema
|
||||
if (
|
||||
model_instance.credential_type.managed_by_tower is True and
|
||||
'ssh_key_unlock' in model_instance.credential_type.defined_fields
|
||||
'ssh_key_unlock' in defined_fields
|
||||
):
|
||||
|
||||
# in order to properly test the necessity of `ssh_key_unlock`, we
|
||||
|
||||
@ -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()
|
||||
53
awx/main/migrations/0067_v350_credential_plugins.py
Normal file
53
awx/main/migrations/0067_v350_credential_plugins.py
Normal file
@ -0,0 +1,53 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import taggit.managers
|
||||
|
||||
# AWX
|
||||
import awx.main.fields
|
||||
from awx.main.models import CredentialType
|
||||
|
||||
|
||||
def setup_tower_managed_defaults(apps, schema_editor):
|
||||
CredentialType.setup_tower_managed_defaults()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('taggit', '0002_auto_20150616_2121'),
|
||||
('main', '0066_v350_inventorysource_custom_virtualenv'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='CredentialInputSource',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created', models.DateTimeField(default=None, editable=False)),
|
||||
('modified', models.DateTimeField(default=None, editable=False)),
|
||||
('description', models.TextField(blank=True, default='')),
|
||||
('input_field_name', models.CharField(max_length=1024)),
|
||||
('metadata', awx.main.fields.DynamicCredentialInputField(blank=True, default={})),
|
||||
('created_by', models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{'class': 'credentialinputsource', 'model_name': 'credentialinputsource', 'app_label': 'main'}(class)s_created+", to=settings.AUTH_USER_MODEL)),
|
||||
('modified_by', models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{'class': 'credentialinputsource', 'model_name': 'credentialinputsource', 'app_label': 'main'}(class)s_modified+", to=settings.AUTH_USER_MODEL)),
|
||||
('source_credential', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='target_input_sources', to='main.Credential')),
|
||||
('tags', taggit.managers.TaggableManager(blank=True, help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags')),
|
||||
('target_credential', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='input_sources', to='main.Credential')),
|
||||
],
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='credentialtype',
|
||||
name='kind',
|
||||
field=models.CharField(choices=[('ssh', 'Machine'), ('vault', 'Vault'), ('net', 'Network'), ('scm', 'Source Control'), ('cloud', 'Cloud'), ('insights', 'Insights'), ('external', 'External')], max_length=32),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='credentialinputsource',
|
||||
unique_together=set([('target_credential', 'input_field_name')]),
|
||||
),
|
||||
migrations.RunPython(setup_tower_managed_defaults),
|
||||
]
|
||||
@ -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
|
||||
|
||||
@ -4,6 +4,7 @@ import functools
|
||||
import inspect
|
||||
import logging
|
||||
import os
|
||||
from pkg_resources import iter_entry_points
|
||||
import re
|
||||
import stat
|
||||
import tempfile
|
||||
@ -17,16 +18,22 @@ from django.db import models
|
||||
from django.utils.translation import ugettext_lazy as _, ugettext_noop
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.encoding import force_text
|
||||
from django.utils.functional import cached_property
|
||||
|
||||
# AWX
|
||||
from awx.api.versioning import reverse
|
||||
from awx.main.fields import (ImplicitRoleField, CredentialInputField,
|
||||
CredentialTypeInputField,
|
||||
CredentialTypeInjectorField)
|
||||
CredentialTypeInjectorField,
|
||||
DynamicCredentialInputField,)
|
||||
from awx.main.utils import decrypt_field, classproperty
|
||||
from awx.main.utils.safe_yaml import safe_dump
|
||||
from awx.main.validators import validate_ssh_private_key
|
||||
from awx.main.models.base import CommonModelNameNotUnique, PasswordFieldsModel
|
||||
from awx.main.models.base import (
|
||||
CommonModelNameNotUnique,
|
||||
PasswordFieldsModel,
|
||||
PrimordialModel
|
||||
)
|
||||
from awx.main.models.mixins import ResourceMixin
|
||||
from awx.main.models.rbac import (
|
||||
ROLE_SINGLETON_SYSTEM_ADMINISTRATOR,
|
||||
@ -35,9 +42,13 @@ from awx.main.models.rbac import (
|
||||
from awx.main.utils import encrypt_field
|
||||
from . import injectors as builtin_injectors
|
||||
|
||||
__all__ = ['Credential', 'CredentialType', 'V1Credential', 'build_safe_env']
|
||||
__all__ = ['Credential', 'CredentialType', 'CredentialInputSource', 'V1Credential', 'build_safe_env']
|
||||
|
||||
logger = logging.getLogger('awx.main.models.credential')
|
||||
credential_plugins = dict(
|
||||
(ep.name, ep.load())
|
||||
for ep in iter_entry_points('awx.credential_plugins')
|
||||
)
|
||||
|
||||
HIDDEN_PASSWORD = '**********'
|
||||
|
||||
@ -220,7 +231,6 @@ class V1Credential(object):
|
||||
}
|
||||
|
||||
|
||||
|
||||
class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin):
|
||||
'''
|
||||
A credential contains information about how to talk to a remote resource
|
||||
@ -364,6 +374,10 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin):
|
||||
needed.append('vault_password')
|
||||
return needed
|
||||
|
||||
@cached_property
|
||||
def dynamic_input_fields(self):
|
||||
return [obj.input_field_name for obj in self.input_sources.all()]
|
||||
|
||||
def _password_field_allows_ask(self, field):
|
||||
return field in self.credential_type.askable_fields
|
||||
|
||||
@ -441,6 +455,8 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin):
|
||||
:param field_name(str): The name of the input field.
|
||||
:param default(optional[str]): A default return value to use.
|
||||
"""
|
||||
if self.kind != 'external' and field_name in self.dynamic_input_fields:
|
||||
return self._get_dynamic_input(field_name)
|
||||
if field_name in self.credential_type.secret_fields:
|
||||
try:
|
||||
return decrypt_field(self, field_name)
|
||||
@ -461,8 +477,17 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin):
|
||||
raise AttributeError(field_name)
|
||||
|
||||
def has_input(self, field_name):
|
||||
if field_name in self.dynamic_input_fields:
|
||||
return True
|
||||
return field_name in self.inputs and self.inputs[field_name] not in ('', None)
|
||||
|
||||
def _get_dynamic_input(self, field_name):
|
||||
for input_source in self.input_sources.all():
|
||||
if input_source.input_field_name == field_name:
|
||||
return input_source.get_input_value()
|
||||
else:
|
||||
raise ValueError('{} is not a dynamic input field'.format(field_name))
|
||||
|
||||
|
||||
class CredentialType(CommonModelNameNotUnique):
|
||||
'''
|
||||
@ -484,6 +509,7 @@ class CredentialType(CommonModelNameNotUnique):
|
||||
('scm', _('Source Control')),
|
||||
('cloud', _('Cloud')),
|
||||
('insights', _('Insights')),
|
||||
('external', _('External')),
|
||||
)
|
||||
|
||||
kind = models.CharField(
|
||||
@ -552,6 +578,16 @@ class CredentialType(CommonModelNameNotUnique):
|
||||
if field.get('ask_at_runtime', False) is True
|
||||
]
|
||||
|
||||
@property
|
||||
def plugin(self):
|
||||
if self.kind != 'external':
|
||||
raise AttributeError('plugin')
|
||||
[plugin] = [
|
||||
plugin for ns, plugin in credential_plugins.items()
|
||||
if ns == self.namespace
|
||||
]
|
||||
return plugin
|
||||
|
||||
def default_for_field(self, field_id):
|
||||
for field in self.inputs.get('fields', []):
|
||||
if field['id'] == field_id:
|
||||
@ -583,6 +619,15 @@ class CredentialType(CommonModelNameNotUnique):
|
||||
created.inputs = created.injectors = {}
|
||||
created.save()
|
||||
|
||||
@classmethod
|
||||
def load_plugin(cls, ns, plugin):
|
||||
ManagedCredentialType(
|
||||
namespace=ns,
|
||||
name=plugin.name,
|
||||
kind='external',
|
||||
inputs=plugin.inputs
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_v1_kind(cls, kind, data={}):
|
||||
match = None
|
||||
@ -653,15 +698,15 @@ class CredentialType(CommonModelNameNotUnique):
|
||||
# build a normal namespace with secret values decrypted (for
|
||||
# ansible-playbook) and a safe namespace with secret values hidden (for
|
||||
# DB storage)
|
||||
for field_name, value in credential.inputs.items():
|
||||
injectable_fields = list(credential.inputs.keys()) + credential.dynamic_input_fields
|
||||
for field_name in list(set(injectable_fields)):
|
||||
value = credential.get_input(field_name)
|
||||
|
||||
if type(value) is bool:
|
||||
# boolean values can't be secret/encrypted
|
||||
# boolean values can't be secret/encrypted/external
|
||||
safe_namespace[field_name] = namespace[field_name] = value
|
||||
continue
|
||||
|
||||
value = credential.get_input(field_name)
|
||||
|
||||
if field_name in self.secret_fields:
|
||||
safe_namespace[field_name] = '**********'
|
||||
elif len(value):
|
||||
@ -774,6 +819,12 @@ ManagedCredentialType(
|
||||
'format': 'ssh_private_key',
|
||||
'secret': True,
|
||||
'multiline': True
|
||||
}, {
|
||||
'id': 'ssh_public_key_data',
|
||||
'label': ugettext_noop('Signed SSH Certificate'),
|
||||
'type': 'string',
|
||||
'multiline': True,
|
||||
'secret': True,
|
||||
}, {
|
||||
'id': 'ssh_key_unlock',
|
||||
'label': ugettext_noop('Private Key Passphrase'),
|
||||
@ -1253,3 +1304,70 @@ ManagedCredentialType(
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class CredentialInputSource(PrimordialModel):
|
||||
|
||||
class Meta:
|
||||
app_label = 'main'
|
||||
unique_together = (('target_credential', 'input_field_name'),)
|
||||
|
||||
target_credential = models.ForeignKey(
|
||||
'Credential',
|
||||
related_name='input_sources',
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
)
|
||||
source_credential = models.ForeignKey(
|
||||
'Credential',
|
||||
related_name='target_input_sources',
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
)
|
||||
input_field_name = models.CharField(
|
||||
max_length=1024,
|
||||
)
|
||||
metadata = DynamicCredentialInputField(
|
||||
blank=True,
|
||||
default={}
|
||||
)
|
||||
|
||||
def clean_target_credential(self):
|
||||
if self.target_credential.kind == 'external':
|
||||
raise ValidationError(_('Target must be a non-external credential'))
|
||||
return self.target_credential
|
||||
|
||||
def clean_source_credential(self):
|
||||
if self.source_credential.kind != 'external':
|
||||
raise ValidationError(_('Source must be an external credential'))
|
||||
return self.source_credential
|
||||
|
||||
def clean_input_field_name(self):
|
||||
defined_fields = self.target_credential.credential_type.defined_fields
|
||||
if self.input_field_name not in defined_fields:
|
||||
raise ValidationError(_(
|
||||
'Input field must be defined on target credential (options are {}).'.format(
|
||||
', '.join(sorted(defined_fields))
|
||||
)
|
||||
))
|
||||
return self.input_field_name
|
||||
|
||||
def get_input_value(self):
|
||||
backend = self.source_credential.credential_type.plugin.backend
|
||||
backend_kwargs = {}
|
||||
for field_name, value in self.source_credential.inputs.items():
|
||||
if field_name in self.source_credential.credential_type.secret_fields:
|
||||
backend_kwargs[field_name] = decrypt_field(self.source_credential, field_name)
|
||||
else:
|
||||
backend_kwargs[field_name] = value
|
||||
|
||||
backend_kwargs.update(self.metadata)
|
||||
return backend(**backend_kwargs)
|
||||
|
||||
def get_absolute_url(self, request=None):
|
||||
view_name = 'api:credential_input_source_detail'
|
||||
return reverse(view_name, kwargs={'pk': self.pk}, request=request)
|
||||
|
||||
|
||||
for ns, plugin in credential_plugins.items():
|
||||
CredentialType.load_plugin(ns, plugin)
|
||||
|
||||
@ -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'))
|
||||
|
||||
@ -772,7 +772,12 @@ class BaseTask(object):
|
||||
'credentials': {
|
||||
<awx.main.models.Credential>: '/path/to/decrypted/data',
|
||||
<awx.main.models.Credential>: '/path/to/decrypted/data',
|
||||
<awx.main.models.Credential>: '/path/to/decrypted/data',
|
||||
...
|
||||
},
|
||||
'certificates': {
|
||||
<awx.main.models.Credential>: /path/to/signed/ssh/certificate,
|
||||
<awx.main.models.Credential>: /path/to/signed/ssh/certificate,
|
||||
...
|
||||
}
|
||||
}
|
||||
'''
|
||||
@ -787,7 +792,6 @@ class BaseTask(object):
|
||||
# and we're running an earlier version (<6.5).
|
||||
if 'OPENSSH PRIVATE KEY' in data and not openssh_keys_supported:
|
||||
raise RuntimeError(OPENSSH_KEY_ERROR)
|
||||
for credential, data in private_data.get('credentials', {}).items():
|
||||
# OpenSSH formatted keys must have a trailing newline to be
|
||||
# accepted by ssh-add.
|
||||
if 'OPENSSH PRIVATE KEY' in data and not data.endswith('\n'):
|
||||
@ -813,6 +817,13 @@ class BaseTask(object):
|
||||
f.close()
|
||||
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
|
||||
private_data_files['credentials'][credential] = path
|
||||
for credential, data in private_data.get('certificates', {}).items():
|
||||
name = 'credential_%d-cert.pub' % credential.pk
|
||||
path = os.path.join(private_data_dir, name)
|
||||
with open(path, 'w') as f:
|
||||
f.write(data)
|
||||
f.close()
|
||||
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
|
||||
return private_data_files
|
||||
|
||||
def build_passwords(self, instance, runtime_passwords):
|
||||
@ -1269,16 +1280,23 @@ class RunJob(BaseTask):
|
||||
'credentials': {
|
||||
<awx.main.models.Credential>: <credential_decrypted_ssh_key_data>,
|
||||
<awx.main.models.Credential>: <credential_decrypted_ssh_key_data>,
|
||||
<awx.main.models.Credential>: <credential_decrypted_ssh_key_data>
|
||||
...
|
||||
},
|
||||
'certificates': {
|
||||
<awx.main.models.Credential>: <signed SSH certificate data>,
|
||||
<awx.main.models.Credential>: <signed SSH certificate data>,
|
||||
...
|
||||
}
|
||||
}
|
||||
'''
|
||||
private_data = {'credentials': {}}
|
||||
for credential in job.credentials.all():
|
||||
for credential in job.credentials.prefetch_related('input_sources__source_credential').all():
|
||||
# If we were sent SSH credentials, decrypt them and send them
|
||||
# back (they will be written to a temporary file).
|
||||
if credential.has_input('ssh_key_data'):
|
||||
private_data['credentials'][credential] = credential.get_input('ssh_key_data', default='')
|
||||
if credential.has_input('ssh_public_key_data'):
|
||||
private_data.setdefault('certificates', {})[credential] = credential.get_input('ssh_public_key_data', default='')
|
||||
|
||||
if credential.kind == 'openstack':
|
||||
openstack_auth = dict(auth_url=credential.get_input('host', default=''),
|
||||
@ -1503,7 +1521,7 @@ class RunJob(BaseTask):
|
||||
return self._write_extra_vars_file(private_data_dir, extra_vars, safe_dict)
|
||||
|
||||
def build_credentials_list(self, job):
|
||||
return job.credentials.all()
|
||||
return job.credentials.prefetch_related('input_sources__source_credential').all()
|
||||
|
||||
def get_password_prompts(self, passwords={}):
|
||||
d = super(RunJob, self).get_password_prompts(passwords)
|
||||
@ -2187,7 +2205,12 @@ class RunAdHocCommand(BaseTask):
|
||||
'credentials': {
|
||||
<awx.main.models.Credential>: <credential_decrypted_ssh_key_data>,
|
||||
<awx.main.models.Credential>: <credential_decrypted_ssh_key_data>,
|
||||
<awx.main.models.Credential>: <credential_decrypted_ssh_key_data>
|
||||
...
|
||||
},
|
||||
'certificates': {
|
||||
<awx.main.models.Credential>: <signed SSH certificate data>,
|
||||
<awx.main.models.Credential>: <signed SSH certificate data>,
|
||||
...
|
||||
}
|
||||
}
|
||||
'''
|
||||
@ -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):
|
||||
|
||||
@ -4,6 +4,7 @@ import pytest
|
||||
from unittest import mock
|
||||
from contextlib import contextmanager
|
||||
|
||||
from awx.main.models import Credential
|
||||
from awx.main.tests.factories import (
|
||||
create_organization,
|
||||
create_job_template,
|
||||
@ -139,3 +140,11 @@ def pytest_runtest_teardown(item, nextitem):
|
||||
# this is a local test cache, so we want every test to start with empty cache
|
||||
cache.clear()
|
||||
|
||||
|
||||
@pytest.fixture(scope='session', autouse=True)
|
||||
def mock_external_credential_input_sources():
|
||||
# Credential objects query their related input sources on initialization.
|
||||
# We mock that behavior out of credentials by default unless we need to
|
||||
# test it explicitly.
|
||||
with mock.patch.object(Credential, 'dynamic_input_fields', new=[]) as _fixture:
|
||||
yield _fixture
|
||||
|
||||
@ -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
|
||||
|
||||
378
awx/main/tests/functional/api/test_credential_input_sources.py
Normal file
378
awx/main/tests/functional/api/test_credential_input_sources.py
Normal file
@ -0,0 +1,378 @@
|
||||
import pytest
|
||||
|
||||
from awx.main.models import CredentialInputSource
|
||||
from awx.api.versioning import reverse
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_associate_credential_input_source(get, post, delete, admin, vault_credential, external_credential):
|
||||
list_url = reverse(
|
||||
'api:credential_input_source_list',
|
||||
kwargs={'version': 'v2'}
|
||||
)
|
||||
|
||||
# attach
|
||||
params = {
|
||||
'target_credential': vault_credential.pk,
|
||||
'source_credential': external_credential.pk,
|
||||
'input_field_name': 'vault_password',
|
||||
'metadata': {'key': 'some_example_key'}
|
||||
}
|
||||
response = post(list_url, params, admin)
|
||||
assert response.status_code == 201
|
||||
|
||||
detail = get(response.data['url'], admin)
|
||||
assert detail.status_code == 200
|
||||
|
||||
response = get(list_url, admin)
|
||||
assert response.status_code == 200
|
||||
assert response.data['count'] == 1
|
||||
assert CredentialInputSource.objects.count() == 1
|
||||
input_source = CredentialInputSource.objects.first()
|
||||
assert input_source.metadata == {'key': 'some_example_key'}
|
||||
|
||||
# detach
|
||||
response = delete(
|
||||
reverse(
|
||||
'api:credential_input_source_detail',
|
||||
kwargs={'version': 'v2', 'pk': detail.data['id']}
|
||||
),
|
||||
admin
|
||||
)
|
||||
assert response.status_code == 204
|
||||
|
||||
response = get(list_url, admin)
|
||||
assert response.status_code == 200
|
||||
assert response.data['count'] == 0
|
||||
assert CredentialInputSource.objects.count() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize('metadata', [
|
||||
{}, # key is required
|
||||
{'key': None}, # must be a string
|
||||
{'key': 123}, # must be a string
|
||||
{'extraneous': 'foo'}, # invalid parameter
|
||||
])
|
||||
def test_associate_credential_input_source_with_invalid_metadata(get, post, admin, vault_credential, external_credential, metadata):
|
||||
list_url = reverse(
|
||||
'api:credential_input_source_list',
|
||||
kwargs={'version': 'v2'},
|
||||
)
|
||||
|
||||
params = {
|
||||
'target_credential': vault_credential.pk,
|
||||
'source_credential': external_credential.pk,
|
||||
'input_field_name': 'vault_password',
|
||||
'metadata': metadata,
|
||||
}
|
||||
response = post(list_url, params, admin)
|
||||
assert response.status_code == 400
|
||||
assert b'metadata' in response.content
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_from_list(get, post, admin, vault_credential, external_credential):
|
||||
params = {
|
||||
'source_credential': external_credential.pk,
|
||||
'target_credential': vault_credential.pk,
|
||||
'input_field_name': 'vault_password',
|
||||
'metadata': {'key': 'some_example_key'},
|
||||
}
|
||||
assert post(reverse(
|
||||
'api:credential_input_source_list',
|
||||
kwargs={'version': 'v2'}
|
||||
), params, admin).status_code == 201
|
||||
assert CredentialInputSource.objects.count() == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_credential_input_source_with_external_target_returns_400(post, admin, external_credential, other_external_credential):
|
||||
list_url = reverse(
|
||||
'api:credential_input_source_list',
|
||||
kwargs={'version': 'v2'}
|
||||
)
|
||||
params = {
|
||||
'target_credential': other_external_credential.pk,
|
||||
'source_credential': external_credential.pk,
|
||||
'input_field_name': 'token',
|
||||
'metadata': {'key': 'some_key'},
|
||||
}
|
||||
response = post(list_url, params, admin)
|
||||
assert response.status_code == 400
|
||||
assert response.data['target_credential'] == ['Target must be a non-external credential']
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_input_source_rbac_associate(get, post, delete, alice, vault_credential, external_credential):
|
||||
list_url = reverse(
|
||||
'api:credential_input_source_list',
|
||||
kwargs={'version': 'v2'}
|
||||
)
|
||||
params = {
|
||||
'target_credential': vault_credential.pk,
|
||||
'source_credential': external_credential.pk,
|
||||
'input_field_name': 'vault_password',
|
||||
'metadata': {'key': 'some_key'},
|
||||
}
|
||||
|
||||
# alice can't admin the target *or* source cred
|
||||
response = post(list_url, params, alice)
|
||||
assert response.status_code == 403
|
||||
|
||||
# alice can't use the source cred
|
||||
vault_credential.admin_role.members.add(alice)
|
||||
response = post(list_url, params, alice)
|
||||
assert response.status_code == 403
|
||||
|
||||
# alice is allowed to associate now
|
||||
external_credential.use_role.members.add(alice)
|
||||
response = post(list_url, params, alice)
|
||||
assert response.status_code == 201
|
||||
|
||||
# now let's try disassociation
|
||||
detail = get(response.data['url'], alice)
|
||||
assert detail.status_code == 200
|
||||
vault_credential.admin_role.members.remove(alice)
|
||||
external_credential.use_role.members.remove(alice)
|
||||
|
||||
# now that permissions are removed, alice can't *read* the input source
|
||||
assert get(response.data['url'], alice).status_code == 403
|
||||
|
||||
# alice can't admin the target (so she can't remove the input source)
|
||||
delete_url = reverse(
|
||||
'api:credential_input_source_detail',
|
||||
kwargs={'version': 'v2', 'pk': detail.data['id']}
|
||||
)
|
||||
response = delete(delete_url, alice)
|
||||
assert response.status_code == 403
|
||||
|
||||
# alice is allowed to disassociate now
|
||||
vault_credential.admin_role.members.add(alice)
|
||||
response = delete(delete_url, alice)
|
||||
assert response.status_code == 204
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_input_source_detail_rbac(get, post, patch, delete, admin, alice,
|
||||
vault_credential, external_credential,
|
||||
other_external_credential):
|
||||
sublist_url = reverse(
|
||||
'api:credential_input_source_sublist',
|
||||
kwargs={'version': 'v2', 'pk': vault_credential.pk}
|
||||
)
|
||||
params = {
|
||||
'source_credential': external_credential.pk,
|
||||
'input_field_name': 'vault_password',
|
||||
'metadata': {'key': 'some_key'},
|
||||
}
|
||||
|
||||
response = post(sublist_url, params, admin)
|
||||
assert response.status_code == 201
|
||||
|
||||
url = response.data['url']
|
||||
|
||||
# alice can't read the input source directly because she can't read the target cred
|
||||
detail = get(url, alice)
|
||||
assert detail.status_code == 403
|
||||
|
||||
# alice can read the input source directly
|
||||
vault_credential.read_role.members.add(alice)
|
||||
detail = get(url, alice)
|
||||
assert detail.status_code == 200
|
||||
|
||||
# she can also see it on the credential sublist
|
||||
response = get(sublist_url, admin)
|
||||
assert response.status_code == 200
|
||||
assert response.data['count'] == 1
|
||||
|
||||
# alice can't change or delete the input source because she can't change
|
||||
# the target cred and she can't use the source cred
|
||||
assert patch(url, {'input_field_name': 'vault_id'}, alice).status_code == 403
|
||||
assert delete(url, alice).status_code == 403
|
||||
|
||||
# alice still can't change the input source because she she can't use the
|
||||
# source cred
|
||||
vault_credential.admin_role.members.add(alice)
|
||||
assert patch(url, {'input_field_name': 'vault_id'}, alice).status_code == 403
|
||||
|
||||
# alice can now admin the target cred and use the source cred, so she can
|
||||
# change the input field name
|
||||
external_credential.use_role.members.add(alice)
|
||||
assert patch(url, {'input_field_name': 'vault_id'}, alice).status_code == 200
|
||||
assert CredentialInputSource.objects.first().input_field_name == 'vault_id'
|
||||
|
||||
# she _cannot_, however, apply a source credential she doesn't have access to
|
||||
assert patch(url, {'source_credential': other_external_credential.pk}, alice).status_code == 403
|
||||
|
||||
assert delete(url, alice).status_code == 204
|
||||
assert CredentialInputSource.objects.count() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_input_source_create_rbac(get, post, patch, delete, alice,
|
||||
vault_credential, external_credential,
|
||||
other_external_credential):
|
||||
list_url = reverse(
|
||||
'api:credential_input_source_list',
|
||||
kwargs={'version': 'v2'}
|
||||
)
|
||||
params = {
|
||||
'target_credential': vault_credential.pk,
|
||||
'source_credential': external_credential.pk,
|
||||
'input_field_name': 'vault_password',
|
||||
'metadata': {'key': 'some_key'},
|
||||
}
|
||||
|
||||
# alice can't create the inv source because she has access to neither credential
|
||||
response = post(list_url, params, alice)
|
||||
assert response.status_code == 403
|
||||
|
||||
# alice still can't because she can't use the source credential
|
||||
vault_credential.admin_role.members.add(alice)
|
||||
response = post(list_url, params, alice)
|
||||
assert response.status_code == 403
|
||||
|
||||
# alice can create an input source if she has permissions on both credentials
|
||||
external_credential.use_role.members.add(alice)
|
||||
response = post(list_url, params, alice)
|
||||
assert response.status_code == 201
|
||||
assert CredentialInputSource.objects.count() == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_input_source_rbac_swap_target_credential(get, post, put, patch, admin, alice,
|
||||
machine_credential, vault_credential,
|
||||
external_credential):
|
||||
# If you change the target credential for an input source,
|
||||
# you have to have admin role on the *original* credential (so you can
|
||||
# remove the relationship) *and* on the *new* credential (so you can apply the
|
||||
# new relationship)
|
||||
list_url = reverse(
|
||||
'api:credential_input_source_list',
|
||||
kwargs={'version': 'v2'}
|
||||
)
|
||||
params = {
|
||||
'target_credential': vault_credential.pk,
|
||||
'source_credential': external_credential.pk,
|
||||
'input_field_name': 'vault_password',
|
||||
'metadata': {'key': 'some_key'},
|
||||
}
|
||||
|
||||
response = post(list_url, params, admin)
|
||||
assert response.status_code == 201
|
||||
url = response.data['url']
|
||||
|
||||
# alice starts with use permission on the source credential
|
||||
# alice starts with no permissions on the target credential
|
||||
external_credential.admin_role.members.add(alice)
|
||||
|
||||
# alice can't change target cred because she can't admin either one
|
||||
assert patch(url, {
|
||||
'target_credential': machine_credential.pk,
|
||||
'input_field_name': 'password'
|
||||
}, alice).status_code == 403
|
||||
|
||||
# alice still can't change target cred because she can't admin *the new one*
|
||||
vault_credential.admin_role.members.add(alice)
|
||||
assert patch(url, {
|
||||
'target_credential': machine_credential.pk,
|
||||
'input_field_name': 'password'
|
||||
}, alice).status_code == 403
|
||||
|
||||
machine_credential.admin_role.members.add(alice)
|
||||
assert patch(url, {
|
||||
'target_credential': machine_credential.pk,
|
||||
'input_field_name': 'password'
|
||||
}, alice).status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_input_source_rbac_change_metadata(get, post, put, patch, admin, alice,
|
||||
machine_credential, external_credential):
|
||||
# To change an input source, a user must have admin permissions on the
|
||||
# target credential and use permissions on the source credential.
|
||||
list_url = reverse(
|
||||
'api:credential_input_source_list',
|
||||
kwargs={'version': 'v2'}
|
||||
)
|
||||
params = {
|
||||
'target_credential': machine_credential.pk,
|
||||
'source_credential': external_credential.pk,
|
||||
'input_field_name': 'password',
|
||||
'metadata': {'key': 'some_key'},
|
||||
}
|
||||
|
||||
response = post(list_url, params, admin)
|
||||
assert response.status_code == 201
|
||||
url = response.data['url']
|
||||
|
||||
# alice can't change input source metadata because she isn't an admin of the
|
||||
# target credential and doesn't have use permission on the source credential
|
||||
assert patch(url, {
|
||||
'metadata': {'key': 'some_other_key'}
|
||||
}, alice).status_code == 403
|
||||
|
||||
# alice still can't change input source metadata because she doesn't have
|
||||
# use permission on the source credential.
|
||||
machine_credential.admin_role.members.add(alice)
|
||||
assert patch(url, {
|
||||
'metadata': {'key': 'some_other_key'}
|
||||
}, alice).status_code == 403
|
||||
|
||||
external_credential.use_role.members.add(alice)
|
||||
assert patch(url, {
|
||||
'metadata': {'key': 'some_other_key'}
|
||||
}, alice).status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_credential_input_source_with_non_external_source_returns_400(post, admin, credential, vault_credential):
|
||||
list_url = reverse(
|
||||
'api:credential_input_source_list',
|
||||
kwargs={'version': 'v2'}
|
||||
)
|
||||
params = {
|
||||
'target_credential': vault_credential.pk,
|
||||
'source_credential': credential.pk,
|
||||
'input_field_name': 'vault_password'
|
||||
}
|
||||
response = post(list_url, params, admin)
|
||||
assert response.status_code == 400
|
||||
assert response.data['source_credential'] == ['Source must be an external credential']
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_credential_input_source_with_undefined_input_returns_400(post, admin, vault_credential, external_credential):
|
||||
list_url = reverse(
|
||||
'api:credential_input_source_list',
|
||||
kwargs={'version': 'v2'}
|
||||
)
|
||||
params = {
|
||||
'target_credential': vault_credential.pk,
|
||||
'source_credential': external_credential.pk,
|
||||
'input_field_name': 'not_defined_for_credential_type',
|
||||
'metadata': {'key': 'some_key'}
|
||||
}
|
||||
response = post(list_url, params, admin)
|
||||
assert response.status_code == 400
|
||||
assert response.data['input_field_name'] == ['Input field must be defined on target credential (options are vault_id, vault_password).']
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_credential_input_source_with_already_used_input_returns_400(post, admin, vault_credential, external_credential, other_external_credential):
|
||||
list_url = reverse(
|
||||
'api:credential_input_source_list',
|
||||
kwargs={'version': 'v2'}
|
||||
)
|
||||
all_params = [{
|
||||
'target_credential': vault_credential.pk,
|
||||
'source_credential': external_credential.pk,
|
||||
'input_field_name': 'vault_password'
|
||||
}, {
|
||||
'target_credential': vault_credential.pk,
|
||||
'source_credential': other_external_credential.pk,
|
||||
'input_field_name': 'vault_password'
|
||||
}]
|
||||
all_responses = [post(list_url, params, admin) for params in all_params]
|
||||
assert all_responses.pop().status_code == 400
|
||||
@ -250,6 +250,42 @@ def credentialtype_insights():
|
||||
return insights_type
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def credentialtype_external():
|
||||
external_type_inputs = {
|
||||
'fields': [{
|
||||
'id': 'url',
|
||||
'label': 'Server URL',
|
||||
'type': 'string',
|
||||
'help_text': 'The server url.'
|
||||
}, {
|
||||
'id': 'token',
|
||||
'label': 'Token',
|
||||
'type': 'string',
|
||||
'secret': True,
|
||||
'help_text': 'An access token for the server.'
|
||||
}],
|
||||
'metadata': [{
|
||||
'id': 'key',
|
||||
'label': 'Key',
|
||||
'type': 'string'
|
||||
}, {
|
||||
'id': 'version',
|
||||
'label': 'Version',
|
||||
'type': 'string'
|
||||
}],
|
||||
'required': ['url', 'token', 'key'],
|
||||
}
|
||||
external_type = CredentialType(
|
||||
kind='external',
|
||||
managed_by_tower=True,
|
||||
name='External Service',
|
||||
inputs=external_type_inputs
|
||||
)
|
||||
external_type.save()
|
||||
return external_type
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def credential(credentialtype_aws):
|
||||
return Credential.objects.create(credential_type=credentialtype_aws, name='test-cred',
|
||||
@ -293,6 +329,18 @@ def org_credential(organization, credentialtype_aws):
|
||||
organization=organization)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def external_credential(credentialtype_external):
|
||||
return Credential.objects.create(credential_type=credentialtype_external, name='external-cred',
|
||||
inputs={'url': 'http://testhost.com', 'token': 'secret1'})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def other_external_credential(credentialtype_external):
|
||||
return Credential.objects.create(credential_type=credentialtype_external, name='other-external-cred',
|
||||
inputs={'url': 'http://testhost.com', 'token': 'secret2'})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def inventory(organization):
|
||||
return organization.inventories.create(name="test-inv")
|
||||
|
||||
@ -75,10 +75,15 @@ GLqbpJyX2r3p/Rmo6mLY71SqpA==
|
||||
@pytest.mark.django_db
|
||||
def test_default_cred_types():
|
||||
assert sorted(CredentialType.defaults.keys()) == [
|
||||
'aim',
|
||||
'aws',
|
||||
'azure_kv',
|
||||
'azure_rm',
|
||||
'cloudforms',
|
||||
'conjur',
|
||||
'gce',
|
||||
'hashivault_kv',
|
||||
'hashivault_ssh',
|
||||
'insights',
|
||||
'net',
|
||||
'openstack',
|
||||
@ -181,7 +186,7 @@ def test_ssh_key_data_validation(organization, kind, ssh_key_data, ssh_key_unloc
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize('inputs, valid', [
|
||||
({'vault_password': 'some-pass'}, True),
|
||||
({}, False),
|
||||
({}, True),
|
||||
({'vault_password': 'dev-pass', 'vault_id': 'dev'}, True),
|
||||
({'vault_password': 'dev-pass', 'vault_id': 'dev@prompt'}, False), # @ not allowed
|
||||
])
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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 {
|
||||
|
||||
20
awx/ui/client/features/credentials/_index.less
Normal file
20
awx/ui/client/features/credentials/_index.less
Normal file
@ -0,0 +1,20 @@
|
||||
.InputSourceLookup-selectedItem {
|
||||
display: flex;
|
||||
flex: 0 0 100%;
|
||||
align-items: center;
|
||||
min-height: 50px;
|
||||
margin-top: 16px;
|
||||
border-radius: 5px;
|
||||
background-color: @default-no-items-bord;
|
||||
border: 1px solid @default-border;
|
||||
}
|
||||
|
||||
.InputSourceLookup-selectedItemLabel {
|
||||
color: @default-interface-txt;
|
||||
text-transform: uppercase;
|
||||
margin: 0 @at-space-2x;
|
||||
}
|
||||
|
||||
.InputSourceLookup-selectedItemText {
|
||||
font-style: italic;
|
||||
}
|
||||
@ -1,155 +0,0 @@
|
||||
function AddCredentialsController (
|
||||
models,
|
||||
$state,
|
||||
$scope,
|
||||
strings,
|
||||
componentsStrings,
|
||||
ConfigService
|
||||
) {
|
||||
const vm = this || {};
|
||||
|
||||
const { me, credential, credentialType, organization } = models;
|
||||
|
||||
vm.mode = 'add';
|
||||
vm.strings = strings;
|
||||
vm.panelTitle = strings.get('add.PANEL_TITLE');
|
||||
|
||||
vm.tab = {
|
||||
details: { _active: true },
|
||||
permissions: { _disabled: true }
|
||||
};
|
||||
|
||||
vm.form = credential.createFormSchema('post', {
|
||||
omit: ['user', 'team', 'inputs']
|
||||
});
|
||||
|
||||
vm.form._formName = 'credential';
|
||||
|
||||
vm.form.disabled = !credential.isCreatable();
|
||||
|
||||
vm.form.organization._resource = 'organization';
|
||||
vm.form.organization._route = 'credentials.add.organization';
|
||||
vm.form.organization._model = organization;
|
||||
vm.form.organization._placeholder = strings.get('inputs.ORGANIZATION_PLACEHOLDER');
|
||||
|
||||
vm.form.credential_type._resource = 'credential_type';
|
||||
vm.form.credential_type._route = 'credentials.add.credentialType';
|
||||
vm.form.credential_type._model = credentialType;
|
||||
vm.form.credential_type._placeholder = strings.get('inputs.CREDENTIAL_TYPE_PLACEHOLDER');
|
||||
|
||||
const gceFileInputSchema = {
|
||||
id: 'gce_service_account_key',
|
||||
type: 'file',
|
||||
label: strings.get('inputs.GCE_FILE_INPUT_LABEL'),
|
||||
help_text: strings.get('inputs.GCE_FILE_INPUT_HELP_TEXT'),
|
||||
};
|
||||
|
||||
let gceFileInputPreEditValues;
|
||||
|
||||
vm.form.inputs = {
|
||||
_get: () => {
|
||||
credentialType.mergeInputProperties();
|
||||
|
||||
const fields = credentialType.get('inputs.fields');
|
||||
|
||||
if (credentialType.get('name') === 'Google Compute Engine') {
|
||||
fields.splice(2, 0, gceFileInputSchema);
|
||||
$scope.$watch(`vm.form.${gceFileInputSchema.id}._value`, vm.gceOnFileInputChanged);
|
||||
} else if (credentialType.get('name') === 'Machine') {
|
||||
const apiConfig = ConfigService.get();
|
||||
const become = fields.find((field) => field.id === 'become_method');
|
||||
become._isDynamic = true;
|
||||
become._choices = Array.from(apiConfig.become_methods, method => method[0]);
|
||||
}
|
||||
|
||||
return fields;
|
||||
},
|
||||
_source: vm.form.credential_type,
|
||||
_reference: 'vm.form.inputs',
|
||||
_key: 'inputs'
|
||||
};
|
||||
|
||||
vm.form.save = data => {
|
||||
data.user = me.get('id');
|
||||
|
||||
if (_.get(data.inputs, gceFileInputSchema.id)) {
|
||||
delete data.inputs[gceFileInputSchema.id];
|
||||
}
|
||||
|
||||
const filteredInputs = _.omit(data.inputs, (value) => value === '');
|
||||
data.inputs = filteredInputs;
|
||||
|
||||
return credential.request('post', { data });
|
||||
};
|
||||
|
||||
vm.form.onSaveSuccess = res => {
|
||||
$state.go('credentials.edit', { credential_id: res.data.id }, { reload: true });
|
||||
};
|
||||
|
||||
vm.gceOnFileInputChanged = (value, oldValue) => {
|
||||
if (value === oldValue) return;
|
||||
|
||||
const gceFileIsLoaded = !!value;
|
||||
const gceFileInputState = vm.form[gceFileInputSchema.id];
|
||||
const { obj, error } = vm.gceParseFileInput(value);
|
||||
|
||||
gceFileInputState._isValid = !error;
|
||||
gceFileInputState._message = error ? componentsStrings.get('message.INVALID_INPUT') : '';
|
||||
|
||||
vm.form.project._disabled = gceFileIsLoaded;
|
||||
vm.form.username._disabled = gceFileIsLoaded;
|
||||
vm.form.ssh_key_data._disabled = gceFileIsLoaded;
|
||||
vm.form.ssh_key_data._displayHint = !vm.form.ssh_key_data._disabled;
|
||||
|
||||
if (gceFileIsLoaded) {
|
||||
gceFileInputPreEditValues = Object.assign({}, {
|
||||
project: vm.form.project._value,
|
||||
ssh_key_data: vm.form.ssh_key_data._value,
|
||||
username: vm.form.username._value
|
||||
});
|
||||
vm.form.project._value = _.get(obj, 'project_id', '');
|
||||
vm.form.ssh_key_data._value = _.get(obj, 'private_key', '');
|
||||
vm.form.username._value = _.get(obj, 'client_email', '');
|
||||
} else {
|
||||
vm.form.project._value = gceFileInputPreEditValues.project;
|
||||
vm.form.ssh_key_data._value = gceFileInputPreEditValues.ssh_key_data;
|
||||
vm.form.username._value = gceFileInputPreEditValues.username;
|
||||
}
|
||||
};
|
||||
|
||||
vm.gceParseFileInput = value => {
|
||||
let obj;
|
||||
let error;
|
||||
|
||||
try {
|
||||
obj = angular.fromJson(value);
|
||||
} catch (err) {
|
||||
error = err;
|
||||
}
|
||||
|
||||
return { obj, error };
|
||||
};
|
||||
|
||||
$scope.$watch('organization', () => {
|
||||
if ($scope.organization) {
|
||||
vm.form.organization._idFromModal = $scope.organization;
|
||||
}
|
||||
});
|
||||
|
||||
$scope.$watch('credential_type', () => {
|
||||
if ($scope.credential_type) {
|
||||
vm.form.credential_type._idFromModal = $scope.credential_type;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
AddCredentialsController.$inject = [
|
||||
'resolvedModels',
|
||||
'$state',
|
||||
'$scope',
|
||||
'CredentialsStrings',
|
||||
'ComponentsStrings',
|
||||
'ConfigService'
|
||||
];
|
||||
|
||||
export default AddCredentialsController;
|
||||
@ -0,0 +1,650 @@
|
||||
/* eslint camelcase: 0 */
|
||||
/* eslint arrow-body-style: 0 */
|
||||
function AddEditCredentialsController (
|
||||
models,
|
||||
$state,
|
||||
$scope,
|
||||
strings,
|
||||
componentsStrings,
|
||||
ConfigService,
|
||||
ngToast,
|
||||
Wait,
|
||||
$filter,
|
||||
CredentialType,
|
||||
GetBasePath,
|
||||
Rest,
|
||||
) {
|
||||
const vm = this || {};
|
||||
const {
|
||||
me,
|
||||
credential,
|
||||
credentialType,
|
||||
organization,
|
||||
isOrgEditableByUser,
|
||||
sourceCredentials,
|
||||
} = models;
|
||||
|
||||
const omit = ['user', 'team', 'inputs'];
|
||||
const isEditable = credential.isEditable();
|
||||
const isExternal = credentialType.get('kind') === 'external';
|
||||
const mode = $state.current.name.startsWith('credentials.add') ? 'add' : 'edit';
|
||||
|
||||
vm.mode = mode;
|
||||
vm.strings = strings;
|
||||
|
||||
if (mode === 'edit') {
|
||||
vm.panelTitle = credential.get('name');
|
||||
vm.tab = {
|
||||
details: {
|
||||
_active: true,
|
||||
_go: 'credentials.edit',
|
||||
_params: { credential_id: credential.get('id') }
|
||||
},
|
||||
permissions: {
|
||||
_go: 'credentials.edit.permissions',
|
||||
_params: { credential_id: credential.get('id') }
|
||||
}
|
||||
};
|
||||
|
||||
if (isEditable) {
|
||||
vm.form = credential.createFormSchema('put', { omit });
|
||||
} else {
|
||||
vm.form = credential.createFormSchema({ omit });
|
||||
vm.form.disabled = !isEditable;
|
||||
}
|
||||
|
||||
vm.form.organization._disabled = !isOrgEditableByUser;
|
||||
// Only exists for permissions compatibility
|
||||
$scope.credential_obj = credential.get();
|
||||
|
||||
vm.form.organization._resource = 'organization';
|
||||
vm.form.organization._model = organization;
|
||||
vm.form.organization._route = 'credentials.edit.organization';
|
||||
vm.form.organization._value = credential.get('summary_fields.organization.id');
|
||||
vm.form.organization._displayValue = credential.get('summary_fields.organization.name');
|
||||
vm.form.organization._placeholder = strings.get('inputs.ORGANIZATION_PLACEHOLDER');
|
||||
|
||||
vm.form.credential_type._resource = 'credential_type';
|
||||
vm.form.credential_type._model = credentialType;
|
||||
vm.form.credential_type._route = 'credentials.edit.credentialType';
|
||||
vm.form.credential_type._placeholder = strings.get('inputs.CREDENTIAL_TYPE_PLACEHOLDER');
|
||||
vm.form.credential_type._value = credentialType.get('id');
|
||||
vm.form.credential_type._displayValue = credentialType.get('name');
|
||||
vm.isTestable = (isEditable && credentialType.get('kind') === 'external');
|
||||
|
||||
if (credential.get('related.input_sources.results.length' > 0)) {
|
||||
vm.form.credential_type._disabled = true;
|
||||
}
|
||||
|
||||
$scope.$watch('$state.current.name', (value) => {
|
||||
if (/credentials.edit($|\.organization$|\.credentialType$)/.test(value)) {
|
||||
vm.tab.details._active = true;
|
||||
vm.tab.permissions._active = false;
|
||||
} else {
|
||||
vm.tab.permissions._active = true;
|
||||
vm.tab.details._active = false;
|
||||
}
|
||||
});
|
||||
} else if (mode === 'add') {
|
||||
vm.panelTitle = strings.get('add.PANEL_TITLE');
|
||||
vm.tab = {
|
||||
details: { _active: true },
|
||||
permissions: { _disabled: true }
|
||||
};
|
||||
vm.form = credential.createFormSchema('post', {
|
||||
omit: ['user', 'team', 'inputs']
|
||||
});
|
||||
|
||||
vm.form._formName = 'credential';
|
||||
vm.form.disabled = !credential.isCreatable();
|
||||
|
||||
vm.form.organization._resource = 'organization';
|
||||
vm.form.organization._route = 'credentials.add.organization';
|
||||
vm.form.organization._model = organization;
|
||||
vm.form.organization._placeholder = strings.get('inputs.ORGANIZATION_PLACEHOLDER');
|
||||
|
||||
vm.form.credential_type._resource = 'credential_type';
|
||||
vm.form.credential_type._route = 'credentials.add.credentialType';
|
||||
vm.form.credential_type._model = credentialType;
|
||||
vm.form.credential_type._placeholder = strings.get('inputs.CREDENTIAL_TYPE_PLACEHOLDER');
|
||||
vm.isTestable = credentialType.get('kind') === 'external';
|
||||
}
|
||||
|
||||
$scope.$watch('organization', () => {
|
||||
if ($scope.organization) {
|
||||
vm.form.organization._idFromModal = $scope.organization;
|
||||
}
|
||||
});
|
||||
|
||||
$scope.$watch('credential_type', () => {
|
||||
if ($scope.credential_type) {
|
||||
vm.form.credential_type._idFromModal = $scope.credential_type;
|
||||
}
|
||||
});
|
||||
|
||||
const gceFileInputSchema = {
|
||||
id: 'gce_service_account_key',
|
||||
type: 'file',
|
||||
label: strings.get('inputs.GCE_FILE_INPUT_LABEL'),
|
||||
help_text: strings.get('inputs.GCE_FILE_INPUT_HELP_TEXT'),
|
||||
};
|
||||
|
||||
let gceFileInputPreEditValues;
|
||||
|
||||
vm.form.inputs = {
|
||||
_get ({ getSubmitData, check }) {
|
||||
const apiConfig = ConfigService.get();
|
||||
|
||||
credentialType.mergeInputProperties();
|
||||
const fields = credential.assignInputGroupValues(
|
||||
apiConfig,
|
||||
credentialType,
|
||||
sourceCredentials
|
||||
);
|
||||
|
||||
if (credentialType.get('name') === 'Google Compute Engine') {
|
||||
fields.splice(2, 0, gceFileInputSchema);
|
||||
$scope.$watch(`vm.form.${gceFileInputSchema.id}._value`, gceOnFileInputChanged);
|
||||
if (mode === 'edit') {
|
||||
$scope.$watch('vm.form.ssh_key_data._isBeingReplaced', gceOnReplaceKeyChanged);
|
||||
}
|
||||
}
|
||||
|
||||
vm.inputSources.initialItems = credential.get('related.input_sources.results');
|
||||
vm.inputSources.items = [];
|
||||
vm.inputSources.changedInputFields = [];
|
||||
if (credential.get('credential_type') === credentialType.get('id')) {
|
||||
vm.inputSources.items = credential.get('related.input_sources.results');
|
||||
}
|
||||
vm.isTestable = (isEditable && credentialType.get('kind') === 'external');
|
||||
vm.getSubmitData = getSubmitData;
|
||||
vm.checkForm = check;
|
||||
|
||||
return fields;
|
||||
},
|
||||
_onRemoveTag ({ id }) {
|
||||
vm.onInputSourceClear(id);
|
||||
},
|
||||
_onInputLookup ({ id }) {
|
||||
vm.onInputSourceOpen(id);
|
||||
},
|
||||
_source: vm.form.credential_type,
|
||||
_reference: 'vm.form.inputs',
|
||||
_key: 'inputs',
|
||||
border: true,
|
||||
title: true,
|
||||
};
|
||||
|
||||
vm.externalTest = {
|
||||
form: {
|
||||
inputs: {
|
||||
_get: () => vm.externalTest.metadataInputs,
|
||||
_reference: 'vm.form.inputs',
|
||||
_key: 'inputs',
|
||||
_source: { _value: {} },
|
||||
},
|
||||
},
|
||||
metadataInputs: null,
|
||||
};
|
||||
vm.inputSources = {
|
||||
tabs: {
|
||||
credential: {
|
||||
_active: true,
|
||||
_disabled: false,
|
||||
},
|
||||
metadata: {
|
||||
_active: false,
|
||||
_disabled: false,
|
||||
}
|
||||
},
|
||||
form: {
|
||||
inputs: {
|
||||
_get: () => vm.inputSources.metadataInputs,
|
||||
_reference: 'vm.form.inputs',
|
||||
_key: 'inputs',
|
||||
_source: { _value: {} },
|
||||
},
|
||||
},
|
||||
field: null,
|
||||
credentialTypeId: null,
|
||||
credentialTypeName: null,
|
||||
credentialId: null,
|
||||
credentialName: null,
|
||||
metadataInputs: null,
|
||||
changedInputFields: [],
|
||||
initialItems: credential.get('related.input_sources.results'),
|
||||
items: credential.get('related.input_sources.results'),
|
||||
};
|
||||
|
||||
function setInputSourceTab (name) {
|
||||
const metaIsActive = name === 'metadata';
|
||||
vm.inputSources.tabs.credential._active = !metaIsActive;
|
||||
vm.inputSources.tabs.credential._disabled = false;
|
||||
vm.inputSources.tabs.metadata._active = metaIsActive;
|
||||
vm.inputSources.tabs.metadata._disabled = false;
|
||||
}
|
||||
|
||||
function unsetInputSourceTabs () {
|
||||
vm.inputSources.tabs.credential._active = false;
|
||||
vm.inputSources.tabs.credential._disabled = false;
|
||||
vm.inputSources.tabs.metadata._active = false;
|
||||
vm.inputSources.tabs.metadata._disabled = false;
|
||||
}
|
||||
|
||||
vm.onInputSourceClear = (field) => {
|
||||
vm.form[field].tagMode = true;
|
||||
vm.form[field].asTag = false;
|
||||
vm.form[field]._value = '';
|
||||
vm.form[field]._tagValue = '';
|
||||
vm.form[field]._isValid = true;
|
||||
vm.form[field]._rejected = false;
|
||||
vm.inputSources.items = vm.inputSources.items
|
||||
.filter(({ input_field_name }) => input_field_name !== field);
|
||||
vm.inputSources.changedInputFields.push(field);
|
||||
};
|
||||
|
||||
vm.onInputSourceOpen = (field) => {
|
||||
// We get here when the input source lookup modal for a field is opened. If source
|
||||
// credential and metadata values for this field already exist in the initial API data
|
||||
// or from it being set during a prior visit to the lookup, we initialize the lookup with
|
||||
// these values here before opening it.
|
||||
const sourceItem = vm.inputSources.items
|
||||
.find(({ input_field_name }) => input_field_name === field);
|
||||
if (sourceItem) {
|
||||
const { source_credential, summary_fields } = sourceItem;
|
||||
const { source_credential: { credential_type_id, name } } = summary_fields;
|
||||
vm.inputSources.credentialId = source_credential;
|
||||
vm.inputSources.credentialName = name;
|
||||
vm.inputSources.credentialTypeId = credential_type_id;
|
||||
vm.inputSources._value = credential_type_id;
|
||||
} else {
|
||||
vm.inputSources.credentialId = null;
|
||||
vm.inputSources.credentialName = null;
|
||||
vm.inputSources.credentialTypeId = null;
|
||||
vm.inputSources._value = null;
|
||||
}
|
||||
|
||||
setInputSourceTab('credential');
|
||||
vm.inputSources.field = field;
|
||||
};
|
||||
|
||||
vm.onInputSourceClose = () => {
|
||||
// We get here if the lookup was closed or canceled so we clear the state for the lookup
|
||||
// and metadata form without storing any changes.
|
||||
vm.inputSources.field = null;
|
||||
vm.inputSources.credentialId = null;
|
||||
vm.inputSources.credentialName = null;
|
||||
vm.inputSources.metadataInputs = null;
|
||||
unsetInputSourceTabs();
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract the current set of input values from the metadata form and reshape them to a
|
||||
* metadata object that can be sent to the api later or reloaded when re-opening the form.
|
||||
*/
|
||||
function getMetadataFormSubmitData ({ inputs }) {
|
||||
return inputs._group.reduce((metadata, { id, _value }) => {
|
||||
if (_value !== undefined) {
|
||||
metadata[id] = _value;
|
||||
}
|
||||
return metadata;
|
||||
}, {});
|
||||
}
|
||||
|
||||
vm.onInputSourceNext = () => {
|
||||
const { field, credentialId, credentialTypeId } = vm.inputSources;
|
||||
Wait('start');
|
||||
new CredentialType('get', credentialTypeId)
|
||||
.then(model => {
|
||||
model.mergeInputProperties('metadata');
|
||||
vm.inputSources.metadataInputs = model.get('inputs.metadata');
|
||||
vm.inputSources.credentialTypeName = model.get('name');
|
||||
// Pre-populate the input values for the metadata form if state for this specific
|
||||
// field_name->source_credential link already exists. This occurs one of two ways:
|
||||
//
|
||||
// 1. This field->source_credential link already exists in the API and so we're
|
||||
// showing the current state as it exists on the backend.
|
||||
// 2. The metadata form for this specific field->source_credential combination was
|
||||
// set during a prior visit to this lookup and so we're reflecting the most
|
||||
// recent set of (unsaved) metadata values provided by the user for this field.
|
||||
//
|
||||
// Note: Prior state for a given credential input field is only set for one source
|
||||
// credential at a time. Linking a field to a source credential will remove all
|
||||
// other prior input state for that field.
|
||||
const [metavals] = vm.inputSources.items
|
||||
.filter(({ input_field_name }) => input_field_name === field)
|
||||
.filter(({ source_credential }) => source_credential === credentialId)
|
||||
.map(({ metadata }) => metadata);
|
||||
Object.keys(metavals || {}).forEach(key => {
|
||||
const obj = vm.inputSources.metadataInputs.find(o => o.id === key);
|
||||
if (obj) obj._value = metavals[key];
|
||||
});
|
||||
setInputSourceTab('metadata');
|
||||
})
|
||||
.finally(() => Wait('stop'));
|
||||
};
|
||||
|
||||
vm.onInputSourceSelect = () => {
|
||||
const { field, credentialId, credentialName, credentialTypeId } = vm.inputSources;
|
||||
const metadata = getMetadataFormSubmitData(vm.inputSources.form);
|
||||
// Remove any input source objects already stored for this field then store the metadata
|
||||
// and currently selected source credential as a credential input source object that
|
||||
// can be sent to the api later or reloaded into the form if it is reopened.
|
||||
vm.inputSources.items = vm.inputSources.items
|
||||
.filter(({ input_field_name }) => input_field_name !== field)
|
||||
.concat([{
|
||||
metadata,
|
||||
input_field_name: field,
|
||||
source_credential: credentialId,
|
||||
target_credential: credential.get('id'),
|
||||
summary_fields: {
|
||||
source_credential: {
|
||||
name: credentialName,
|
||||
credential_type_id: credentialTypeId
|
||||
}
|
||||
},
|
||||
}]);
|
||||
// Record that this field was changed
|
||||
vm.inputSources.changedInputFields.push(field);
|
||||
// Now that we've extracted and stored the selected source credential and metadata values
|
||||
// for this field, we clear the state for the source credential lookup and metadata form.
|
||||
vm.inputSources.field = null;
|
||||
vm.inputSources.metadataInputs = null;
|
||||
unsetInputSourceTabs();
|
||||
// We've linked this field to a credential, so display value as a credential tag
|
||||
vm.form[field]._value = '';
|
||||
vm.form[field]._tagValue = credentialName;
|
||||
vm.form[field]._isValid = true;
|
||||
vm.form[field].asTag = true;
|
||||
vm.checkForm();
|
||||
};
|
||||
|
||||
vm.onInputSourceTabSelect = (name) => {
|
||||
if (name === 'metadata') {
|
||||
// Clicking on the metadata tab should have identical behavior to clicking the 'next'
|
||||
// button, so we pass-through to the same handler here.
|
||||
vm.onInputSourceNext();
|
||||
} else {
|
||||
setInputSourceTab('credential');
|
||||
}
|
||||
};
|
||||
|
||||
vm.onInputSourceItemSelect = ({ id, credential_type, name }) => {
|
||||
vm.inputSources.credentialId = id;
|
||||
vm.inputSources.credentialName = name;
|
||||
vm.inputSources.credentialTypeId = credential_type;
|
||||
vm.inputSources._value = credential_type;
|
||||
};
|
||||
|
||||
vm.onInputSourceTest = () => {
|
||||
// We get here if the test button on the metadata form for the field of a non-external
|
||||
// credential was used. All input values for the external credential are already stored
|
||||
// on the backend, so we are only testing how it works with a set of metadata before
|
||||
// linking it.
|
||||
const metadata = getMetadataFormSubmitData(vm.inputSources.form);
|
||||
const name = $filter('sanitize')(vm.inputSources.credentialTypeName);
|
||||
const endpoint = `${vm.inputSources.credentialId}/test/`;
|
||||
return runTest({ name, model: credential, endpoint, data: { metadata } });
|
||||
};
|
||||
|
||||
function onExternalTestOpen () {
|
||||
// We get here if test button on the top-level form for an external credential type was
|
||||
// used. We load the metadata schema for this particular external credential type and
|
||||
// use it to generate and open a form for submitting test values.
|
||||
credentialType.mergeInputProperties('metadata');
|
||||
vm.externalTest.metadataInputs = credentialType.get('inputs.metadata');
|
||||
}
|
||||
vm.form.secondary = onExternalTestOpen;
|
||||
|
||||
vm.onExternalTestClose = () => {
|
||||
// We get here if the metadata test form for an external credential type was canceled or
|
||||
// closed so we clear the form state and close without submitting any data to the test api,
|
||||
vm.externalTest.metadataInputs = null;
|
||||
};
|
||||
|
||||
vm.onExternalTest = () => {
|
||||
const name = $filter('sanitize')(credentialType.get('name'));
|
||||
const { inputs } = vm.getSubmitData();
|
||||
const metadata = getMetadataFormSubmitData(vm.externalTest.form);
|
||||
// We get here if the test button on the top-level form for an external credential type was
|
||||
// used. We need to see if the currently selected credential type is the one loaded from
|
||||
// the api when we initialized the view or if its type was changed on the form and hasn't
|
||||
// been saved. If the credential type hasn't been changed, it means some of the input
|
||||
// values for the credential may be stored in the backend and not in the form, so we need
|
||||
// to use the test endpoint for the credential. If the credential type has been changed,
|
||||
// the user must provide a complete set of input values for the credential to save their
|
||||
// changes, so we use the generic test endpoint for the credental type as if we were
|
||||
// testing a completely new and unsaved credential.
|
||||
let model;
|
||||
if (credential.get('credential_type') !== credentialType.get('id')) {
|
||||
model = credentialType;
|
||||
} else {
|
||||
model = credential;
|
||||
}
|
||||
|
||||
const endpoint = `${model.get('id')}/test/`;
|
||||
return runTest({ name, model, endpoint, data: { inputs, metadata } });
|
||||
};
|
||||
|
||||
vm.filterInputSourceCredentialResults = (data) => {
|
||||
// If an external credential is changed to have a non-external `credential_type` while
|
||||
// editing, we avoid showing a self-reference in the list of selectable external
|
||||
// credentials for input fields by filtering it out here.
|
||||
if (isExternal) {
|
||||
data.results = data.results.filter(({ id }) => id !== credential.get('id'));
|
||||
}
|
||||
|
||||
// only show credentials we can use
|
||||
data.results = data.results
|
||||
.filter(({ summary_fields }) => summary_fields.user_capabilities.use);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
function runTest ({ name, model, endpoint, data: { inputs, metadata } }) {
|
||||
return model.http.post({ url: endpoint, data: { inputs, metadata }, replace: false })
|
||||
.then(() => {
|
||||
const icon = 'fa-check-circle';
|
||||
const msg = strings.get('edit.TEST_PASSED');
|
||||
const content = buildTestNotificationContent({ name, icon, msg });
|
||||
ngToast.success({
|
||||
content,
|
||||
dismissButton: false,
|
||||
dismissOnTimeout: true
|
||||
});
|
||||
})
|
||||
.catch(({ data }) => {
|
||||
const icon = 'fa-exclamation-triangle';
|
||||
const msg = data.inputs || strings.get('edit.TEST_FAILED');
|
||||
const content = buildTestNotificationContent({ name, icon, msg });
|
||||
ngToast.danger({
|
||||
content,
|
||||
dismissButton: false,
|
||||
dismissOnTimeout: true
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function buildTestNotificationContent ({ name, msg, icon }) {
|
||||
const sanitize = $filter('sanitize');
|
||||
const content = `<div class="Toast-wrapper">
|
||||
<div class="Toast-icon">
|
||||
<i class="fa ${icon} Toast-successIcon"></i>
|
||||
</div>
|
||||
<div>
|
||||
<b>${sanitize(name)}:</b> ${sanitize(msg)}
|
||||
</div>
|
||||
</div>`;
|
||||
return content;
|
||||
}
|
||||
|
||||
function deleteInputSource ({ id }) {
|
||||
Rest.setUrl(`${GetBasePath('credential_input_sources')}${id}/`);
|
||||
return Rest.destroy();
|
||||
}
|
||||
|
||||
function createInputSource (data) {
|
||||
Rest.setUrl(GetBasePath('credential_input_sources'));
|
||||
return Rest.post(data);
|
||||
}
|
||||
|
||||
function create (data) {
|
||||
data.user = me.get('id');
|
||||
|
||||
if (_.get(data.inputs, gceFileInputSchema.id)) {
|
||||
delete data.inputs[gceFileInputSchema.id];
|
||||
}
|
||||
|
||||
const updatedLinkedFieldNames = vm.inputSources.items
|
||||
.map(({ input_field_name }) => input_field_name);
|
||||
const sourcesToAssociate = [...vm.inputSources.items];
|
||||
|
||||
// remove inputs with empty string values
|
||||
let filteredInputs = _.omit(data.inputs, (value) => value === '');
|
||||
// remove inputs that are to be linked to an external credential
|
||||
filteredInputs = _.omit(filteredInputs, updatedLinkedFieldNames);
|
||||
data.inputs = filteredInputs;
|
||||
|
||||
return credential.request('post', { data })
|
||||
.then(() => {
|
||||
sourcesToAssociate.forEach(obj => { obj.target_credential = credential.get('id'); });
|
||||
return Promise.all(sourcesToAssociate.map(createInputSource));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* If a credential's `credential_type` is changed while editing, the inputs associated with
|
||||
* the old type need to be cleared before saving the inputs associated with the new type.
|
||||
* Otherwise inputs are merged together making the request invalid.
|
||||
*/
|
||||
function update (data) {
|
||||
data.user = me.get('id');
|
||||
credential.unset('inputs');
|
||||
|
||||
if (_.get(data.inputs, gceFileInputSchema.id)) {
|
||||
delete data.inputs[gceFileInputSchema.id];
|
||||
}
|
||||
|
||||
const initialLinkedFieldNames = vm.inputSources.initialItems
|
||||
.map(({ input_field_name }) => input_field_name);
|
||||
const updatedLinkedFieldNames = vm.inputSources.items
|
||||
.map(({ input_field_name }) => input_field_name);
|
||||
|
||||
const fieldsToDisassociate = initialLinkedFieldNames
|
||||
.filter(name => !updatedLinkedFieldNames.includes(name))
|
||||
.concat(updatedLinkedFieldNames)
|
||||
.filter(name => vm.inputSources.changedInputFields.includes(name));
|
||||
const fieldsToAssociate = updatedLinkedFieldNames
|
||||
.filter(name => vm.inputSources.changedInputFields.includes(name));
|
||||
|
||||
const sourcesToDisassociate = fieldsToDisassociate
|
||||
.map(name => vm.inputSources.initialItems
|
||||
.find(({ input_field_name }) => input_field_name === name))
|
||||
.filter(source => source !== undefined);
|
||||
const sourcesToAssociate = fieldsToAssociate
|
||||
.map(name => vm.inputSources.items
|
||||
.find(({ input_field_name }) => input_field_name === name))
|
||||
.filter(source => source !== undefined);
|
||||
|
||||
// remove inputs with empty string values
|
||||
let filteredInputs = _.omit(data.inputs, (value) => value === '');
|
||||
// remove inputs that are to be linked to an external credential
|
||||
filteredInputs = _.omit(filteredInputs, updatedLinkedFieldNames);
|
||||
data.inputs = filteredInputs;
|
||||
|
||||
return credential.request('put', { data })
|
||||
.then(() => Promise.all(sourcesToDisassociate.map(deleteInputSource)))
|
||||
.then(() => Promise.all(sourcesToAssociate.map(createInputSource)));
|
||||
}
|
||||
|
||||
vm.form.save = data => {
|
||||
if (mode === 'edit') {
|
||||
return update(data);
|
||||
}
|
||||
return create(data);
|
||||
};
|
||||
|
||||
vm.form.onSaveSuccess = () => {
|
||||
$state.go('credentials.edit', { credential_id: credential.get('id') }, { reload: true });
|
||||
};
|
||||
|
||||
function gceOnReplaceKeyChanged (value) {
|
||||
vm.form[gceFileInputSchema.id]._disabled = !value;
|
||||
}
|
||||
|
||||
function gceOnFileInputChanged (value, oldValue) {
|
||||
if (value === oldValue) return;
|
||||
|
||||
const gceFileIsLoaded = !!value;
|
||||
const gceFileInputState = vm.form[gceFileInputSchema.id];
|
||||
const { obj, error } = gceParseFileInput(value);
|
||||
|
||||
gceFileInputState._isValid = !error;
|
||||
gceFileInputState._message = error ? componentsStrings.get('message.INVALID_INPUT') : '';
|
||||
|
||||
vm.form.project._disabled = gceFileIsLoaded;
|
||||
vm.form.username._disabled = gceFileIsLoaded;
|
||||
vm.form.ssh_key_data._disabled = gceFileIsLoaded;
|
||||
vm.form.ssh_key_data._displayHint = !vm.form.ssh_key_data._disabled;
|
||||
|
||||
if (gceFileIsLoaded) {
|
||||
gceFileInputPreEditValues = Object.assign({}, {
|
||||
project: vm.form.project._value,
|
||||
ssh_key_data: vm.form.ssh_key_data._value,
|
||||
username: vm.form.username._value
|
||||
});
|
||||
|
||||
vm.form.project.asTag = false;
|
||||
vm.form.project._value = _.get(obj, 'project_id', '');
|
||||
vm.inputSources.changedInputFields.push('project');
|
||||
vm.inputSources.items = vm.inputSources.items
|
||||
.filter(({ input_field_name }) => input_field_name !== 'project');
|
||||
|
||||
vm.form.ssh_key_data.asTag = false;
|
||||
vm.form.ssh_key_data._value = _.get(obj, 'private_key', '');
|
||||
vm.inputSources.changedInputFields.push('ssh_key_data');
|
||||
vm.inputSources.items = vm.inputSources.items
|
||||
.filter(({ input_field_name }) => input_field_name !== 'ssh_key_data');
|
||||
|
||||
vm.form.username.asTag = false;
|
||||
vm.form.username._value = _.get(obj, 'client_email', '');
|
||||
vm.inputSources.changedInputFields.push('username');
|
||||
vm.inputSources.items = vm.inputSources.items
|
||||
.filter(({ input_field_name }) => input_field_name !== 'username');
|
||||
} else {
|
||||
vm.form.project._value = gceFileInputPreEditValues.project;
|
||||
vm.form.ssh_key_data._value = gceFileInputPreEditValues.ssh_key_data;
|
||||
vm.form.username._value = gceFileInputPreEditValues.username;
|
||||
}
|
||||
}
|
||||
|
||||
function gceParseFileInput (value) {
|
||||
let obj;
|
||||
let error;
|
||||
|
||||
try {
|
||||
obj = angular.fromJson(value);
|
||||
} catch (err) {
|
||||
error = err;
|
||||
}
|
||||
|
||||
return { obj, error };
|
||||
}
|
||||
}
|
||||
|
||||
AddEditCredentialsController.$inject = [
|
||||
'resolvedModels',
|
||||
'$state',
|
||||
'$scope',
|
||||
'CredentialsStrings',
|
||||
'ComponentsStrings',
|
||||
'ConfigService',
|
||||
'ngToast',
|
||||
'Wait',
|
||||
'$filter',
|
||||
'CredentialTypeModel',
|
||||
'GetBasePath',
|
||||
'Rest',
|
||||
];
|
||||
|
||||
export default AddEditCredentialsController;
|
||||
@ -23,6 +23,7 @@
|
||||
</at-input-group>
|
||||
|
||||
<at-action-group col="12" pos="right">
|
||||
<at-form-action type="secondary" ng-if="vm.isTestable"></at-form-action>
|
||||
<at-form-action type="cancel" to="credentials"></at-form-action>
|
||||
<at-form-action type="save"></at-form-action>
|
||||
</at-action-group>
|
||||
@ -42,5 +43,24 @@
|
||||
<div class="at-CredentialsPermissions" ui-view="related"></div>
|
||||
</at-panel-body>
|
||||
</at-panel>
|
||||
|
||||
<at-input-source-lookup
|
||||
ng-if="vm.inputSources.field"
|
||||
selected-id="vm.inputSources.credentialId"
|
||||
selected-name="vm.inputSources.credentialName"
|
||||
tabs="vm.inputSources.tabs"
|
||||
form="vm.inputSources.form"
|
||||
on-close="vm.onInputSourceClose"
|
||||
on-next="vm.onInputSourceNext"
|
||||
on-select="vm.onInputSourceSelect"
|
||||
on-tab-select="vm.onInputSourceTabSelect"
|
||||
on-item-select="vm.onInputSourceItemSelect"
|
||||
on-test="vm.onInputSourceTest"
|
||||
results-filter="vm.filterInputSourceCredentialResults"
|
||||
/>
|
||||
<at-external-credential-test
|
||||
ng-if="vm.externalTest.metadataInputs"
|
||||
on-close="vm.onExternalTestClose"
|
||||
on-submit="vm.onExternalTest"
|
||||
form="vm.externalTest.form"
|
||||
/>
|
||||
<div ng-if="$state.current.name.includes('permissions.add')" ui-view="modal"></div>
|
||||
|
||||
@ -11,7 +11,7 @@ function CredentialsStrings (BaseString) {
|
||||
|
||||
ns.tab = {
|
||||
DETAILS: t.s('Details'),
|
||||
PERMISSIONS: t.s('Permissions')
|
||||
PERMISSIONS: t.s('Permissions'),
|
||||
};
|
||||
|
||||
ns.inputs = {
|
||||
@ -22,10 +22,29 @@ function CredentialsStrings (BaseString) {
|
||||
GCE_FILE_INPUT_HELP_TEXT: t.s('Provide account information using Google Compute Engine JSON credentials file.')
|
||||
};
|
||||
|
||||
ns.externalTest = {
|
||||
TITLE: t.s('Test External Credential')
|
||||
};
|
||||
|
||||
ns.inputSources = {
|
||||
TITLE: t.s('Set Input Source'),
|
||||
CREDENTIAL: t.s('CREDENTIAL'),
|
||||
METADATA: t.s('METADATA'),
|
||||
NO_MATCH: t.s('No records matched your search.'),
|
||||
NO_RECORDS: t.s('No external credentials available.'),
|
||||
SELECTED: t.s('selected'),
|
||||
NONE_SELECTED: t.s('No credential selected'),
|
||||
};
|
||||
|
||||
ns.add = {
|
||||
PANEL_TITLE: t.s('NEW CREDENTIAL')
|
||||
};
|
||||
|
||||
ns.edit = {
|
||||
TEST_PASSED: t.s('Test passed.'),
|
||||
TEST_FAILED: t.s('Test failed.')
|
||||
};
|
||||
|
||||
ns.permissions = {
|
||||
TITLE: t.s('CREDENTIALS PERMISSIONS')
|
||||
};
|
||||
|
||||
@ -1,216 +0,0 @@
|
||||
function EditCredentialsController (
|
||||
models,
|
||||
$state,
|
||||
$scope,
|
||||
strings,
|
||||
componentsStrings,
|
||||
ConfigService
|
||||
) {
|
||||
const vm = this || {};
|
||||
|
||||
const { me, credential, credentialType, organization, isOrgCredAdmin } = models;
|
||||
|
||||
const omit = ['user', 'team', 'inputs'];
|
||||
const isEditable = credential.isEditable();
|
||||
|
||||
vm.mode = 'edit';
|
||||
vm.strings = strings;
|
||||
vm.panelTitle = credential.get('name');
|
||||
|
||||
vm.tab = {
|
||||
details: {
|
||||
_active: true,
|
||||
_go: 'credentials.edit',
|
||||
_params: { credential_id: credential.get('id') }
|
||||
},
|
||||
permissions: {
|
||||
_go: 'credentials.edit.permissions',
|
||||
_params: { credential_id: credential.get('id') }
|
||||
}
|
||||
};
|
||||
|
||||
$scope.$watch('$state.current.name', (value) => {
|
||||
if (/credentials.edit($|\.organization$|\.credentialType$)/.test(value)) {
|
||||
vm.tab.details._active = true;
|
||||
vm.tab.permissions._active = false;
|
||||
} else {
|
||||
vm.tab.permissions._active = true;
|
||||
vm.tab.details._active = false;
|
||||
}
|
||||
});
|
||||
|
||||
$scope.$watch('organization', () => {
|
||||
if ($scope.organization) {
|
||||
vm.form.organization._idFromModal = $scope.organization;
|
||||
}
|
||||
});
|
||||
|
||||
$scope.$watch('credential_type', () => {
|
||||
if ($scope.credential_type) {
|
||||
vm.form.credential_type._idFromModal = $scope.credential_type;
|
||||
}
|
||||
});
|
||||
|
||||
// Only exists for permissions compatibility
|
||||
$scope.credential_obj = credential.get();
|
||||
|
||||
if (isEditable) {
|
||||
vm.form = credential.createFormSchema('put', { omit });
|
||||
} else {
|
||||
vm.form = credential.createFormSchema({ omit });
|
||||
vm.form.disabled = !isEditable;
|
||||
}
|
||||
|
||||
const isOrgAdmin = _.some(me.get('related.admin_of_organizations.results'), (org) => org.id === organization.get('id'));
|
||||
const isSuperuser = me.get('is_superuser');
|
||||
const isCurrentAuthor = Boolean(credential.get('summary_fields.created_by.id') === me.get('id'));
|
||||
vm.form.organization._disabled = true;
|
||||
|
||||
if (isSuperuser || isOrgAdmin || isOrgCredAdmin || (credential.get('organization') === null && isCurrentAuthor)) {
|
||||
vm.form.organization._disabled = false;
|
||||
}
|
||||
|
||||
vm.form.organization._resource = 'organization';
|
||||
vm.form.organization._model = organization;
|
||||
vm.form.organization._route = 'credentials.edit.organization';
|
||||
vm.form.organization._value = credential.get('summary_fields.organization.id');
|
||||
vm.form.organization._displayValue = credential.get('summary_fields.organization.name');
|
||||
vm.form.organization._placeholder = strings.get('inputs.ORGANIZATION_PLACEHOLDER');
|
||||
|
||||
vm.form.credential_type._resource = 'credential_type';
|
||||
vm.form.credential_type._model = credentialType;
|
||||
vm.form.credential_type._route = 'credentials.edit.credentialType';
|
||||
vm.form.credential_type._value = credentialType.get('id');
|
||||
vm.form.credential_type._displayValue = credentialType.get('name');
|
||||
vm.form.credential_type._placeholder = strings.get('inputs.CREDENTIAL_TYPE_PLACEHOLDER');
|
||||
|
||||
const gceFileInputSchema = {
|
||||
id: 'gce_service_account_key',
|
||||
type: 'file',
|
||||
label: strings.get('inputs.GCE_FILE_INPUT_LABEL'),
|
||||
help_text: strings.get('inputs.GCE_FILE_INPUT_HELP_TEXT'),
|
||||
};
|
||||
|
||||
let gceFileInputPreEditValues;
|
||||
|
||||
vm.form.inputs = {
|
||||
_get () {
|
||||
let fields;
|
||||
|
||||
credentialType.mergeInputProperties();
|
||||
|
||||
if (credentialType.get('id') === credential.get('credential_type')) {
|
||||
fields = credential.assignInputGroupValues(credentialType.get('inputs.fields'));
|
||||
} else {
|
||||
fields = credentialType.get('inputs.fields');
|
||||
}
|
||||
|
||||
if (credentialType.get('name') === 'Google Compute Engine') {
|
||||
fields.splice(2, 0, gceFileInputSchema);
|
||||
|
||||
$scope.$watch(`vm.form.${gceFileInputSchema.id}._value`, vm.gceOnFileInputChanged);
|
||||
$scope.$watch('vm.form.ssh_key_data._isBeingReplaced', vm.gceOnReplaceKeyChanged);
|
||||
} else if (credentialType.get('name') === 'Machine') {
|
||||
const apiConfig = ConfigService.get();
|
||||
const become = fields.find((field) => field.id === 'become_method');
|
||||
become._isDynamic = true;
|
||||
become._choices = Array.from(apiConfig.become_methods, method => method[0]);
|
||||
// Add the value to the choices if it doesn't exist in the preset list
|
||||
if (become._value && become._value !== '') {
|
||||
const optionMatches = become._choices
|
||||
.findIndex((option) => option === become._value);
|
||||
if (optionMatches === -1) {
|
||||
become._choices.push(become._value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fields;
|
||||
},
|
||||
_source: vm.form.credential_type,
|
||||
_reference: 'vm.form.inputs',
|
||||
_key: 'inputs'
|
||||
};
|
||||
|
||||
/**
|
||||
* If a credential's `credential_type` is changed while editing, the inputs associated with
|
||||
* the old type need to be cleared before saving the inputs associated with the new type.
|
||||
* Otherwise inputs are merged together making the request invalid.
|
||||
*/
|
||||
vm.form.save = data => {
|
||||
data.user = me.get('id');
|
||||
credential.unset('inputs');
|
||||
|
||||
if (_.get(data.inputs, gceFileInputSchema.id)) {
|
||||
delete data.inputs[gceFileInputSchema.id];
|
||||
}
|
||||
|
||||
const filteredInputs = _.omit(data.inputs, (value) => value === '');
|
||||
data.inputs = filteredInputs;
|
||||
|
||||
return credential.request('put', { data });
|
||||
};
|
||||
|
||||
vm.form.onSaveSuccess = () => {
|
||||
$state.go('credentials.edit', { credential_id: credential.get('id') }, { reload: true });
|
||||
};
|
||||
|
||||
vm.gceOnReplaceKeyChanged = value => {
|
||||
vm.form[gceFileInputSchema.id]._disabled = !value;
|
||||
};
|
||||
|
||||
vm.gceOnFileInputChanged = (value, oldValue) => {
|
||||
if (value === oldValue) return;
|
||||
|
||||
const gceFileIsLoaded = !!value;
|
||||
const gceFileInputState = vm.form[gceFileInputSchema.id];
|
||||
const { obj, error } = vm.gceParseFileInput(value);
|
||||
|
||||
gceFileInputState._isValid = !error;
|
||||
gceFileInputState._message = error ? componentsStrings.get('message.INVALID_INPUT') : '';
|
||||
|
||||
vm.form.project._disabled = gceFileIsLoaded;
|
||||
vm.form.username._disabled = gceFileIsLoaded;
|
||||
vm.form.ssh_key_data._disabled = gceFileIsLoaded;
|
||||
vm.form.ssh_key_data._displayHint = !vm.form.ssh_key_data._disabled;
|
||||
|
||||
if (gceFileIsLoaded) {
|
||||
gceFileInputPreEditValues = Object.assign({}, {
|
||||
project: vm.form.project._value,
|
||||
ssh_key_data: vm.form.ssh_key_data._value,
|
||||
username: vm.form.username._value
|
||||
});
|
||||
vm.form.project._value = _.get(obj, 'project_id', '');
|
||||
vm.form.ssh_key_data._value = _.get(obj, 'private_key', '');
|
||||
vm.form.username._value = _.get(obj, 'client_email', '');
|
||||
} else {
|
||||
vm.form.project._value = gceFileInputPreEditValues.project;
|
||||
vm.form.ssh_key_data._value = gceFileInputPreEditValues.ssh_key_data;
|
||||
vm.form.username._value = gceFileInputPreEditValues.username;
|
||||
}
|
||||
};
|
||||
|
||||
vm.gceParseFileInput = value => {
|
||||
let obj;
|
||||
let error;
|
||||
|
||||
try {
|
||||
obj = angular.fromJson(value);
|
||||
} catch (err) {
|
||||
error = err;
|
||||
}
|
||||
|
||||
return { obj, error };
|
||||
};
|
||||
}
|
||||
|
||||
EditCredentialsController.$inject = [
|
||||
'resolvedModels',
|
||||
'$state',
|
||||
'$scope',
|
||||
'CredentialsStrings',
|
||||
'ComponentsStrings',
|
||||
'ConfigService'
|
||||
];
|
||||
|
||||
export default EditCredentialsController;
|
||||
@ -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: '=',
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,20 @@
|
||||
<at-dialog title="vm.title" on-close="vm.onClose">
|
||||
<at-form state="vm.form" autocomplete="off" id="external_test_form">
|
||||
<at-input-group col="12" tab="20" state="vm.form.inputs" form-id="external_test"></at-input-group>
|
||||
<at-action-group col="12" pos="right">
|
||||
<at-action-button
|
||||
variant="tertiary"
|
||||
ng-click="vm.onClose()"
|
||||
>
|
||||
{{::vm.strings.get('CLOSE')}}
|
||||
</at-action-button>
|
||||
<at-action-button
|
||||
variant="primary"
|
||||
ng-click="vm.onSubmit()"
|
||||
ng-disabled="!vm.form.isValid || vm.form.disabled"
|
||||
>
|
||||
{{::vm.strings.get('RUN')}}
|
||||
</at-action-button>
|
||||
</at-action-group>
|
||||
</at-form>
|
||||
</at-dialog>
|
||||
@ -1,7 +1,8 @@
|
||||
import LegacyCredentials from './legacy.credentials';
|
||||
import AddController from './add-credentials.controller';
|
||||
import EditController from './edit-credentials.controller';
|
||||
import AddEditController from './add-edit-credentials.controller';
|
||||
import CredentialsStrings from './credentials.strings';
|
||||
import InputSourceLookupComponent from './input-source-lookup.component';
|
||||
import ExternalTestModalComponent from './external-test-modal.component';
|
||||
|
||||
const MODULE_NAME = 'at.features.credentials';
|
||||
|
||||
@ -15,7 +16,9 @@ function CredentialsResolve (
|
||||
CredentialType,
|
||||
Organization,
|
||||
ProcessErrors,
|
||||
strings
|
||||
strings,
|
||||
Rest,
|
||||
GetBasePath,
|
||||
) {
|
||||
const id = $stateParams.credential_id;
|
||||
|
||||
@ -27,6 +30,7 @@ function CredentialsResolve (
|
||||
promises.credential = new Credential('options');
|
||||
promises.credentialType = new CredentialType();
|
||||
promises.organization = new Organization();
|
||||
promises.sourceCredentials = $q.resolve({ data: { count: 0, results: [] } });
|
||||
|
||||
return $q.all(promises);
|
||||
}
|
||||
@ -38,17 +42,32 @@ function CredentialsResolve (
|
||||
const typeId = models.credential.get('credential_type');
|
||||
const orgId = models.credential.get('organization');
|
||||
|
||||
Rest.setUrl(GetBasePath('credentials'));
|
||||
const params = { target_input_sources__target_credential: id };
|
||||
const sourceCredentialsPromise = Rest.get({ params });
|
||||
|
||||
const dependents = {
|
||||
credentialType: new CredentialType('get', typeId),
|
||||
organization: new Organization('get', orgId)
|
||||
organization: new Organization('get', orgId),
|
||||
credentialInputSources: models.credential.extend('GET', 'input_sources'),
|
||||
sourceCredentials: sourceCredentialsPromise
|
||||
};
|
||||
|
||||
dependents.isOrgCredAdmin = dependents.organization.then((org) => org.search({ role_level: 'credential_admin_role' }));
|
||||
|
||||
return $q.all(dependents)
|
||||
.then(related => {
|
||||
models.credentialType = related.credentialType;
|
||||
models.organization = related.organization;
|
||||
models.isOrgCredAdmin = related.isOrgCredAdmin;
|
||||
models.sourceCredentials = related.sourceCredentials;
|
||||
|
||||
const isOrgAdmin = _.some(models.me.get('related.admin_of_organizations.results'), (org) => org.id === models.organization.get('id'));
|
||||
const isSuperuser = models.me.get('is_superuser');
|
||||
const isCurrentAuthor = Boolean(models.credential.get('summary_fields.created_by.id') === models.me.get('id'));
|
||||
|
||||
models.isOrgEditableByUser = (isSuperuser || isOrgAdmin
|
||||
|| related.isOrgCredAdmin
|
||||
|| (models.credential.get('organization') === null && isCurrentAuthor));
|
||||
|
||||
return models;
|
||||
});
|
||||
@ -69,7 +88,9 @@ CredentialsResolve.$inject = [
|
||||
'CredentialTypeModel',
|
||||
'OrganizationModel',
|
||||
'ProcessErrors',
|
||||
'CredentialsStrings'
|
||||
'CredentialsStrings',
|
||||
'Rest',
|
||||
'GetBasePath',
|
||||
];
|
||||
|
||||
function CredentialsRun ($stateExtender, legacy, strings) {
|
||||
@ -86,7 +107,7 @@ function CredentialsRun ($stateExtender, legacy, strings) {
|
||||
views: {
|
||||
'add@credentials': {
|
||||
templateUrl: addEditTemplate,
|
||||
controller: AddController,
|
||||
controller: AddEditController,
|
||||
controllerAs: 'vm'
|
||||
}
|
||||
},
|
||||
@ -109,7 +130,7 @@ function CredentialsRun ($stateExtender, legacy, strings) {
|
||||
views: {
|
||||
'edit@credentials': {
|
||||
templateUrl: addEditTemplate,
|
||||
controller: EditController,
|
||||
controller: AddEditController,
|
||||
controllerAs: 'vm'
|
||||
}
|
||||
},
|
||||
@ -135,10 +156,11 @@ CredentialsRun.$inject = [
|
||||
|
||||
angular
|
||||
.module(MODULE_NAME, [])
|
||||
.controller('AddController', AddController)
|
||||
.controller('EditController', EditController)
|
||||
.controller('AddEditController', AddEditController)
|
||||
.service('LegacyCredentialsService', LegacyCredentials)
|
||||
.service('CredentialsStrings', CredentialsStrings)
|
||||
.component('atInputSourceLookup', InputSourceLookupComponent)
|
||||
.component('atExternalCredentialTest', ExternalTestModalComponent)
|
||||
.run(CredentialsRun);
|
||||
|
||||
export default MODULE_NAME;
|
||||
|
||||
@ -0,0 +1,39 @@
|
||||
const templateUrl = require('~features/credentials/input-source-lookup.partial.html');
|
||||
|
||||
function InputSourceLookupController (strings, wait) {
|
||||
const vm = this || {};
|
||||
|
||||
vm.strings = strings;
|
||||
vm.title = strings.get('inputSources.TITLE');
|
||||
|
||||
vm.$onInit = () => wait('start');
|
||||
|
||||
vm.onReady = () => {
|
||||
vm.isReady = true;
|
||||
wait('stop');
|
||||
};
|
||||
}
|
||||
|
||||
InputSourceLookupController.$inject = [
|
||||
'CredentialsStrings',
|
||||
'Wait',
|
||||
];
|
||||
|
||||
export default {
|
||||
templateUrl,
|
||||
controller: InputSourceLookupController,
|
||||
controllerAs: 'vm',
|
||||
bindings: {
|
||||
tabs: '=',
|
||||
onClose: '=',
|
||||
onNext: '=',
|
||||
onSelect: '=',
|
||||
onTabSelect: '=',
|
||||
onItemSelect: '=',
|
||||
onTest: '=',
|
||||
selectedId: '=',
|
||||
selectedName: '=',
|
||||
form: '=',
|
||||
resultsFilter: '=',
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,89 @@
|
||||
<at-dialog title="vm.title" on-close="vm.onClose" ng-show="vm.isReady">
|
||||
<at-tab-group>
|
||||
<at-tab
|
||||
state="vm.tabs.credential"
|
||||
ng-click="vm.onTabSelect('credential');"
|
||||
>
|
||||
{{::vm.strings.get('inputSources.CREDENTIAL')}}
|
||||
</at-tab>
|
||||
<at-tab
|
||||
state="vm.tabs.metadata"
|
||||
ng-click="vm.onTabSelect('metadata');"
|
||||
>
|
||||
{{::vm.strings.get('inputSources.METADATA')}}
|
||||
</at-tab>
|
||||
</at-tab-group>
|
||||
<div class="InputSourceLookup-selectedItem" ng-show="vm.tabs.credential._active">
|
||||
<div class="InputSourceLookup-selectedItemLabel">
|
||||
{{::vm.strings.get('inputSources.SELECTED')}}
|
||||
</div>
|
||||
<span>
|
||||
<div class="at-InputTagContainer">
|
||||
<at-tag
|
||||
ng-show="vm.selectedName"
|
||||
tag="vm.selectedName"
|
||||
icon="external"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="InputSourceLookup-selectedItemText"
|
||||
ng-show="!vm.selectedName"
|
||||
>
|
||||
{{::vm.strings.get('inputSources.NONE_SELECTED')}}
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
<at-lookup-list
|
||||
ng-show="vm.tabs.credential._active"
|
||||
resource-name="credential"
|
||||
base-params="{
|
||||
order_by: 'name',
|
||||
credential_type__kind: 'external',
|
||||
page_size: 5
|
||||
}"
|
||||
results-filter="vm.resultsFilter"
|
||||
selected-id="vm.selectedId"
|
||||
on-ready="vm.onReady"
|
||||
on-item-select="vm.onItemSelect"
|
||||
/>
|
||||
<at-form state="vm.form" autocomplete="off" id="input_source_form">
|
||||
<at-input-group
|
||||
ng-if="vm.tabs.metadata._active"
|
||||
col="12" tab="20"
|
||||
state="vm.form.inputs"
|
||||
form-id="input_source">
|
||||
</at-input-group>
|
||||
<at-action-group col="12" pos="right">
|
||||
<at-action-button
|
||||
variant="secondary"
|
||||
ng-click="vm.onTest()"
|
||||
ng-disabled="!vm.form.isValid || vm.form.disabled"
|
||||
ng-show="vm.tabs.metadata._active"
|
||||
>
|
||||
{{::vm.strings.get('TEST')}}
|
||||
</at-action-button>
|
||||
<at-action-button
|
||||
variant="tertiary"
|
||||
ng-click="vm.onClose()"
|
||||
>
|
||||
{{::vm.strings.get('CANCEL')}}
|
||||
</at-action-button>
|
||||
<at-action-button
|
||||
variant="primary"
|
||||
ng-click="vm.onNext()"
|
||||
ng-disabled="!vm.selectedId"
|
||||
ng-show="vm.tabs.credential._active"
|
||||
>
|
||||
{{::vm.strings.get('NEXT')}}
|
||||
</at-action-button>
|
||||
<at-action-button
|
||||
variant="primary"
|
||||
ng-click="vm.onSelect()"
|
||||
ng-disabled="!vm.form.isValid || vm.form.disabled"
|
||||
ng-show="vm.tabs.metadata._active"
|
||||
>
|
||||
{{::vm.strings.get('OK')}}
|
||||
</at-action-button>
|
||||
</at-action-group>
|
||||
</at-form>
|
||||
</at-dialog>
|
||||
@ -465,7 +465,13 @@ function getCredentialDetails () {
|
||||
}
|
||||
|
||||
function buildCredentialDetails (credential) {
|
||||
const icon = `${credential.kind}`;
|
||||
let icon;
|
||||
if (credential.cloud) {
|
||||
icon = 'cloud';
|
||||
} else {
|
||||
icon = `${credential.kind}`;
|
||||
}
|
||||
|
||||
const link = `/#/credentials/${credential.id}`;
|
||||
const tooltip = strings.get('tooltips.CREDENTIAL');
|
||||
const value = $filter('sanitize')(credential.name);
|
||||
|
||||
@ -2262,6 +2262,7 @@ body {
|
||||
.Toast-wrapper {
|
||||
display: flex;
|
||||
max-width: 250px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.Toast-icon {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
@import 'action/_index';
|
||||
@import 'dialog/_index';
|
||||
@import 'input/_index';
|
||||
@import 'launchTemplateButton/_index';
|
||||
@import 'layout/_index';
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
.at-ActionGroup {
|
||||
margin-top: @at-margin-panel;
|
||||
|
||||
button:last-child {
|
||||
margin-left: @at-margin-panel-inset;
|
||||
button {
|
||||
margin-left: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
@ -0,0 +1,3 @@
|
||||
<button class="btn at-Button{{ vm.fill }}{{ vm.color ? '--' + vm.color : '' }}">
|
||||
<ng-transclude></ng-transclude>
|
||||
</button>
|
||||
@ -4,12 +4,12 @@ function ComponentsStrings (BaseString) {
|
||||
const { t } = this;
|
||||
const ns = this.components;
|
||||
|
||||
ns.REPLACE = t.s('REPLACE');
|
||||
ns.REVERT = t.s('REVERT');
|
||||
ns.REPLACE = t.s('Replace');
|
||||
ns.REVERT = t.s('Revert');
|
||||
ns.ENCRYPTED = t.s('ENCRYPTED');
|
||||
ns.OPTIONS = t.s('OPTIONS');
|
||||
ns.SHOW = t.s('SHOW');
|
||||
ns.HIDE = t.s('HIDE');
|
||||
ns.SHOW = t.s('Show');
|
||||
ns.HIDE = t.s('Hide');
|
||||
|
||||
ns.message = {
|
||||
REQUIRED_INPUT_MISSING: t.s('Please enter a value.'),
|
||||
@ -40,7 +40,7 @@ function ComponentsStrings (BaseString) {
|
||||
};
|
||||
|
||||
ns.textarea = {
|
||||
SSH_KEY_HINT: t.s('HINT: Drag and drop an SSH private key file on the field below.')
|
||||
SSH_KEY_HINT: t.s('HINT: Drag and drop private file on the field below.')
|
||||
};
|
||||
|
||||
ns.lookup = {
|
||||
|
||||
43
awx/ui/client/lib/components/dialog/_index.less
Normal file
43
awx/ui/client/lib/components/dialog/_index.less
Normal file
@ -0,0 +1,43 @@
|
||||
.at-Dialog {
|
||||
display: block;
|
||||
border: none;
|
||||
opacity: 1;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
animation-name: at-DialogFadeIn;
|
||||
animation-iteration-count: 1;
|
||||
animation-timing-function: ease-in;
|
||||
animation-duration: 0.3s;
|
||||
}
|
||||
|
||||
@keyframes at-DialogFadeIn {
|
||||
0% { opacity: 0; background: rgba(0, 0, 0, 0); }
|
||||
100% { opacity: 1; background: rgba(0, 0, 0, 0.3); }
|
||||
}
|
||||
|
||||
.at-Dialog-body {
|
||||
font-size: @at-font-size;
|
||||
padding: @at-padding-panel 0;
|
||||
}
|
||||
|
||||
.at-Dialog-dismiss {
|
||||
.at-mixin-ButtonIcon();
|
||||
font-size: @at-font-size-modal-dismiss;
|
||||
color: @at-color-icon-dismiss;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.at-Dialog-heading {
|
||||
margin: 0;
|
||||
overflow: visible;
|
||||
|
||||
& > .at-Dialog-dismiss {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.at-Dialog-title {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
.at-mixin-Heading(@at-font-size-modal-heading);
|
||||
}
|
||||
34
awx/ui/client/lib/components/dialog/dialog.component.js
Normal file
34
awx/ui/client/lib/components/dialog/dialog.component.js
Normal file
@ -0,0 +1,34 @@
|
||||
const templateUrl = require('~components/dialog/dialog.partial.html');
|
||||
|
||||
const overlayClass = 'at-Dialog';
|
||||
|
||||
function DialogController () {
|
||||
const vm = this || {};
|
||||
|
||||
vm.handleClick = ({ target }) => {
|
||||
if (!vm.onClose) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetElement = $(target);
|
||||
|
||||
if (targetElement.hasClass(overlayClass)) {
|
||||
vm.onClose();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
DialogController.$inject = [
|
||||
'$element',
|
||||
];
|
||||
|
||||
export default {
|
||||
templateUrl,
|
||||
controller: DialogController,
|
||||
controllerAs: 'vm',
|
||||
transclude: true,
|
||||
bindings: {
|
||||
title: '=',
|
||||
onClose: '=',
|
||||
},
|
||||
};
|
||||
19
awx/ui/client/lib/components/dialog/dialog.partial.html
Normal file
19
awx/ui/client/lib/components/dialog/dialog.partial.html
Normal file
@ -0,0 +1,19 @@
|
||||
<div class="modal at-Dialog" ng-click="vm.handleClick($event)" tabindex="-1" role="dialog">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content at-Dialog-window">
|
||||
<div class="Modal-header">
|
||||
<div class="Modal-title">
|
||||
<div class="at-Dialog-heading">
|
||||
<h4 class="modal-title at-Dialog-title">{{ vm.title }}</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div class="Modal-exitHolder">
|
||||
<div class="at-Dialog-dismiss">
|
||||
<i class="fa fa-lg fa-times-circle" ng-click="vm.onClose()"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ng-transclude></ng-transclude>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -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'];
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
<button class="btn at-Button{{ fill }}--{{ color }}"
|
||||
ng-disabled="type !== 'cancel' && (form.disabled || (type === 'save' && !form.isValid))"
|
||||
ng-disabled="type !== 'cancel' && (form.disabled
|
||||
|| (type === 'save' && !form.isValid))
|
||||
|| (type === 'secondary' && !form.isValid)"
|
||||
ng-click="action()">
|
||||
{{::text}}
|
||||
</button>
|
||||
|
||||
@ -30,7 +30,6 @@ function AtFormController (eventService, strings) {
|
||||
({ modal } = scope[scope.ns]);
|
||||
|
||||
vm.state.disabled = scope.state.disabled;
|
||||
|
||||
vm.setListeners();
|
||||
};
|
||||
|
||||
@ -62,6 +61,35 @@ function AtFormController (eventService, strings) {
|
||||
scope.$apply(vm.submit);
|
||||
};
|
||||
|
||||
vm.getSubmitData = () => vm.components
|
||||
.filter(component => component.category === 'input')
|
||||
.reduce((values, component) => {
|
||||
if (component.state._value === undefined) {
|
||||
return values;
|
||||
}
|
||||
|
||||
if (component.state._format === 'selectFromOptions') {
|
||||
values[component.state.id] = component.state._value[0];
|
||||
} else if (component.state._key && typeof component.state._value === 'object') {
|
||||
values[component.state.id] = component.state._value[component.state._key];
|
||||
} else if (component.state._group) {
|
||||
values[component.state._key] = values[component.state._key] || {};
|
||||
values[component.state._key][component.state.id] = component.state._value;
|
||||
} else {
|
||||
values[component.state.id] = component.state._value;
|
||||
}
|
||||
|
||||
return values;
|
||||
}, {});
|
||||
|
||||
vm.submitSecondary = () => {
|
||||
if (!vm.state.isValid) {
|
||||
return;
|
||||
}
|
||||
const data = vm.getSubmitData();
|
||||
scope.state.secondary(data);
|
||||
};
|
||||
|
||||
vm.submit = () => {
|
||||
if (!vm.state.isValid) {
|
||||
return;
|
||||
@ -69,26 +97,7 @@ function AtFormController (eventService, strings) {
|
||||
|
||||
vm.state.disabled = true;
|
||||
|
||||
const data = vm.components
|
||||
.filter(component => component.category === 'input')
|
||||
.reduce((values, component) => {
|
||||
if (component.state._value === undefined) {
|
||||
return values;
|
||||
}
|
||||
|
||||
if (component.state._format === 'selectFromOptions') {
|
||||
values[component.state.id] = component.state._value[0];
|
||||
} else if (component.state._key && typeof component.state._value === 'object') {
|
||||
values[component.state.id] = component.state._value[component.state._key];
|
||||
} else if (component.state._group) {
|
||||
values[component.state._key] = values[component.state._key] || {};
|
||||
values[component.state._key][component.state.id] = component.state._value;
|
||||
} else {
|
||||
values[component.state.id] = component.state._value;
|
||||
}
|
||||
|
||||
return values;
|
||||
}, {});
|
||||
const data = vm.getSubmitData();
|
||||
|
||||
scope.state.save(data)
|
||||
.then(scope.state.onSaveSuccess)
|
||||
@ -179,6 +188,10 @@ function AtFormController (eventService, strings) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (vm.components[i].state.asTag) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!vm.components[i].state._isValid) {
|
||||
isValid = false;
|
||||
break;
|
||||
@ -194,6 +207,10 @@ function AtFormController (eventService, strings) {
|
||||
if (isValid !== vm.state.isValid) {
|
||||
vm.state.isValid = isValid;
|
||||
}
|
||||
|
||||
if (isValid !== scope.state.isValid) {
|
||||
scope.state.isValid = isValid;
|
||||
}
|
||||
};
|
||||
|
||||
vm.deregisterInputGroup = components => {
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import atLibServices from '~services';
|
||||
|
||||
import actionGroup from '~components/action/action-group.directive';
|
||||
import actionButton from '~components/action/action-button.directive';
|
||||
import dialog from '~components/dialog/dialog.component';
|
||||
import divider from '~components/utility/divider.directive';
|
||||
import dynamicSelect from '~components/input/dynamic-select.directive';
|
||||
import form from '~components/form/form.directive';
|
||||
@ -20,6 +22,7 @@ import inputTextareaSecret from '~components/input/textarea-secret.directive';
|
||||
import launchTemplate from '~components/launchTemplateButton/launchTemplateButton.component';
|
||||
import layout from '~components/layout/layout.directive';
|
||||
import list from '~components/list/list.directive';
|
||||
import lookupList from '~components/lookup-list/lookup-list.component';
|
||||
import modal from '~components/modal/modal.directive';
|
||||
import panel from '~components/panel/panel.directive';
|
||||
import panelBody from '~components/panel/body.directive';
|
||||
@ -53,6 +56,8 @@ angular
|
||||
atCodeMirror
|
||||
])
|
||||
.directive('atActionGroup', actionGroup)
|
||||
.directive('atActionButton', actionButton)
|
||||
.component('atDialog', dialog)
|
||||
.directive('atDivider', divider)
|
||||
.directive('atDynamicSelect', dynamicSelect)
|
||||
.directive('atForm', form)
|
||||
@ -72,6 +77,7 @@ angular
|
||||
.component('atLaunchTemplate', launchTemplate)
|
||||
.directive('atLayout', layout)
|
||||
.directive('atList', list)
|
||||
.component('atLookupList', lookupList)
|
||||
.directive('atListToolbar', toolbar)
|
||||
.component('atRelaunch', relaunch)
|
||||
.directive('atRow', row)
|
||||
|
||||
@ -75,6 +75,12 @@
|
||||
height: @at-height-textarea;
|
||||
}
|
||||
|
||||
.at-Input-button--long-sm {
|
||||
.at-mixin-InputButton();
|
||||
max-width: @at-width-input-button-md;
|
||||
min-height: @at-height-textarea;
|
||||
}
|
||||
|
||||
.at-Input-button--active {
|
||||
.at-mixin-ButtonColor(at-color-info, at-color-default);
|
||||
}
|
||||
@ -328,3 +334,26 @@
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.at-InputTaggedTextarea {
|
||||
.at-mixin-FontFixedWidth();
|
||||
min-height: @at-height-textarea;
|
||||
padding: 6px @at-padding-input 0 @at-padding-input;
|
||||
border-radius: @at-border-radius;
|
||||
}
|
||||
|
||||
.at-InputTagContainer {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.TagComponent {
|
||||
max-height: @at-space-4x;
|
||||
}
|
||||
|
||||
.TagComponent-name {
|
||||
align-self: auto;
|
||||
word-break: break-all;
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
@ -51,6 +51,10 @@ function BaseInputController (strings) {
|
||||
let isValid = true;
|
||||
let message = '';
|
||||
|
||||
if (scope.state.asTag) {
|
||||
return (isValid, message);
|
||||
}
|
||||
|
||||
if (scope.state._value || scope.state._displayValue) {
|
||||
scope.state._touched = true;
|
||||
}
|
||||
|
||||
@ -48,7 +48,7 @@ function AtInputGroupController ($scope, $compile) {
|
||||
|
||||
state._value = source._value;
|
||||
|
||||
const inputs = state._get(source._value);
|
||||
const inputs = state._get(form);
|
||||
const group = vm.createComponentConfigs(inputs);
|
||||
|
||||
vm.insert(group);
|
||||
@ -66,7 +66,9 @@ function AtInputGroupController ($scope, $compile) {
|
||||
_element: vm.createComponent(input, i),
|
||||
_key: 'inputs',
|
||||
_group: true,
|
||||
_groupIndex: i
|
||||
_groupIndex: i,
|
||||
_onInputLookup: state._onInputLookup,
|
||||
_onRemoveTag: state._onRemoveTag,
|
||||
}, input));
|
||||
});
|
||||
}
|
||||
@ -97,6 +99,7 @@ function AtInputGroupController ($scope, $compile) {
|
||||
|
||||
if (input.secret) {
|
||||
config._component = 'at-input-textarea-secret';
|
||||
input.format = 'ssh_private_key';
|
||||
} else {
|
||||
config._component = 'at-input-textarea';
|
||||
}
|
||||
@ -111,12 +114,16 @@ function AtInputGroupController ($scope, $compile) {
|
||||
config._component = 'at-input-checkbox';
|
||||
} else if (input.type === 'file') {
|
||||
config._component = 'at-input-file';
|
||||
} else if (input.choices) {
|
||||
}
|
||||
|
||||
if (input.choices) {
|
||||
config._component = 'at-input-select';
|
||||
config._format = 'array';
|
||||
config._data = input.choices;
|
||||
config._exp = 'choice for (index, choice) in state._data';
|
||||
} else {
|
||||
}
|
||||
|
||||
if (!config._component) {
|
||||
const preface = vm.strings.get('group.UNSUPPORTED_ERROR_PREFACE');
|
||||
throw new Error(`${preface}: ${input.type}`);
|
||||
}
|
||||
@ -160,7 +167,6 @@ function AtInputGroupController ($scope, $compile) {
|
||||
</${input._component}>`);
|
||||
|
||||
$compile(component)(scope.$parent);
|
||||
|
||||
return component;
|
||||
};
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<div ng-show="state._group" class="col-sm-12 at-InputGroup">
|
||||
<div class="at-InputGroup-border"></div>
|
||||
<div ng-if="state.border" class="at-InputGroup-border"></div>
|
||||
<div class="at-InputGroup-inset">
|
||||
<div class="row">
|
||||
<div ng-if="state.title" class="row">
|
||||
<div class="col-sm-12">
|
||||
<h4 class="at-InputGroup-title">
|
||||
<ng-transclude></ng-transclude>
|
||||
|
||||
@ -2,10 +2,11 @@
|
||||
<span ng-if="state.required" class="at-InputLabel-required">*</span>
|
||||
<span class="at-InputLabel-name" >{{::state.label | translate}}</span>
|
||||
<at-popover state="state"></at-popover>
|
||||
<span ng-if="state._displayHint" class="at-InputLabel-hint" translate>{{::state._hint}}</span>
|
||||
<span ng-if="state._displayHint && !state.asTag" class="at-InputLabel-hint" translate>{{::state._hint}}</span>
|
||||
<div ng-if="state._displayPromptOnLaunch" class="at-InputLabel-checkbox pull-right">
|
||||
<label class="at-InputLabel-checkboxLabel">
|
||||
<input type="checkbox"
|
||||
ng-disabled="state.asTag"
|
||||
ng-model="state._promptOnLaunch"
|
||||
ng-change="vm.togglePromptOnLaunch()" />
|
||||
<p>{{:: vm.strings.get('label.PROMPT_ON_LAUNCH') }}</p>
|
||||
|
||||
@ -21,17 +21,15 @@ 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';
|
||||
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;
|
||||
scope.state._buttonText = vm.strings.get('REPLACE');
|
||||
}
|
||||
|
||||
vm.check();
|
||||
@ -41,15 +39,30 @@ 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;
|
||||
scope.state._showHideText = vm.strings.get('HIDE');
|
||||
} else {
|
||||
scope.type = 'password';
|
||||
scope.state._buttonText = vm.strings.get('SHOW');
|
||||
scope.state._show = false;
|
||||
scope.state._showHideText = vm.strings.get('SHOW');
|
||||
}
|
||||
};
|
||||
|
||||
vm.onLookupClick = () => {
|
||||
if (scope.state._onInputLookup) {
|
||||
const { id, label, required, type } = scope.state;
|
||||
scope.state._onInputLookup({ id, label, required, type });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@ -3,26 +3,67 @@
|
||||
<at-input-label></at-input-label>
|
||||
|
||||
<div class="input-group">
|
||||
<span class="input-group-btn at-InputGroup-button input-group-prepend">
|
||||
<button class="btn at-ButtonHollow--white"
|
||||
ng-class="{
|
||||
'at-Input-button--fixed-xs': mode === 'input',
|
||||
'at-Input-button--fixed-sm': mode === 'encrypted'
|
||||
}"
|
||||
ng-disabled="!state._enableToggle && (state._disabled || form.disabled)"
|
||||
ng-click="vm.toggle()">
|
||||
{{ state._buttonText }}
|
||||
<span ng-if="state.tagMode" class="input-group-btn input-group-prepend">
|
||||
<button
|
||||
class="btn at-ButtonHollow--default at-Input-button"
|
||||
ng-disabled="state._disabled || form.disabled"
|
||||
ng-click="vm.onLookupClick()">
|
||||
<i class="fa fa-search"></i>
|
||||
</button>
|
||||
</span>
|
||||
<input type="{{ type }}"
|
||||
<span
|
||||
ng-if="state.tagMode && state.asTag"
|
||||
ng-disabled="state._disabled || form.disabled"
|
||||
class="form-control at-Input"
|
||||
>
|
||||
<div class="at-InputTagContainer">
|
||||
<at-tag
|
||||
ng-show="(!state._disabled) && state._tagValue"
|
||||
icon="external"
|
||||
tag="state._tagValue"
|
||||
remove-tag="state._onRemoveTag(state)"
|
||||
/>
|
||||
<at-tag
|
||||
ng-show="state._disabled && state._tagValue"
|
||||
icon="external"
|
||||
tag="state._tagValue"
|
||||
/>
|
||||
</div>
|
||||
</span>
|
||||
<input ng-if="!state.asTag" type="{{ type }}"
|
||||
class="form-control at-Input"
|
||||
ng-model="state[state._activeModel]"
|
||||
ng-class="{ 'at-Input--rejected': state._rejected }"
|
||||
ng-attr-maxlength="{{ state.max_length || undefined }}"
|
||||
ng-attr-tabindex="{{ tab || undefined }}"
|
||||
ng-attr-placeholder="{{state._placeholder || undefined }}"
|
||||
ng-attr-placeholder="{{ state._promptOnLaunch ? '' : state._placeholder || undefined }}"
|
||||
ng-change="vm.check()"
|
||||
ng-disabled="state._disabled || form.disabled" />
|
||||
ng-disabled="state._disabled || form.disabled"
|
||||
/>
|
||||
<span ng-show="mode == 'encrypted'" class="input-group-btn input-group-append">
|
||||
<button
|
||||
class="btn at-ButtonHollow--default at-Input-button"
|
||||
ng-disabled="state.asTag || (!state._enableToggle && (state._disabled || form.disabled))"
|
||||
ng-click="vm.toggleRevertReplace()"
|
||||
aw-tool-tip="{{ state._buttonText }}"
|
||||
data-tip-watch="state._buttonText"
|
||||
data-placement="top">
|
||||
<i ng-show="!state._isBeingReplaced" class="fa fa-undo"></i>
|
||||
<i ng-show="state._isBeingReplaced" class="fa fa-undo fa-flip-horizontal"></i>
|
||||
</button>
|
||||
</span>
|
||||
<span class="input-group-btn input-group-append">
|
||||
<button
|
||||
class="btn at-ButtonHollow--default at-Input-button"
|
||||
ng-disabled="state.asTag || state._disabled || form.disabled"
|
||||
ng-click="vm.toggleShowHide()"
|
||||
aw-tool-tip="{{ state._showHideText }}"
|
||||
data-tip-watch="state._showHideText"
|
||||
data-placement="top">
|
||||
<i ng-show="!state._show" class="fa fa-eye"></i>
|
||||
<i ng-show="state._show" class="fa fa-eye-slash"></i>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<at-input-message></at-input-message>
|
||||
|
||||
@ -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'];
|
||||
|
||||
@ -1,15 +1,51 @@
|
||||
<div class="col-sm-{{::col}} at-InputContainer">
|
||||
<div class="form-group at-u-flat">
|
||||
<at-input-label></at-input-label>
|
||||
|
||||
<input type="text" class="form-control at-Input"
|
||||
ng-class="{ 'at-Input--rejected': state._rejected }"
|
||||
ng-model="state._value"
|
||||
ng-attr-maxlength="{{ state.max_length || undefined }}"
|
||||
ng-attr-tabindex="{{ tab || undefined }}"
|
||||
ng-attr-placeholder="{{::state._placeholder || undefined }}"
|
||||
ng-disabled="state._disabled || form.disabled" />
|
||||
|
||||
<div ng-if="state.tagMode" class="input-group">
|
||||
<span class="input-group-btn input-group-prepend">
|
||||
<button
|
||||
class="btn at-ButtonHollow--default at-Input-button"
|
||||
ng-disabled="state._disabled || form.disabled"
|
||||
ng-click="vm.onLookupClick()">
|
||||
<i class="fa fa-search"></i>
|
||||
</button>
|
||||
</span>
|
||||
<span
|
||||
ng-if="state.asTag"
|
||||
ng-disabled="state._disabled || form.disabled"
|
||||
class="form-control at-Input"
|
||||
>
|
||||
<div class="at-InputTagContainer">
|
||||
<at-tag
|
||||
ng-show="(!state._disabled) && state._tagValue"
|
||||
icon="external"
|
||||
tag="state._tagValue"
|
||||
remove-tag="state._onRemoveTag(state)"
|
||||
/>
|
||||
<at-tag
|
||||
ng-show="state._disabled && state._tagValue"
|
||||
icon="external"
|
||||
tag="state._tagValue"
|
||||
/>
|
||||
</div>
|
||||
</span>
|
||||
<input ng-if="!state.asTag" type="text" class="form-control at-Input"
|
||||
ng-class="{ 'at-Input--rejected': state._rejected }"
|
||||
ng-model="state._value"
|
||||
ng-attr-maxlength="{{ state.max_length || undefined }}"
|
||||
ng-attr-tabindex="{{ tab || undefined }}"
|
||||
ng-attr-placeholder="{{::state._placeholder || undefined }}"
|
||||
ng-disabled="state._disabled || form.disabled"
|
||||
/>
|
||||
</div>
|
||||
<input ng-if="!state.tagMode" type="text" class="form-control at-Input"
|
||||
ng-class="{ 'at-Input--rejected': state._rejected }"
|
||||
ng-model="state._value"
|
||||
ng-attr-maxlength="{{ state.max_length || undefined }}"
|
||||
ng-attr-tabindex="{{ tab || undefined }}"
|
||||
ng-attr-placeholder="{{::state._placeholder || undefined }}"
|
||||
ng-disabled="state._disabled || form.disabled"
|
||||
/>
|
||||
<at-input-message></at-input-message>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -45,6 +45,7 @@ function AtInputTextareaSecretController (baseInputController, eventService) {
|
||||
};
|
||||
|
||||
vm.onIsBeingReplacedChanged = () => {
|
||||
if (!scope.state) return;
|
||||
if (!scope.state._touched) return;
|
||||
|
||||
vm.onRevertReplaceToggle();
|
||||
@ -92,6 +93,13 @@ function AtInputTextareaSecretController (baseInputController, eventService) {
|
||||
input.value = '';
|
||||
});
|
||||
};
|
||||
|
||||
vm.onLookupClick = () => {
|
||||
if (scope.state._onInputLookup) {
|
||||
const { id, label, required, type } = scope.state;
|
||||
scope.state._onInputLookup({ id, label, required, type });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
AtInputTextareaSecretController.$inject = [
|
||||
|
||||
@ -1,32 +1,68 @@
|
||||
<div class="col-sm-{{::col}} at-InputContainer">
|
||||
<div class="form-group at-u-flat">
|
||||
<at-input-label></at-input-label>
|
||||
|
||||
<div ng-class="{ 'input-group': state._edit }">
|
||||
<div ng-if="state._edit" class="input-group-btn at-InputGroup-button input-group-prepend">
|
||||
<button class="btn at-ButtonHollow--white at-Input-button--fixed-md"
|
||||
ng-disabled="!state._enableToggle && (state._disabled || form.disabled)"
|
||||
ng-click="state._isBeingReplaced = !state._isBeingReplaced">
|
||||
{{ state._buttonText }}
|
||||
<div class="input-group">
|
||||
<div class="input-group-btn at-InputGroup-button input-group-prepend" ng-show="state.tagMode">
|
||||
<button
|
||||
class="btn at-ButtonHollow--white at-Input-button--long-sm"
|
||||
ng-disabled="state._disabled || form.disabled"
|
||||
ng-click="vm.onLookupClick()"
|
||||
>
|
||||
<i class="fa fa-search"></i>
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
ng-show="ssh && !state.asTag"
|
||||
ng-disabled="state._disabled || form.disabled"
|
||||
class="at-InputFile--hidden"
|
||||
ng-class="{'at-InputFile--drag': drag }"
|
||||
type="file"
|
||||
name="files"
|
||||
/>
|
||||
<div
|
||||
ng-if="state.asTag"
|
||||
ng-disabled="state._disabled || form.disabled"
|
||||
class="form-control at-Input at-InputTaggedTextarea"
|
||||
>
|
||||
<div class="at-InputTagContainer">
|
||||
<at-tag
|
||||
ng-show="(!state._disabled) && state._tagValue"
|
||||
icon="external"
|
||||
tag="state._tagValue"
|
||||
remove-tag="state._onRemoveTag(state)"
|
||||
/>
|
||||
<at-tag
|
||||
ng-show="state._disabled && state._tagValue"
|
||||
icon="external"
|
||||
tag="state._tagValue"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
class="form-control at-Input at-InputTextarea"
|
||||
ng-show="!state.asTag"
|
||||
ng-model="state[state._activeModel]"
|
||||
ng-class="{ 'at-Input--rejected': state._rejected }"
|
||||
ng-attr-rows="{{::state._rows || 6 }}"
|
||||
ng-attr-maxlength="{{ state.max_length || undefined }}"
|
||||
ng-attr-tabindex="{{ tab || undefined }}"
|
||||
ng-attr-placeholder="{{state._placeholder || undefined }}"
|
||||
ng-disabled="state._disabled || form.disabled"
|
||||
/>
|
||||
<div ng-if="state._edit" class="input-group-btn at-InputGroup-button input-group-append">
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<i ng-show="!state._isBeingReplaced" class="fa fa-undo"></i>
|
||||
<i ng-show="state._isBeingReplaced" class="fa fa-undo fa-flip-horizontal"></i>
|
||||
</button>
|
||||
</div>
|
||||
<input ng-show="ssh"
|
||||
ng-disabled="state._disabled || form.disabled"
|
||||
class="at-InputFile--hidden"
|
||||
ng-class="{'at-InputFile--drag': drag }"
|
||||
type="file"
|
||||
name="files" />
|
||||
<textarea class="form-control at-Input at-InputTextarea"
|
||||
ng-model="state[state._activeModel]"
|
||||
ng-class="{ 'at-Input--rejected': state._rejected }"
|
||||
ng-attr-rows="{{::state._rows || 6 }}"
|
||||
ng-attr-maxlength="{{ state.max_length || undefined }}"
|
||||
ng-attr-tabindex="{{ tab || undefined }}"
|
||||
ng-attr-placeholder="{{state._placeholder || undefined }}"
|
||||
ng-disabled="state._disabled || form.disabled" />
|
||||
</textarea>
|
||||
</div>
|
||||
|
||||
<at-input-message></at-input-message>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -13,12 +13,21 @@ function atInputTextareaLink (scope, element, attrs, controllers) {
|
||||
|
||||
function AtInputTextareaController (baseInputController) {
|
||||
const vm = this || {};
|
||||
let scope;
|
||||
|
||||
vm.init = (scope, element, form) => {
|
||||
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'];
|
||||
|
||||
@ -1,17 +1,45 @@
|
||||
<div class="col-sm-{{::col}} at-InputContainer">
|
||||
<div class="form-group at-u-flat">
|
||||
<at-input-label></at-input-label>
|
||||
|
||||
<textarea class="form-control at-Input at-InputTextarea"
|
||||
ng-model="state._value"
|
||||
ng-class="{ 'at-Input--rejected': state._rejected }"
|
||||
ng-attr-rows="{{::state._rows || 6 }}"
|
||||
ng-attr-maxlength="{{ state.max_length || undefined }}"
|
||||
ng-attr-tabindex="{{ tab || undefined }}"
|
||||
ng-attr-placeholder="{{::state._placeholder || undefined }}"
|
||||
ng-change="vm.check()"
|
||||
ng-disabled="state._disabled || form.disabled" /></textarea>
|
||||
|
||||
<div ng-class="{ 'input-group': state.tagMode }">
|
||||
<div class="input-group-btn at-InputGroup-button input-group-prepend" ng-show="state.tagMode">
|
||||
<button class="btn at-ButtonHollow--white at-Input-button--long-sm"
|
||||
ng-disabled="state._disabled || form.disabled"
|
||||
ng-click="vm.onLookupClick()">
|
||||
<i class="fa fa-search"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
ng-show="state.asTag"
|
||||
ng-disabled="state._disabled || form.disabled"
|
||||
class="form-control at-Input at-InputTaggedTextarea"
|
||||
>
|
||||
<div class="at-InputTagContainer">
|
||||
<at-tag
|
||||
ng-show="(!state._disabled) && state._tagValue"
|
||||
icon="external"
|
||||
tag="state._tagValue"
|
||||
remove-tag="state._onRemoveTag(state)"
|
||||
/>
|
||||
<at-tag
|
||||
ng-show="state._disabled && state._tagValue"
|
||||
icon="external"
|
||||
tag="state._tagValue"
|
||||
remove-tag="state._onRemoveTag(state)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<textarea class="form-control at-Input at-InputTextarea"
|
||||
ng-show="!state.asTag"
|
||||
ng-model="state._value"
|
||||
ng-class="{ 'at-Input--rejected': state._rejected }"
|
||||
ng-attr-rows="{{::state._rows || 6 }}"
|
||||
ng-attr-maxlength="{{ state.max_length || undefined }}"
|
||||
ng-attr-tabindex="{{ tab || undefined }}"
|
||||
ng-attr-placeholder="{{::state._placeholder || undefined }}"
|
||||
ng-change="vm.check()"
|
||||
ng-disabled="state._disabled || form.disabled" />
|
||||
</div>
|
||||
<at-input-message></at-input-message>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,52 @@
|
||||
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));
|
||||
})
|
||||
.finally(() => vm.onReady());
|
||||
};
|
||||
|
||||
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: {
|
||||
onItemSelect: '=',
|
||||
onReady: '=',
|
||||
selectedId: '=',
|
||||
resourceName: '@',
|
||||
baseParams: '=',
|
||||
resultsFilter: '=',
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,90 @@
|
||||
<div>
|
||||
<div ng-hide="vm.collection.length === 0 && (searchTags | isEmpty)">
|
||||
<div style="min-height: 20px"></div>
|
||||
<smart-search
|
||||
django-model="{{ vm.resourceName + 's' }}"
|
||||
base-path="{{ vm.resourceName + 's' }}"
|
||||
iterator="{{ vm.list.iterator }}"
|
||||
dataset="vm.dataset"
|
||||
list="vm.list"
|
||||
collection="vm.collection"
|
||||
default-params="vm.defaultParams"
|
||||
query-set="vm.queryset"
|
||||
search-tags="searchTags">
|
||||
</smart-search>
|
||||
</div>
|
||||
<div
|
||||
class="row"
|
||||
ng-show="vm.collection.length === 0 && !(searchTags | isEmpty)"
|
||||
>
|
||||
<div class="col-lg-12 List-searchNoResults">
|
||||
{{::vm.strings.get('NO_MATCH')}}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="List-noItems"
|
||||
ng-show="vm.collection.length === 0 && (searchTags | isEmpty)"
|
||||
>
|
||||
{{::vm.strings.get('NO_ITEMS')}}
|
||||
</div>
|
||||
<div
|
||||
class="list-table-container"
|
||||
ng-show="vm.collection.length > 0"
|
||||
>
|
||||
<div
|
||||
id="credential_input_source_table"
|
||||
class="List-table"
|
||||
is-extended="false"
|
||||
>
|
||||
<div class="List-lookupLayout List-tableHeaderRow">
|
||||
<div></div>
|
||||
<div class="d-flex h-100">
|
||||
<div
|
||||
base-path="{{ vm.resourceName + 's' }}"
|
||||
collection="vm.collection"
|
||||
dataset="vm.dataset"
|
||||
column-sort=""
|
||||
column-field="name"
|
||||
column-iterator="{{ vm.list.iterator }}"
|
||||
column-label="Name"
|
||||
column-custom-class="List-tableCell col-md-4 col-sm-9 col-xs-9"
|
||||
query-set="vm.queryset"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
id="{{ obj.id }}"
|
||||
class="List-lookupLayout List-tableRow"
|
||||
ng-repeat="obj in vm.collection"
|
||||
>
|
||||
<div class="List-centerEnd select-column">
|
||||
<input type="radio"
|
||||
name="check_{{ vm.resourceName }}_{{ obj.id }}"
|
||||
ng-checked="obj.id === vm.selectedId"
|
||||
ng-click="vm.onItemSelect(obj)"
|
||||
>
|
||||
</div>
|
||||
<div class="d-flex h-100">
|
||||
<div
|
||||
class="List-tableCell name-column col-sm-12"
|
||||
ng-click="vm.onItemSelect(obj)"
|
||||
>
|
||||
{{ obj.name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<paginate
|
||||
base-path="{{ vm.resourceName + 's' }}"
|
||||
iterator="{{ vm.list.iterator }}"
|
||||
collection="vm.collection"
|
||||
dataset="vm.dataset"
|
||||
query-set="vm.queryset"
|
||||
hide-view-per-page="true"
|
||||
>
|
||||
</paginate>
|
||||
</div>
|
||||
@ -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();">
|
||||
<ng-transclude></ng-transclude>
|
||||
</button>
|
||||
|
||||
@ -61,6 +61,10 @@
|
||||
&--vault:before {
|
||||
content: '\f187';
|
||||
}
|
||||
|
||||
&--external:before {
|
||||
content: '\f14c'
|
||||
}
|
||||
}
|
||||
|
||||
.TagComponent-button {
|
||||
|
||||
@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@ -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, sourceCredentials) {
|
||||
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,46 @@ 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.tagMode = true;
|
||||
field.asTag = true;
|
||||
const { summary_fields } = this.get('related.input_sources.results')
|
||||
.find(({ input_field_name }) => input_field_name === field.id);
|
||||
field._tagValue = summary_fields.source_credential.name;
|
||||
|
||||
const { source_credential: { id } } = summary_fields;
|
||||
const src = sourceCredentials.data.results.find(obj => obj.id === id);
|
||||
const canRemove = _.get(src, ['summary_fields', 'user_capabilities', 'delete'], false);
|
||||
|
||||
if (!canRemove) {
|
||||
field._disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
return field;
|
||||
});
|
||||
|
||||
return inputs;
|
||||
}
|
||||
|
||||
function setDependentResources (id) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -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');
|
||||
@ -73,6 +75,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 = {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -21,11 +21,11 @@ const inputContainerElements = {
|
||||
},
|
||||
show: {
|
||||
locateStrategy: 'xpath',
|
||||
selector: `.//button[${normalized}='show']`
|
||||
selector: './/i[contains(@class, "fa fa-eye")]'
|
||||
},
|
||||
hide: {
|
||||
locateStrategy: 'xpath',
|
||||
selector: `.//button[${normalized}='hide']`
|
||||
selector: './/i[contains(@class, "fa fa-eye-slash")]'
|
||||
},
|
||||
on: {
|
||||
locateStrategy: 'xpath',
|
||||
@ -37,11 +37,11 @@ const inputContainerElements = {
|
||||
},
|
||||
replace: {
|
||||
locateStrategy: 'xpath',
|
||||
selector: `.//button[${normalized}='replace']`
|
||||
selector: './/i[contains(@class, "fa fa-undo")]'
|
||||
},
|
||||
revert: {
|
||||
locateStrategy: 'xpath',
|
||||
selector: `.//button[${normalized}='revert']`
|
||||
selector: './/i[contains(@class, "fa fa-undo fa-flip-horizontal")]'
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -146,19 +146,19 @@ module.exports = {
|
||||
const input = `@${f.id}`;
|
||||
|
||||
group.expect.element('@show').visible;
|
||||
group.expect.element('@hide').not.present;
|
||||
group.expect.element('@hide').not.visible;
|
||||
|
||||
type.setValue(input, 'SECRET');
|
||||
type.expect.element(input).text.equal('');
|
||||
|
||||
group.click('@show');
|
||||
group.expect.element('@show').not.present;
|
||||
group.expect.element('@show').not.visible;
|
||||
group.expect.element('@hide').visible;
|
||||
type.expect.element(input).value.contain('SECRET');
|
||||
|
||||
group.click('@hide');
|
||||
group.expect.element('@show').visible;
|
||||
group.expect.element('@hide').not.present;
|
||||
group.expect.element('@hide').not.visible;
|
||||
type.expect.element(input).text.equal('');
|
||||
});
|
||||
},
|
||||
|
||||
@ -216,14 +216,22 @@ module.exports = {
|
||||
edit.section.gce.expect.element('@sshKeyData').not.enabled;
|
||||
},
|
||||
'select and deselect credential file when replacing private key': client => {
|
||||
const taggedTextArea = '.at-InputTaggedTextarea';
|
||||
const textArea = '.at-InputTextarea';
|
||||
const replace = 'button i[class="fa fa-undo"]';
|
||||
const revert = 'button i[class="fa fa-undo fa-flip-horizontal"]';
|
||||
const { gce } = credentials.section.edit.section.details.section;
|
||||
|
||||
gce.section.sshKeyData.waitForElementVisible('@replace');
|
||||
gce.section.sshKeyData.click('@replace');
|
||||
gce.waitForElementVisible(replace);
|
||||
// eslint-disable-next-line prefer-arrow-callback
|
||||
client.execute(function clickReplace (selector) {
|
||||
document.querySelector(selector).click();
|
||||
}, [replace]);
|
||||
|
||||
gce.expect.element('@email').enabled;
|
||||
gce.expect.element('@project').enabled;
|
||||
gce.expect.element('@sshKeyData').enabled;
|
||||
gce.expect.element(textArea).enabled;
|
||||
gce.expect.element(taggedTextArea).not.present;
|
||||
gce.expect.element('@serviceAccountFile').enabled;
|
||||
|
||||
gce.section.sshKeyData.expect.element('@error').visible;
|
||||
@ -247,10 +255,9 @@ module.exports = {
|
||||
gce.section.sshKeyData.expect.element('@error').not.present;
|
||||
gce.section.serviceAccountFile.expect.element('@error').not.present;
|
||||
|
||||
gce.section.sshKeyData.expect.element('@replace').not.present;
|
||||
gce.section.sshKeyData.expect.element('@revert').present;
|
||||
gce.section.sshKeyData.expect.element('@revert').not.enabled;
|
||||
|
||||
gce.expect.element(replace).not.present;
|
||||
gce.expect.element(revert).present;
|
||||
gce.expect.element('.input-group-append button').not.enabled;
|
||||
gce.section.serviceAccountFile.click('form i[class*="trash"]');
|
||||
|
||||
gce.expect.element('@email').enabled;
|
||||
@ -264,9 +271,11 @@ module.exports = {
|
||||
gce.section.project.expect.element('@error').not.present;
|
||||
gce.section.serviceAccountFile.expect.element('@error').not.present;
|
||||
|
||||
gce.section.sshKeyData.expect.element('@revert').enabled;
|
||||
|
||||
gce.section.sshKeyData.click('@revert');
|
||||
gce.expect.element('.input-group-append button').enabled;
|
||||
// eslint-disable-next-line prefer-arrow-callback
|
||||
client.execute(function clickRevert (selector) {
|
||||
document.querySelector(selector).click();
|
||||
}, [revert]);
|
||||
|
||||
gce.expect.element('@email').enabled;
|
||||
gce.expect.element('@project').enabled;
|
||||
|
||||
@ -173,13 +173,13 @@ module.exports = {
|
||||
|
||||
machine.section.password.expect.element('@replace').visible;
|
||||
machine.section.password.expect.element('@replace').enabled;
|
||||
machine.section.password.expect.element('@revert').not.present;
|
||||
machine.section.password.expect.element('@revert').not.visible;
|
||||
|
||||
machine.expect.element('@password').not.enabled;
|
||||
|
||||
machine.section.password.click('@replace');
|
||||
|
||||
machine.section.password.expect.element('@replace').not.present;
|
||||
machine.section.password.expect.element('@replace').not.visible;
|
||||
machine.section.password.expect.element('@revert').visible;
|
||||
|
||||
machine.expect.element('@password').enabled;
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
} from '../fixtures';
|
||||
|
||||
import {
|
||||
AWX_E2E_TIMEOUT_MEDIUM,
|
||||
AWX_E2E_TIMEOUT_SHORT
|
||||
} from '../settings';
|
||||
|
||||
@ -23,16 +24,19 @@ module.exports = {
|
||||
Promise.all(resources)
|
||||
.then(([jt, jt2]) => {
|
||||
data = { jt, jt2 };
|
||||
// We login and load the test page *after* data setup is finished.
|
||||
// This helps avoid flakiness due to unpredictable spinners, etc.
|
||||
// caused by first-time project syncs when creating the job templates.
|
||||
client
|
||||
.login()
|
||||
.waitForAngular()
|
||||
.resizeWindow(1200, 1000)
|
||||
.useXpath()
|
||||
.findThenClick(templatesNavTab)
|
||||
.findThenClick('//*[@id="button-add"]')
|
||||
.findThenClick('//a[@ui-sref="templates.addJobTemplate"]');
|
||||
done();
|
||||
});
|
||||
client
|
||||
.login()
|
||||
.waitForAngular()
|
||||
.resizeWindow(1200, 1000)
|
||||
.useXpath()
|
||||
.findThenClick(templatesNavTab)
|
||||
.findThenClick('//*[@id="button-add"]')
|
||||
.findThenClick('//a[@ui-sref="templates.addJobTemplate"]');
|
||||
},
|
||||
'Test max character limit when creating a job template': client => {
|
||||
client
|
||||
@ -49,18 +53,21 @@ module.exports = {
|
||||
'//input[@name="project_name"]',
|
||||
['test-form-error-handling-project', client.Keys.ENTER]
|
||||
)
|
||||
|
||||
// clicked twice to make the element an active field
|
||||
.findThenClick('//*[@id="select2-playbook-select-container"]')
|
||||
// After the test sets a value for the project, a few seconds are
|
||||
// needed while the UI fetches the playbooks names with a network
|
||||
// call. There's no obvious dom element here to poll, so we wait a
|
||||
// reasonably safe amount of time for the form to settle down
|
||||
// before proceeding.
|
||||
.pause(AWX_E2E_TIMEOUT_MEDIUM)
|
||||
.waitForElementNotVisible('//*[contains(@class, "spinny")]')
|
||||
.findThenClick('//*[@id="select2-playbook-select-container"]')
|
||||
.findThenClick('//li[text()="hello_world.yml"]')
|
||||
.findThenClick('//*[@id="job_template_save_btn"]')
|
||||
.findThenClick('//*[@id="alert_ok_btn"]');
|
||||
|
||||
client.expect.element('//div[@id="job_template_name_group"]' +
|
||||
'//div[@id="job_template-name-api-error"]').to.be.visible.before(AWX_E2E_TIMEOUT_SHORT);
|
||||
'//div[@id="job_template-name-api-error"]').to.be.visible;
|
||||
},
|
||||
|
||||
'Test duplicate template name handling when creating a job template': client => {
|
||||
client
|
||||
.waitForElementNotVisible('//*[@id="alert_ok_btn"]')
|
||||
|
||||
313
docs/credential_plugins.md
Normal file
313
docs/credential_plugins.md
Normal file
@ -0,0 +1,313 @@
|
||||
Credential Plugins
|
||||
==================
|
||||
|
||||
By default, sensitive credential values (such as SSH passwords, SSH private
|
||||
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.
|
||||
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`.
|
||||
|
||||
2. Create a second credential used to _authenticate_ with the external
|
||||
secret management system (in this example, specifying a URL and an
|
||||
OAuth2.0 token _to access_ HashiCorp Vault)
|
||||
|
||||
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/`).
|
||||
|
||||
Note that you can perform these lookups on *any* field for any non-external
|
||||
credential, including those with custom credential types. 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. External credentials
|
||||
cannot have lookups applied to their fields.
|
||||
|
||||
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',
|
||||
]
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
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'
|
||||
)
|
||||
```
|
||||
|
||||
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 <token>"
|
||||
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 <token>"
|
||||
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 <token>" \
|
||||
-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 <token>" \
|
||||
-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 <token>" \
|
||||
-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 <token>"
|
||||
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 <token>"
|
||||
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 <token>" \
|
||||
-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"}}'
|
||||
|
||||
HTTP/1.1 201 Created
|
||||
{
|
||||
"credential_type": 1,
|
||||
"description": "",
|
||||
"id": 1,
|
||||
...
|
||||
```
|
||||
|
||||
```shell
|
||||
~ curl -sik "https://awx.example.org/api/v2/credentials/" \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-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 <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-X POST \
|
||||
-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
|
||||
```
|
||||
|
||||
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`.
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -40,11 +46,11 @@ djangorestframework-yaml==1.0.3
|
||||
djangorestframework==3.7.7
|
||||
future==0.16.0 # via django-radius
|
||||
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
|
||||
@ -61,6 +67,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
|
||||
@ -74,7 +82,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
|
||||
@ -89,8 +97,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
|
||||
@ -103,6 +111,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
|
||||
|
||||
7
setup.py
7
setup.py
@ -114,6 +114,13 @@ setup(
|
||||
'console_scripts': [
|
||||
'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',
|
||||
'aim = awx.main.credential_plugins.aim:aim_plugin'
|
||||
]
|
||||
},
|
||||
data_files = proc_data_files([
|
||||
("%s" % homedir, ["config/wsgi.py",
|
||||
|
||||
@ -2,3 +2,9 @@
|
||||
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
|
||||
aim = awx.main.credential_plugins.aim:aim_plugin
|
||||
|
||||
28
tools/docker-credential-plugins-override.yml
Normal file
28
tools/docker-credential-plugins-override.yml
Normal file
@ -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"
|
||||
Loading…
x
Reference in New Issue
Block a user