mirror of
https://github.com/ansible/awx.git
synced 2026-05-08 18:07:36 -02: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:
4
Makefile
4
Makefile
@@ -575,6 +575,10 @@ docker-compose: docker-auth
|
|||||||
docker-compose-cluster: 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
|
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
|
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
|
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,
|
CENSOR_VALUE,
|
||||||
)
|
)
|
||||||
from awx.main.models import (
|
from awx.main.models import (
|
||||||
ActivityStream, AdHocCommand, AdHocCommandEvent, Credential,
|
ActivityStream, AdHocCommand, AdHocCommandEvent, Credential, CredentialInputSource,
|
||||||
CredentialType, CustomInventoryScript, Fact, Group, Host, Instance,
|
CredentialType, CustomInventoryScript, Fact, Group, Host, Instance,
|
||||||
InstanceGroup, Inventory, InventorySource, InventoryUpdate,
|
InstanceGroup, Inventory, InventorySource, InventoryUpdate,
|
||||||
InventoryUpdateEvent, Job, JobEvent, JobHostSummary, JobLaunchConfig,
|
InventoryUpdateEvent, Job, JobEvent, JobHostSummary, JobLaunchConfig,
|
||||||
@@ -133,6 +133,8 @@ SUMMARIZABLE_FK_FIELDS = {
|
|||||||
'notification_template': DEFAULT_SUMMARY_FIELDS,
|
'notification_template': DEFAULT_SUMMARY_FIELDS,
|
||||||
'instance_group': {'id', 'name', 'controller_id'},
|
'instance_group': {'id', 'name', 'controller_id'},
|
||||||
'insights_credential': DEFAULT_SUMMARY_FIELDS,
|
'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):
|
class CredentialSerializer(BaseSerializer):
|
||||||
show_capabilities = ['edit', 'delete', 'copy']
|
show_capabilities = ['edit', 'delete', 'copy', 'use']
|
||||||
capabilities_prefetch = ['admin', 'use']
|
capabilities_prefetch = ['admin', 'use']
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -2629,6 +2631,7 @@ class CredentialSerializer(BaseSerializer):
|
|||||||
))
|
))
|
||||||
if self.version > 1:
|
if self.version > 1:
|
||||||
res['copy'] = self.reverse('api:credential_copy', kwargs={'pk': obj.pk})
|
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
|
# TODO: remove when API v1 is removed
|
||||||
if self.version > 1:
|
if self.version > 1:
|
||||||
@@ -2815,6 +2818,32 @@ class CredentialSerializerCreate(CredentialSerializer):
|
|||||||
return credential
|
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 UserCredentialSerializerCreate(CredentialSerializerCreate):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ from awx.api.views import (
|
|||||||
CredentialOwnerUsersList,
|
CredentialOwnerUsersList,
|
||||||
CredentialOwnerTeamsList,
|
CredentialOwnerTeamsList,
|
||||||
CredentialCopy,
|
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_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]+)/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]+)/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']
|
__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,
|
CredentialTypeDetail,
|
||||||
CredentialTypeCredentialList,
|
CredentialTypeCredentialList,
|
||||||
CredentialTypeActivityStreamList,
|
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]+)/$', 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]+)/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]+)/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']
|
__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 .inventory_script import urls as inventory_script_urls
|
||||||
from .credential_type import urls as credential_type_urls
|
from .credential_type import urls as credential_type_urls
|
||||||
from .credential import urls as credential_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 .role import urls as role_urls
|
||||||
from .job_template import urls as job_template_urls
|
from .job_template import urls as job_template_urls
|
||||||
from .job import urls as job_urls
|
from .job import urls as job_urls
|
||||||
@@ -119,6 +120,7 @@ v1_urls = [
|
|||||||
v2_urls = [
|
v2_urls = [
|
||||||
url(r'^$', ApiV2RootView.as_view(), name='api_v2_root_view'),
|
url(r'^$', ApiV2RootView.as_view(), name='api_v2_root_view'),
|
||||||
url(r'^credential_types/', include(credential_type_urls)),
|
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'^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]+)/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'),
|
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
|
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):
|
class HostRelatedSearchMixin(object):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ class ApiVersionRootView(APIView):
|
|||||||
data['credentials'] = reverse('api:credential_list', request=request)
|
data['credentials'] = reverse('api:credential_list', request=request)
|
||||||
if get_request_version(request) > 1:
|
if get_request_version(request) > 1:
|
||||||
data['credential_types'] = reverse('api:credential_type_list', request=request)
|
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['applications'] = reverse('api:o_auth2_application_list', request=request)
|
||||||
data['tokens'] = reverse('api:o_auth2_token_list', request=request)
|
data['tokens'] = reverse('api:o_auth2_token_list', request=request)
|
||||||
data['inventory'] = reverse('api:inventory_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 (
|
from awx.main.models import (
|
||||||
ActivityStream, AdHocCommand, AdHocCommandEvent, Credential, CredentialType,
|
ActivityStream, AdHocCommand, AdHocCommandEvent, Credential, CredentialType,
|
||||||
CustomInventoryScript, Group, Host, Instance, InstanceGroup, Inventory,
|
CredentialInputSource, CustomInventoryScript, Group, Host, Instance, InstanceGroup,
|
||||||
InventorySource, InventoryUpdate, InventoryUpdateEvent, Job, JobEvent,
|
Inventory, InventorySource, InventoryUpdate, InventoryUpdateEvent, Job, JobEvent,
|
||||||
JobHostSummary, JobLaunchConfig, JobTemplate, Label, Notification,
|
JobHostSummary, JobLaunchConfig, JobTemplate, Label, Notification,
|
||||||
NotificationTemplate, Organization, Project, ProjectUpdate,
|
NotificationTemplate, Organization, Project, ProjectUpdate,
|
||||||
ProjectUpdateEvent, Role, Schedule, SystemJob, SystemJobEvent,
|
ProjectUpdateEvent, Role, Schedule, SystemJob, SystemJobEvent,
|
||||||
@@ -426,7 +426,7 @@ class BaseAccess(object):
|
|||||||
if display_method == 'schedule':
|
if display_method == 'schedule':
|
||||||
user_capabilities['schedule'] = user_capabilities['start']
|
user_capabilities['schedule'] = user_capabilities['start']
|
||||||
continue
|
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']
|
user_capabilities['delete'] = user_capabilities['edit']
|
||||||
continue
|
continue
|
||||||
elif display_method == 'copy' and isinstance(obj, (Group, Host)):
|
elif display_method == 'copy' and isinstance(obj, (Group, Host)):
|
||||||
@@ -1162,6 +1162,55 @@ class CredentialAccess(BaseAccess):
|
|||||||
# return True
|
# return True
|
||||||
return self.can_change(obj, None)
|
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):
|
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
|
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):
|
class CredentialInputField(JSONSchemaField):
|
||||||
"""
|
"""
|
||||||
Used to validate JSON for
|
Used to validate JSON for
|
||||||
@@ -592,18 +655,13 @@ class CredentialInputField(JSONSchemaField):
|
|||||||
)
|
)
|
||||||
errors[error.schema['id']] = [error.message]
|
errors[error.schema['id']] = [error.message]
|
||||||
|
|
||||||
inputs = model_instance.credential_type.inputs
|
defined_fields = model_instance.credential_type.defined_fields
|
||||||
for field in inputs.get('required', []):
|
|
||||||
if not value.get(field, None):
|
|
||||||
errors[field] = [_('required for %s') % (
|
|
||||||
model_instance.credential_type.name
|
|
||||||
)]
|
|
||||||
|
|
||||||
# `ssh_key_unlock` requirements are very specific and can't be
|
# `ssh_key_unlock` requirements are very specific and can't be
|
||||||
# represented without complicated JSON schema
|
# represented without complicated JSON schema
|
||||||
if (
|
if (
|
||||||
model_instance.credential_type.managed_by_tower is True and
|
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
|
# 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
|
Organization, Profile, Team, UserSessionMembership
|
||||||
)
|
)
|
||||||
from awx.main.models.credential import ( # noqa
|
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.projects import Project, ProjectUpdate # noqa
|
||||||
from awx.main.models.inventory import ( # noqa
|
from awx.main.models.inventory import ( # noqa
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import functools
|
|||||||
import inspect
|
import inspect
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
from pkg_resources import iter_entry_points
|
||||||
import re
|
import re
|
||||||
import stat
|
import stat
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -17,16 +18,22 @@ from django.db import models
|
|||||||
from django.utils.translation import ugettext_lazy as _, ugettext_noop
|
from django.utils.translation import ugettext_lazy as _, ugettext_noop
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.utils.encoding import force_text
|
from django.utils.encoding import force_text
|
||||||
|
from django.utils.functional import cached_property
|
||||||
|
|
||||||
# AWX
|
# AWX
|
||||||
from awx.api.versioning import reverse
|
from awx.api.versioning import reverse
|
||||||
from awx.main.fields import (ImplicitRoleField, CredentialInputField,
|
from awx.main.fields import (ImplicitRoleField, CredentialInputField,
|
||||||
CredentialTypeInputField,
|
CredentialTypeInputField,
|
||||||
CredentialTypeInjectorField)
|
CredentialTypeInjectorField,
|
||||||
|
DynamicCredentialInputField,)
|
||||||
from awx.main.utils import decrypt_field, classproperty
|
from awx.main.utils import decrypt_field, classproperty
|
||||||
from awx.main.utils.safe_yaml import safe_dump
|
from awx.main.utils.safe_yaml import safe_dump
|
||||||
from awx.main.validators import validate_ssh_private_key
|
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.mixins import ResourceMixin
|
||||||
from awx.main.models.rbac import (
|
from awx.main.models.rbac import (
|
||||||
ROLE_SINGLETON_SYSTEM_ADMINISTRATOR,
|
ROLE_SINGLETON_SYSTEM_ADMINISTRATOR,
|
||||||
@@ -35,9 +42,13 @@ from awx.main.models.rbac import (
|
|||||||
from awx.main.utils import encrypt_field
|
from awx.main.utils import encrypt_field
|
||||||
from . import injectors as builtin_injectors
|
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')
|
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 = '**********'
|
HIDDEN_PASSWORD = '**********'
|
||||||
|
|
||||||
@@ -220,7 +231,6 @@ class V1Credential(object):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin):
|
class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin):
|
||||||
'''
|
'''
|
||||||
A credential contains information about how to talk to a remote resource
|
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')
|
needed.append('vault_password')
|
||||||
return needed
|
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):
|
def _password_field_allows_ask(self, field):
|
||||||
return field in self.credential_type.askable_fields
|
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 field_name(str): The name of the input field.
|
||||||
:param default(optional[str]): A default return value to use.
|
: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:
|
if field_name in self.credential_type.secret_fields:
|
||||||
try:
|
try:
|
||||||
return decrypt_field(self, field_name)
|
return decrypt_field(self, field_name)
|
||||||
@@ -461,8 +477,17 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin):
|
|||||||
raise AttributeError(field_name)
|
raise AttributeError(field_name)
|
||||||
|
|
||||||
def has_input(self, 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)
|
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):
|
class CredentialType(CommonModelNameNotUnique):
|
||||||
'''
|
'''
|
||||||
@@ -484,6 +509,7 @@ class CredentialType(CommonModelNameNotUnique):
|
|||||||
('scm', _('Source Control')),
|
('scm', _('Source Control')),
|
||||||
('cloud', _('Cloud')),
|
('cloud', _('Cloud')),
|
||||||
('insights', _('Insights')),
|
('insights', _('Insights')),
|
||||||
|
('external', _('External')),
|
||||||
)
|
)
|
||||||
|
|
||||||
kind = models.CharField(
|
kind = models.CharField(
|
||||||
@@ -552,6 +578,16 @@ class CredentialType(CommonModelNameNotUnique):
|
|||||||
if field.get('ask_at_runtime', False) is True
|
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):
|
def default_for_field(self, field_id):
|
||||||
for field in self.inputs.get('fields', []):
|
for field in self.inputs.get('fields', []):
|
||||||
if field['id'] == field_id:
|
if field['id'] == field_id:
|
||||||
@@ -583,6 +619,15 @@ class CredentialType(CommonModelNameNotUnique):
|
|||||||
created.inputs = created.injectors = {}
|
created.inputs = created.injectors = {}
|
||||||
created.save()
|
created.save()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load_plugin(cls, ns, plugin):
|
||||||
|
ManagedCredentialType(
|
||||||
|
namespace=ns,
|
||||||
|
name=plugin.name,
|
||||||
|
kind='external',
|
||||||
|
inputs=plugin.inputs
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_v1_kind(cls, kind, data={}):
|
def from_v1_kind(cls, kind, data={}):
|
||||||
match = None
|
match = None
|
||||||
@@ -653,15 +698,15 @@ class CredentialType(CommonModelNameNotUnique):
|
|||||||
# build a normal namespace with secret values decrypted (for
|
# build a normal namespace with secret values decrypted (for
|
||||||
# ansible-playbook) and a safe namespace with secret values hidden (for
|
# ansible-playbook) and a safe namespace with secret values hidden (for
|
||||||
# DB storage)
|
# 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:
|
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
|
safe_namespace[field_name] = namespace[field_name] = value
|
||||||
continue
|
continue
|
||||||
|
|
||||||
value = credential.get_input(field_name)
|
|
||||||
|
|
||||||
if field_name in self.secret_fields:
|
if field_name in self.secret_fields:
|
||||||
safe_namespace[field_name] = '**********'
|
safe_namespace[field_name] = '**********'
|
||||||
elif len(value):
|
elif len(value):
|
||||||
@@ -774,6 +819,12 @@ ManagedCredentialType(
|
|||||||
'format': 'ssh_private_key',
|
'format': 'ssh_private_key',
|
||||||
'secret': True,
|
'secret': True,
|
||||||
'multiline': True
|
'multiline': True
|
||||||
|
}, {
|
||||||
|
'id': 'ssh_public_key_data',
|
||||||
|
'label': ugettext_noop('Signed SSH Certificate'),
|
||||||
|
'type': 'string',
|
||||||
|
'multiline': True,
|
||||||
|
'secret': True,
|
||||||
}, {
|
}, {
|
||||||
'id': 'ssh_key_unlock',
|
'id': 'ssh_key_unlock',
|
||||||
'label': ugettext_noop('Private Key Passphrase'),
|
'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'])
|
self.save(update_fields=['job_explanation'])
|
||||||
return (False, None)
|
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()
|
needed = self.get_passwords_needed_to_start()
|
||||||
try:
|
try:
|
||||||
start_args = json.loads(decrypt_field(self, 'start_args'))
|
start_args = json.loads(decrypt_field(self, 'start_args'))
|
||||||
|
|||||||
@@ -772,7 +772,12 @@ class BaseTask(object):
|
|||||||
'credentials': {
|
'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',
|
<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).
|
# and we're running an earlier version (<6.5).
|
||||||
if 'OPENSSH PRIVATE KEY' in data and not openssh_keys_supported:
|
if 'OPENSSH PRIVATE KEY' in data and not openssh_keys_supported:
|
||||||
raise RuntimeError(OPENSSH_KEY_ERROR)
|
raise RuntimeError(OPENSSH_KEY_ERROR)
|
||||||
for credential, data in private_data.get('credentials', {}).items():
|
|
||||||
# OpenSSH formatted keys must have a trailing newline to be
|
# OpenSSH formatted keys must have a trailing newline to be
|
||||||
# accepted by ssh-add.
|
# accepted by ssh-add.
|
||||||
if 'OPENSSH PRIVATE KEY' in data and not data.endswith('\n'):
|
if 'OPENSSH PRIVATE KEY' in data and not data.endswith('\n'):
|
||||||
@@ -813,6 +817,13 @@ class BaseTask(object):
|
|||||||
f.close()
|
f.close()
|
||||||
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
|
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
|
||||||
private_data_files['credentials'][credential] = path
|
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
|
return private_data_files
|
||||||
|
|
||||||
def build_passwords(self, instance, runtime_passwords):
|
def build_passwords(self, instance, runtime_passwords):
|
||||||
@@ -1269,16 +1280,23 @@ class RunJob(BaseTask):
|
|||||||
'credentials': {
|
'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>,
|
<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': {}}
|
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
|
# If we were sent SSH credentials, decrypt them and send them
|
||||||
# back (they will be written to a temporary file).
|
# back (they will be written to a temporary file).
|
||||||
if credential.has_input('ssh_key_data'):
|
if credential.has_input('ssh_key_data'):
|
||||||
private_data['credentials'][credential] = credential.get_input('ssh_key_data', default='')
|
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':
|
if credential.kind == 'openstack':
|
||||||
openstack_auth = dict(auth_url=credential.get_input('host', default=''),
|
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)
|
return self._write_extra_vars_file(private_data_dir, extra_vars, safe_dict)
|
||||||
|
|
||||||
def build_credentials_list(self, job):
|
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={}):
|
def get_password_prompts(self, passwords={}):
|
||||||
d = super(RunJob, self).get_password_prompts(passwords)
|
d = super(RunJob, self).get_password_prompts(passwords)
|
||||||
@@ -2187,7 +2205,12 @@ class RunAdHocCommand(BaseTask):
|
|||||||
'credentials': {
|
'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>,
|
<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': {}}
|
private_data = {'credentials': {}}
|
||||||
if creds and creds.has_input('ssh_key_data'):
|
if creds and creds.has_input('ssh_key_data'):
|
||||||
private_data['credentials'][creds] = creds.get_input('ssh_key_data', default='')
|
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
|
return private_data
|
||||||
|
|
||||||
def build_passwords(self, ad_hoc_command, runtime_passwords):
|
def build_passwords(self, ad_hoc_command, runtime_passwords):
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import pytest
|
|||||||
from unittest import mock
|
from unittest import mock
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
from awx.main.models import Credential
|
||||||
from awx.main.tests.factories import (
|
from awx.main.tests.factories import (
|
||||||
create_organization,
|
create_organization,
|
||||||
create_job_template,
|
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
|
# this is a local test cache, so we want every test to start with empty cache
|
||||||
cache.clear()
|
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,
|
'credential_type': net.pk,
|
||||||
'organization': organization.id,
|
'organization': organization.id,
|
||||||
'inputs': {
|
'inputs': {
|
||||||
'username': 'joe-user', # username is required
|
'username': 'joe-user',
|
||||||
'authorize': field_value
|
'authorize': field_value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -952,9 +952,15 @@ def test_vault_password_required(post, organization, admin):
|
|||||||
},
|
},
|
||||||
admin
|
admin
|
||||||
)
|
)
|
||||||
assert response.status_code == 400
|
assert response.status_code == 201
|
||||||
assert response.data['inputs'] == {'vault_password': ['required for Vault']}
|
assert Credential.objects.count() == 1
|
||||||
assert Credential.objects.count() == 0
|
|
||||||
|
# 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,
|
params,
|
||||||
admin
|
admin
|
||||||
)
|
)
|
||||||
assert response.status_code == 400
|
assert response.status_code == 201
|
||||||
|
assert Credential.objects.count() == 1
|
||||||
|
|
||||||
assert Credential.objects.count() == 0
|
# username and password must be specified by launch time
|
||||||
errors = response.data
|
j = Job()
|
||||||
if version == 'v2':
|
j.save()
|
||||||
errors = response.data['inputs']
|
j.credentials.add(Credential.objects.first())
|
||||||
assert errors['username'] == ['required for %s' % aws.name]
|
assert j.pre_start() == (False, None)
|
||||||
assert errors['password'] == ['required for %s' % aws.name]
|
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,
|
params,
|
||||||
admin
|
admin
|
||||||
)
|
)
|
||||||
assert response.status_code == 400
|
assert response.status_code == 201
|
||||||
|
assert Credential.objects.count() == 1
|
||||||
|
|
||||||
assert Credential.objects.count() == 0
|
# username, password, and host must be specified by launch time
|
||||||
errors = response.data
|
j = Job()
|
||||||
if version == 'v2':
|
j.save()
|
||||||
errors = response.data['inputs']
|
j.credentials.add(Credential.objects.first())
|
||||||
assert errors['username'] == ['required for %s' % vmware.name]
|
assert j.pre_start() == (False, None)
|
||||||
assert errors['password'] == ['required for %s' % vmware.name]
|
assert 'required fields (host, password, username)' in j.job_explanation
|
||||||
assert errors['host'] == ['required for %s' % vmware.name]
|
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
@@ -1406,14 +1413,14 @@ def test_openstack_create_fail_required_fields(post, organization, admin, versio
|
|||||||
params,
|
params,
|
||||||
admin
|
admin
|
||||||
)
|
)
|
||||||
assert response.status_code == 400
|
assert response.status_code == 201
|
||||||
errors = response.data
|
|
||||||
if version == 'v2':
|
# username, password, host, and project must be specified by launch time
|
||||||
errors = response.data['inputs']
|
j = Job()
|
||||||
assert errors['username'] == ['required for %s' % openstack.name]
|
j.save()
|
||||||
assert errors['password'] == ['required for %s' % openstack.name]
|
j.credentials.add(Credential.objects.first())
|
||||||
assert errors['host'] == ['required for %s' % openstack.name]
|
assert j.pre_start() == (False, None)
|
||||||
assert errors['project'] == ['required for %s' % openstack.name]
|
assert 'required fields (host, password, project, username)' in j.job_explanation
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@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
|
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
|
@pytest.fixture
|
||||||
def credential(credentialtype_aws):
|
def credential(credentialtype_aws):
|
||||||
return Credential.objects.create(credential_type=credentialtype_aws, name='test-cred',
|
return Credential.objects.create(credential_type=credentialtype_aws, name='test-cred',
|
||||||
@@ -293,6 +329,18 @@ def org_credential(organization, credentialtype_aws):
|
|||||||
organization=organization)
|
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
|
@pytest.fixture
|
||||||
def inventory(organization):
|
def inventory(organization):
|
||||||
return organization.inventories.create(name="test-inv")
|
return organization.inventories.create(name="test-inv")
|
||||||
|
|||||||
@@ -75,10 +75,15 @@ GLqbpJyX2r3p/Rmo6mLY71SqpA==
|
|||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_default_cred_types():
|
def test_default_cred_types():
|
||||||
assert sorted(CredentialType.defaults.keys()) == [
|
assert sorted(CredentialType.defaults.keys()) == [
|
||||||
|
'aim',
|
||||||
'aws',
|
'aws',
|
||||||
|
'azure_kv',
|
||||||
'azure_rm',
|
'azure_rm',
|
||||||
'cloudforms',
|
'cloudforms',
|
||||||
|
'conjur',
|
||||||
'gce',
|
'gce',
|
||||||
|
'hashivault_kv',
|
||||||
|
'hashivault_ssh',
|
||||||
'insights',
|
'insights',
|
||||||
'net',
|
'net',
|
||||||
'openstack',
|
'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.django_db
|
||||||
@pytest.mark.parametrize('inputs, valid', [
|
@pytest.mark.parametrize('inputs, valid', [
|
||||||
({'vault_password': 'some-pass'}, True),
|
({'vault_password': 'some-pass'}, True),
|
||||||
({}, False),
|
({}, True),
|
||||||
({'vault_password': 'dev-pass', 'vault_id': 'dev'}, True),
|
({'vault_password': 'dev-pass', 'vault_id': 'dev'}, True),
|
||||||
({'vault_password': 'dev-pass', 'vault_id': 'dev@prompt'}, False), # @ not allowed
|
({'vault_password': 'dev-pass', 'vault_id': 'dev@prompt'}, False), # @ not allowed
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -700,7 +700,8 @@ class TestJobCredentials(TestJobExecution):
|
|||||||
__iter__ = lambda *args: iter(job._credentials),
|
__iter__ = lambda *args: iter(job._credentials),
|
||||||
first = lambda: job._credentials[0]
|
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):
|
with mock.patch.object(UnifiedJob, 'credentials', credentials_mock):
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
@import 'portalMode/_index';
|
@import 'portalMode/_index';
|
||||||
@import 'output/_index';
|
@import 'output/_index';
|
||||||
|
@import 'credentials/_index';
|
||||||
|
|
||||||
/** @define Popup Modal after create new token and applicaiton and save form */
|
/** @define Popup Modal after create new token and applicaiton and save form */
|
||||||
.PopupModal {
|
.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-input-group>
|
||||||
|
|
||||||
<at-action-group col="12" pos="right">
|
<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="cancel" to="credentials"></at-form-action>
|
||||||
<at-form-action type="save"></at-form-action>
|
<at-form-action type="save"></at-form-action>
|
||||||
</at-action-group>
|
</at-action-group>
|
||||||
@@ -42,5 +43,24 @@
|
|||||||
<div class="at-CredentialsPermissions" ui-view="related"></div>
|
<div class="at-CredentialsPermissions" ui-view="related"></div>
|
||||||
</at-panel-body>
|
</at-panel-body>
|
||||||
</at-panel>
|
</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>
|
<div ng-if="$state.current.name.includes('permissions.add')" ui-view="modal"></div>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ function CredentialsStrings (BaseString) {
|
|||||||
|
|
||||||
ns.tab = {
|
ns.tab = {
|
||||||
DETAILS: t.s('Details'),
|
DETAILS: t.s('Details'),
|
||||||
PERMISSIONS: t.s('Permissions')
|
PERMISSIONS: t.s('Permissions'),
|
||||||
};
|
};
|
||||||
|
|
||||||
ns.inputs = {
|
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.')
|
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 = {
|
ns.add = {
|
||||||
PANEL_TITLE: t.s('NEW CREDENTIAL')
|
PANEL_TITLE: t.s('NEW CREDENTIAL')
|
||||||
};
|
};
|
||||||
|
|
||||||
|
ns.edit = {
|
||||||
|
TEST_PASSED: t.s('Test passed.'),
|
||||||
|
TEST_FAILED: t.s('Test failed.')
|
||||||
|
};
|
||||||
|
|
||||||
ns.permissions = {
|
ns.permissions = {
|
||||||
TITLE: t.s('CREDENTIALS 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 LegacyCredentials from './legacy.credentials';
|
||||||
import AddController from './add-credentials.controller';
|
import AddEditController from './add-edit-credentials.controller';
|
||||||
import EditController from './edit-credentials.controller';
|
|
||||||
import CredentialsStrings from './credentials.strings';
|
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';
|
const MODULE_NAME = 'at.features.credentials';
|
||||||
|
|
||||||
@@ -15,7 +16,9 @@ function CredentialsResolve (
|
|||||||
CredentialType,
|
CredentialType,
|
||||||
Organization,
|
Organization,
|
||||||
ProcessErrors,
|
ProcessErrors,
|
||||||
strings
|
strings,
|
||||||
|
Rest,
|
||||||
|
GetBasePath,
|
||||||
) {
|
) {
|
||||||
const id = $stateParams.credential_id;
|
const id = $stateParams.credential_id;
|
||||||
|
|
||||||
@@ -27,6 +30,7 @@ function CredentialsResolve (
|
|||||||
promises.credential = new Credential('options');
|
promises.credential = new Credential('options');
|
||||||
promises.credentialType = new CredentialType();
|
promises.credentialType = new CredentialType();
|
||||||
promises.organization = new Organization();
|
promises.organization = new Organization();
|
||||||
|
promises.sourceCredentials = $q.resolve({ data: { count: 0, results: [] } });
|
||||||
|
|
||||||
return $q.all(promises);
|
return $q.all(promises);
|
||||||
}
|
}
|
||||||
@@ -38,17 +42,32 @@ function CredentialsResolve (
|
|||||||
const typeId = models.credential.get('credential_type');
|
const typeId = models.credential.get('credential_type');
|
||||||
const orgId = models.credential.get('organization');
|
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 = {
|
const dependents = {
|
||||||
credentialType: new CredentialType('get', typeId),
|
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' }));
|
dependents.isOrgCredAdmin = dependents.organization.then((org) => org.search({ role_level: 'credential_admin_role' }));
|
||||||
|
|
||||||
return $q.all(dependents)
|
return $q.all(dependents)
|
||||||
.then(related => {
|
.then(related => {
|
||||||
models.credentialType = related.credentialType;
|
models.credentialType = related.credentialType;
|
||||||
models.organization = related.organization;
|
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;
|
return models;
|
||||||
});
|
});
|
||||||
@@ -69,7 +88,9 @@ CredentialsResolve.$inject = [
|
|||||||
'CredentialTypeModel',
|
'CredentialTypeModel',
|
||||||
'OrganizationModel',
|
'OrganizationModel',
|
||||||
'ProcessErrors',
|
'ProcessErrors',
|
||||||
'CredentialsStrings'
|
'CredentialsStrings',
|
||||||
|
'Rest',
|
||||||
|
'GetBasePath',
|
||||||
];
|
];
|
||||||
|
|
||||||
function CredentialsRun ($stateExtender, legacy, strings) {
|
function CredentialsRun ($stateExtender, legacy, strings) {
|
||||||
@@ -86,7 +107,7 @@ function CredentialsRun ($stateExtender, legacy, strings) {
|
|||||||
views: {
|
views: {
|
||||||
'add@credentials': {
|
'add@credentials': {
|
||||||
templateUrl: addEditTemplate,
|
templateUrl: addEditTemplate,
|
||||||
controller: AddController,
|
controller: AddEditController,
|
||||||
controllerAs: 'vm'
|
controllerAs: 'vm'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -109,7 +130,7 @@ function CredentialsRun ($stateExtender, legacy, strings) {
|
|||||||
views: {
|
views: {
|
||||||
'edit@credentials': {
|
'edit@credentials': {
|
||||||
templateUrl: addEditTemplate,
|
templateUrl: addEditTemplate,
|
||||||
controller: EditController,
|
controller: AddEditController,
|
||||||
controllerAs: 'vm'
|
controllerAs: 'vm'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -135,10 +156,11 @@ CredentialsRun.$inject = [
|
|||||||
|
|
||||||
angular
|
angular
|
||||||
.module(MODULE_NAME, [])
|
.module(MODULE_NAME, [])
|
||||||
.controller('AddController', AddController)
|
.controller('AddEditController', AddEditController)
|
||||||
.controller('EditController', EditController)
|
|
||||||
.service('LegacyCredentialsService', LegacyCredentials)
|
.service('LegacyCredentialsService', LegacyCredentials)
|
||||||
.service('CredentialsStrings', CredentialsStrings)
|
.service('CredentialsStrings', CredentialsStrings)
|
||||||
|
.component('atInputSourceLookup', InputSourceLookupComponent)
|
||||||
|
.component('atExternalCredentialTest', ExternalTestModalComponent)
|
||||||
.run(CredentialsRun);
|
.run(CredentialsRun);
|
||||||
|
|
||||||
export default MODULE_NAME;
|
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) {
|
function buildCredentialDetails (credential) {
|
||||||
const icon = `${credential.kind}`;
|
let icon;
|
||||||
|
if (credential.cloud) {
|
||||||
|
icon = 'cloud';
|
||||||
|
} else {
|
||||||
|
icon = `${credential.kind}`;
|
||||||
|
}
|
||||||
|
|
||||||
const link = `/#/credentials/${credential.id}`;
|
const link = `/#/credentials/${credential.id}`;
|
||||||
const tooltip = strings.get('tooltips.CREDENTIAL');
|
const tooltip = strings.get('tooltips.CREDENTIAL');
|
||||||
const value = $filter('sanitize')(credential.name);
|
const value = $filter('sanitize')(credential.name);
|
||||||
|
|||||||
@@ -2262,6 +2262,7 @@ body {
|
|||||||
.Toast-wrapper {
|
.Toast-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
max-width: 250px;
|
max-width: 250px;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.Toast-icon {
|
.Toast-icon {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
@import 'action/_index';
|
@import 'action/_index';
|
||||||
|
@import 'dialog/_index';
|
||||||
@import 'input/_index';
|
@import 'input/_index';
|
||||||
@import 'launchTemplateButton/_index';
|
@import 'launchTemplateButton/_index';
|
||||||
@import 'layout/_index';
|
@import 'layout/_index';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
.at-ActionGroup {
|
.at-ActionGroup {
|
||||||
margin-top: @at-margin-panel;
|
margin-top: @at-margin-panel;
|
||||||
|
|
||||||
button:last-child {
|
button {
|
||||||
margin-left: @at-margin-panel-inset;
|
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 { t } = this;
|
||||||
const ns = this.components;
|
const ns = this.components;
|
||||||
|
|
||||||
ns.REPLACE = t.s('REPLACE');
|
ns.REPLACE = t.s('Replace');
|
||||||
ns.REVERT = t.s('REVERT');
|
ns.REVERT = t.s('Revert');
|
||||||
ns.ENCRYPTED = t.s('ENCRYPTED');
|
ns.ENCRYPTED = t.s('ENCRYPTED');
|
||||||
ns.OPTIONS = t.s('OPTIONS');
|
ns.OPTIONS = t.s('OPTIONS');
|
||||||
ns.SHOW = t.s('SHOW');
|
ns.SHOW = t.s('Show');
|
||||||
ns.HIDE = t.s('HIDE');
|
ns.HIDE = t.s('Hide');
|
||||||
|
|
||||||
ns.message = {
|
ns.message = {
|
||||||
REQUIRED_INPUT_MISSING: t.s('Please enter a value.'),
|
REQUIRED_INPUT_MISSING: t.s('Please enter a value.'),
|
||||||
@@ -40,7 +40,7 @@ function ComponentsStrings (BaseString) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
ns.textarea = {
|
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 = {
|
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':
|
case 'save':
|
||||||
vm.setSaveDefaults();
|
vm.setSaveDefaults();
|
||||||
break;
|
break;
|
||||||
|
case 'secondary':
|
||||||
|
vm.setSecondaryDefaults();
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
vm.setCustomDefaults();
|
vm.setCustomDefaults();
|
||||||
}
|
}
|
||||||
@@ -43,6 +46,13 @@ function atFormActionController ($state, strings) {
|
|||||||
scope.color = 'success';
|
scope.color = 'success';
|
||||||
scope.action = () => { form.submit(); };
|
scope.action = () => { form.submit(); };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
vm.setSecondaryDefaults = () => {
|
||||||
|
scope.text = strings.get('TEST');
|
||||||
|
scope.fill = '';
|
||||||
|
scope.color = 'info';
|
||||||
|
scope.action = () => { form.submitSecondary(); };
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
atFormActionController.$inject = ['$state', 'ComponentsStrings'];
|
atFormActionController.$inject = ['$state', 'ComponentsStrings'];
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<button class="btn at-Button{{ fill }}--{{ color }}"
|
<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()">
|
ng-click="action()">
|
||||||
{{::text}}
|
{{::text}}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ function AtFormController (eventService, strings) {
|
|||||||
({ modal } = scope[scope.ns]);
|
({ modal } = scope[scope.ns]);
|
||||||
|
|
||||||
vm.state.disabled = scope.state.disabled;
|
vm.state.disabled = scope.state.disabled;
|
||||||
|
|
||||||
vm.setListeners();
|
vm.setListeners();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -62,6 +61,35 @@ function AtFormController (eventService, strings) {
|
|||||||
scope.$apply(vm.submit);
|
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 = () => {
|
vm.submit = () => {
|
||||||
if (!vm.state.isValid) {
|
if (!vm.state.isValid) {
|
||||||
return;
|
return;
|
||||||
@@ -69,26 +97,7 @@ function AtFormController (eventService, strings) {
|
|||||||
|
|
||||||
vm.state.disabled = true;
|
vm.state.disabled = true;
|
||||||
|
|
||||||
const data = vm.components
|
const data = vm.getSubmitData();
|
||||||
.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;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
scope.state.save(data)
|
scope.state.save(data)
|
||||||
.then(scope.state.onSaveSuccess)
|
.then(scope.state.onSaveSuccess)
|
||||||
@@ -179,6 +188,10 @@ function AtFormController (eventService, strings) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (vm.components[i].state.asTag) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (!vm.components[i].state._isValid) {
|
if (!vm.components[i].state._isValid) {
|
||||||
isValid = false;
|
isValid = false;
|
||||||
break;
|
break;
|
||||||
@@ -194,6 +207,10 @@ function AtFormController (eventService, strings) {
|
|||||||
if (isValid !== vm.state.isValid) {
|
if (isValid !== vm.state.isValid) {
|
||||||
vm.state.isValid = isValid;
|
vm.state.isValid = isValid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isValid !== scope.state.isValid) {
|
||||||
|
scope.state.isValid = isValid;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
vm.deregisterInputGroup = components => {
|
vm.deregisterInputGroup = components => {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import atLibServices from '~services';
|
import atLibServices from '~services';
|
||||||
|
|
||||||
import actionGroup from '~components/action/action-group.directive';
|
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 divider from '~components/utility/divider.directive';
|
||||||
import dynamicSelect from '~components/input/dynamic-select.directive';
|
import dynamicSelect from '~components/input/dynamic-select.directive';
|
||||||
import form from '~components/form/form.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 launchTemplate from '~components/launchTemplateButton/launchTemplateButton.component';
|
||||||
import layout from '~components/layout/layout.directive';
|
import layout from '~components/layout/layout.directive';
|
||||||
import list from '~components/list/list.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 modal from '~components/modal/modal.directive';
|
||||||
import panel from '~components/panel/panel.directive';
|
import panel from '~components/panel/panel.directive';
|
||||||
import panelBody from '~components/panel/body.directive';
|
import panelBody from '~components/panel/body.directive';
|
||||||
@@ -53,6 +56,8 @@ angular
|
|||||||
atCodeMirror
|
atCodeMirror
|
||||||
])
|
])
|
||||||
.directive('atActionGroup', actionGroup)
|
.directive('atActionGroup', actionGroup)
|
||||||
|
.directive('atActionButton', actionButton)
|
||||||
|
.component('atDialog', dialog)
|
||||||
.directive('atDivider', divider)
|
.directive('atDivider', divider)
|
||||||
.directive('atDynamicSelect', dynamicSelect)
|
.directive('atDynamicSelect', dynamicSelect)
|
||||||
.directive('atForm', form)
|
.directive('atForm', form)
|
||||||
@@ -72,6 +77,7 @@ angular
|
|||||||
.component('atLaunchTemplate', launchTemplate)
|
.component('atLaunchTemplate', launchTemplate)
|
||||||
.directive('atLayout', layout)
|
.directive('atLayout', layout)
|
||||||
.directive('atList', list)
|
.directive('atList', list)
|
||||||
|
.component('atLookupList', lookupList)
|
||||||
.directive('atListToolbar', toolbar)
|
.directive('atListToolbar', toolbar)
|
||||||
.component('atRelaunch', relaunch)
|
.component('atRelaunch', relaunch)
|
||||||
.directive('atRow', row)
|
.directive('atRow', row)
|
||||||
|
|||||||
@@ -75,6 +75,12 @@
|
|||||||
height: @at-height-textarea;
|
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-Input-button--active {
|
||||||
.at-mixin-ButtonColor(at-color-info, at-color-default);
|
.at-mixin-ButtonColor(at-color-info, at-color-default);
|
||||||
}
|
}
|
||||||
@@ -328,3 +334,26 @@
|
|||||||
margin: 0;
|
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 isValid = true;
|
||||||
let message = '';
|
let message = '';
|
||||||
|
|
||||||
|
if (scope.state.asTag) {
|
||||||
|
return (isValid, message);
|
||||||
|
}
|
||||||
|
|
||||||
if (scope.state._value || scope.state._displayValue) {
|
if (scope.state._value || scope.state._displayValue) {
|
||||||
scope.state._touched = true;
|
scope.state._touched = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ function AtInputGroupController ($scope, $compile) {
|
|||||||
|
|
||||||
state._value = source._value;
|
state._value = source._value;
|
||||||
|
|
||||||
const inputs = state._get(source._value);
|
const inputs = state._get(form);
|
||||||
const group = vm.createComponentConfigs(inputs);
|
const group = vm.createComponentConfigs(inputs);
|
||||||
|
|
||||||
vm.insert(group);
|
vm.insert(group);
|
||||||
@@ -66,7 +66,9 @@ function AtInputGroupController ($scope, $compile) {
|
|||||||
_element: vm.createComponent(input, i),
|
_element: vm.createComponent(input, i),
|
||||||
_key: 'inputs',
|
_key: 'inputs',
|
||||||
_group: true,
|
_group: true,
|
||||||
_groupIndex: i
|
_groupIndex: i,
|
||||||
|
_onInputLookup: state._onInputLookup,
|
||||||
|
_onRemoveTag: state._onRemoveTag,
|
||||||
}, input));
|
}, input));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -97,6 +99,7 @@ function AtInputGroupController ($scope, $compile) {
|
|||||||
|
|
||||||
if (input.secret) {
|
if (input.secret) {
|
||||||
config._component = 'at-input-textarea-secret';
|
config._component = 'at-input-textarea-secret';
|
||||||
|
input.format = 'ssh_private_key';
|
||||||
} else {
|
} else {
|
||||||
config._component = 'at-input-textarea';
|
config._component = 'at-input-textarea';
|
||||||
}
|
}
|
||||||
@@ -111,12 +114,16 @@ function AtInputGroupController ($scope, $compile) {
|
|||||||
config._component = 'at-input-checkbox';
|
config._component = 'at-input-checkbox';
|
||||||
} else if (input.type === 'file') {
|
} else if (input.type === 'file') {
|
||||||
config._component = 'at-input-file';
|
config._component = 'at-input-file';
|
||||||
} else if (input.choices) {
|
}
|
||||||
|
|
||||||
|
if (input.choices) {
|
||||||
config._component = 'at-input-select';
|
config._component = 'at-input-select';
|
||||||
config._format = 'array';
|
config._format = 'array';
|
||||||
config._data = input.choices;
|
config._data = input.choices;
|
||||||
config._exp = 'choice for (index, choice) in state._data';
|
config._exp = 'choice for (index, choice) in state._data';
|
||||||
} else {
|
}
|
||||||
|
|
||||||
|
if (!config._component) {
|
||||||
const preface = vm.strings.get('group.UNSUPPORTED_ERROR_PREFACE');
|
const preface = vm.strings.get('group.UNSUPPORTED_ERROR_PREFACE');
|
||||||
throw new Error(`${preface}: ${input.type}`);
|
throw new Error(`${preface}: ${input.type}`);
|
||||||
}
|
}
|
||||||
@@ -160,7 +167,6 @@ function AtInputGroupController ($scope, $compile) {
|
|||||||
</${input._component}>`);
|
</${input._component}>`);
|
||||||
|
|
||||||
$compile(component)(scope.$parent);
|
$compile(component)(scope.$parent);
|
||||||
|
|
||||||
return component;
|
return component;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<div ng-show="state._group" class="col-sm-12 at-InputGroup">
|
<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="at-InputGroup-inset">
|
||||||
<div class="row">
|
<div ng-if="state.title" class="row">
|
||||||
<div class="col-sm-12">
|
<div class="col-sm-12">
|
||||||
<h4 class="at-InputGroup-title">
|
<h4 class="at-InputGroup-title">
|
||||||
<ng-transclude></ng-transclude>
|
<ng-transclude></ng-transclude>
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
<span ng-if="state.required" class="at-InputLabel-required">*</span>
|
<span ng-if="state.required" class="at-InputLabel-required">*</span>
|
||||||
<span class="at-InputLabel-name" >{{::state.label | translate}}</span>
|
<span class="at-InputLabel-name" >{{::state.label | translate}}</span>
|
||||||
<at-popover state="state"></at-popover>
|
<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">
|
<div ng-if="state._displayPromptOnLaunch" class="at-InputLabel-checkbox pull-right">
|
||||||
<label class="at-InputLabel-checkboxLabel">
|
<label class="at-InputLabel-checkboxLabel">
|
||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
|
ng-disabled="state.asTag"
|
||||||
ng-model="state._promptOnLaunch"
|
ng-model="state._promptOnLaunch"
|
||||||
ng-change="vm.togglePromptOnLaunch()" />
|
ng-change="vm.togglePromptOnLaunch()" />
|
||||||
<p>{{:: vm.strings.get('label.PROMPT_ON_LAUNCH') }}</p>
|
<p>{{:: vm.strings.get('label.PROMPT_ON_LAUNCH') }}</p>
|
||||||
|
|||||||
@@ -21,17 +21,15 @@ function AtInputSecretController (baseInputController) {
|
|||||||
|
|
||||||
scope = _scope_;
|
scope = _scope_;
|
||||||
scope.type = 'password';
|
scope.type = 'password';
|
||||||
|
scope.state._show = false;
|
||||||
|
scope.state._showHideText = vm.strings.get('SHOW');
|
||||||
|
|
||||||
if (!scope.state._value || scope.state._promptOnLaunch) {
|
if (!scope.state._value || scope.state._promptOnLaunch) {
|
||||||
scope.mode = 'input';
|
scope.mode = 'input';
|
||||||
scope.state._buttonText = vm.strings.get('SHOW');
|
|
||||||
|
|
||||||
vm.toggle = vm.toggleShowHide;
|
|
||||||
} else {
|
} else {
|
||||||
scope.mode = 'encrypted';
|
scope.mode = 'encrypted';
|
||||||
scope.state._buttonText = vm.strings.get('REPLACE');
|
|
||||||
scope.state._placeholder = vm.strings.get('ENCRYPTED');
|
scope.state._placeholder = vm.strings.get('ENCRYPTED');
|
||||||
vm.toggle = vm.toggleRevertReplace;
|
scope.state._buttonText = vm.strings.get('REPLACE');
|
||||||
}
|
}
|
||||||
|
|
||||||
vm.check();
|
vm.check();
|
||||||
@@ -41,15 +39,30 @@ function AtInputSecretController (baseInputController) {
|
|||||||
scope.state._isBeingReplaced = !scope.state._isBeingReplaced;
|
scope.state._isBeingReplaced = !scope.state._isBeingReplaced;
|
||||||
|
|
||||||
vm.onRevertReplaceToggle();
|
vm.onRevertReplaceToggle();
|
||||||
|
|
||||||
|
if (scope.state._isBeingReplaced) {
|
||||||
|
if (scope.type !== 'password') {
|
||||||
|
vm.toggleShowHide();
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
vm.toggleShowHide = () => {
|
vm.toggleShowHide = () => {
|
||||||
if (scope.type === 'password') {
|
if (scope.type === 'password') {
|
||||||
scope.type = 'text';
|
scope.type = 'text';
|
||||||
scope.state._buttonText = vm.strings.get('HIDE');
|
scope.state._show = true;
|
||||||
|
scope.state._showHideText = vm.strings.get('HIDE');
|
||||||
} else {
|
} else {
|
||||||
scope.type = 'password';
|
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>
|
<at-input-label></at-input-label>
|
||||||
|
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-btn at-InputGroup-button input-group-prepend">
|
<span ng-if="state.tagMode" class="input-group-btn input-group-prepend">
|
||||||
<button class="btn at-ButtonHollow--white"
|
<button
|
||||||
ng-class="{
|
class="btn at-ButtonHollow--default at-Input-button"
|
||||||
'at-Input-button--fixed-xs': mode === 'input',
|
ng-disabled="state._disabled || form.disabled"
|
||||||
'at-Input-button--fixed-sm': mode === 'encrypted'
|
ng-click="vm.onLookupClick()">
|
||||||
}"
|
<i class="fa fa-search"></i>
|
||||||
ng-disabled="!state._enableToggle && (state._disabled || form.disabled)"
|
|
||||||
ng-click="vm.toggle()">
|
|
||||||
{{ state._buttonText }}
|
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</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"
|
class="form-control at-Input"
|
||||||
ng-model="state[state._activeModel]"
|
ng-model="state[state._activeModel]"
|
||||||
ng-class="{ 'at-Input--rejected': state._rejected }"
|
ng-class="{ 'at-Input--rejected': state._rejected }"
|
||||||
ng-attr-maxlength="{{ state.max_length || undefined }}"
|
ng-attr-maxlength="{{ state.max_length || undefined }}"
|
||||||
ng-attr-tabindex="{{ tab || 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-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>
|
</div>
|
||||||
|
|
||||||
<at-input-message></at-input-message>
|
<at-input-message></at-input-message>
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ function atInputTextLink (scope, element, attrs, controllers) {
|
|||||||
const inputController = controllers[1];
|
const inputController = controllers[1];
|
||||||
|
|
||||||
if (scope.tab === '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);
|
inputController.init(scope, element, formController);
|
||||||
@@ -20,9 +23,15 @@ function AtInputTextController (baseInputController) {
|
|||||||
baseInputController.call(vm, 'input', _scope_, element, form);
|
baseInputController.call(vm, 'input', _scope_, element, form);
|
||||||
scope = _scope_;
|
scope = _scope_;
|
||||||
|
|
||||||
vm.check();
|
|
||||||
scope.$watch('state._value', () => 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'];
|
AtInputTextController.$inject = ['BaseInputController'];
|
||||||
|
|||||||
@@ -1,15 +1,51 @@
|
|||||||
<div class="col-sm-{{::col}} at-InputContainer">
|
<div class="col-sm-{{::col}} at-InputContainer">
|
||||||
<div class="form-group at-u-flat">
|
<div class="form-group at-u-flat">
|
||||||
<at-input-label></at-input-label>
|
<at-input-label></at-input-label>
|
||||||
|
<div ng-if="state.tagMode" class="input-group">
|
||||||
<input type="text" class="form-control at-Input"
|
<span class="input-group-btn input-group-prepend">
|
||||||
ng-class="{ 'at-Input--rejected': state._rejected }"
|
<button
|
||||||
ng-model="state._value"
|
class="btn at-ButtonHollow--default at-Input-button"
|
||||||
ng-attr-maxlength="{{ state.max_length || undefined }}"
|
ng-disabled="state._disabled || form.disabled"
|
||||||
ng-attr-tabindex="{{ tab || undefined }}"
|
ng-click="vm.onLookupClick()">
|
||||||
ng-attr-placeholder="{{::state._placeholder || undefined }}"
|
<i class="fa fa-search"></i>
|
||||||
ng-disabled="state._disabled || form.disabled" />
|
</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>
|
<at-input-message></at-input-message>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ function AtInputTextareaSecretController (baseInputController, eventService) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
vm.onIsBeingReplacedChanged = () => {
|
vm.onIsBeingReplacedChanged = () => {
|
||||||
|
if (!scope.state) return;
|
||||||
if (!scope.state._touched) return;
|
if (!scope.state._touched) return;
|
||||||
|
|
||||||
vm.onRevertReplaceToggle();
|
vm.onRevertReplaceToggle();
|
||||||
@@ -92,6 +93,13 @@ function AtInputTextareaSecretController (baseInputController, eventService) {
|
|||||||
input.value = '';
|
input.value = '';
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
vm.onLookupClick = () => {
|
||||||
|
if (scope.state._onInputLookup) {
|
||||||
|
const { id, label, required, type } = scope.state;
|
||||||
|
scope.state._onInputLookup({ id, label, required, type });
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
AtInputTextareaSecretController.$inject = [
|
AtInputTextareaSecretController.$inject = [
|
||||||
|
|||||||
@@ -1,32 +1,68 @@
|
|||||||
<div class="col-sm-{{::col}} at-InputContainer">
|
<div class="col-sm-{{::col}} at-InputContainer">
|
||||||
<div class="form-group at-u-flat">
|
<div class="form-group at-u-flat">
|
||||||
<at-input-label></at-input-label>
|
<at-input-label></at-input-label>
|
||||||
|
<div class="input-group">
|
||||||
<div ng-class="{ 'input-group': state._edit }">
|
<div class="input-group-btn at-InputGroup-button input-group-prepend" ng-show="state.tagMode">
|
||||||
<div ng-if="state._edit" class="input-group-btn at-InputGroup-button input-group-prepend">
|
<button
|
||||||
<button class="btn at-ButtonHollow--white at-Input-button--fixed-md"
|
class="btn at-ButtonHollow--white at-Input-button--long-sm"
|
||||||
ng-disabled="!state._enableToggle && (state._disabled || form.disabled)"
|
ng-disabled="state._disabled || form.disabled"
|
||||||
ng-click="state._isBeingReplaced = !state._isBeingReplaced">
|
ng-click="vm.onLookupClick()"
|
||||||
{{ state._buttonText }}
|
>
|
||||||
|
<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>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<at-input-message></at-input-message>
|
<at-input-message></at-input-message>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,12 +13,21 @@ function atInputTextareaLink (scope, element, attrs, controllers) {
|
|||||||
|
|
||||||
function AtInputTextareaController (baseInputController) {
|
function AtInputTextareaController (baseInputController) {
|
||||||
const vm = this || {};
|
const vm = this || {};
|
||||||
|
let scope;
|
||||||
|
|
||||||
vm.init = (scope, element, form) => {
|
vm.init = (_scope_, element, form) => {
|
||||||
baseInputController.call(vm, 'input', scope, element, form);
|
baseInputController.call(vm, 'input', _scope_, element, form);
|
||||||
|
scope = _scope_;
|
||||||
|
|
||||||
vm.check();
|
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'];
|
AtInputTextareaController.$inject = ['BaseInputController'];
|
||||||
|
|||||||
@@ -1,17 +1,45 @@
|
|||||||
<div class="col-sm-{{::col}} at-InputContainer">
|
<div class="col-sm-{{::col}} at-InputContainer">
|
||||||
<div class="form-group at-u-flat">
|
<div class="form-group at-u-flat">
|
||||||
<at-input-label></at-input-label>
|
<at-input-label></at-input-label>
|
||||||
|
<div ng-class="{ 'input-group': state.tagMode }">
|
||||||
<textarea class="form-control at-Input at-InputTextarea"
|
<div class="input-group-btn at-InputGroup-button input-group-prepend" ng-show="state.tagMode">
|
||||||
ng-model="state._value"
|
<button class="btn at-ButtonHollow--white at-Input-button--long-sm"
|
||||||
ng-class="{ 'at-Input--rejected': state._rejected }"
|
ng-disabled="state._disabled || form.disabled"
|
||||||
ng-attr-rows="{{::state._rows || 6 }}"
|
ng-click="vm.onLookupClick()">
|
||||||
ng-attr-maxlength="{{ state.max_length || undefined }}"
|
<i class="fa fa-search"></i>
|
||||||
ng-attr-tabindex="{{ tab || undefined }}"
|
</button>
|
||||||
ng-attr-placeholder="{{::state._placeholder || undefined }}"
|
</div>
|
||||||
ng-change="vm.check()"
|
<div
|
||||||
ng-disabled="state._disabled || form.disabled" /></textarea>
|
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>
|
<at-input-message></at-input-message>
|
||||||
</div>
|
</div>
|
||||||
</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-attr-disabled="{{ state._disabled || undefined }}"
|
||||||
ng-class="{ 'at-Tab--active': state._active, 'at-Tab--disabled': state._disabled }"
|
ng-class="{ 'at-Tab--active': state._active, 'at-Tab--disabled': state._disabled }"
|
||||||
ng-hide="{{ state._hide }}"
|
ng-hide="{{ state._hide }}"
|
||||||
ng-click="vm.go()">
|
ng-click="state._go && vm.go();">
|
||||||
<ng-transclude></ng-transclude>
|
<ng-transclude></ng-transclude>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -61,6 +61,10 @@
|
|||||||
&--vault:before {
|
&--vault:before {
|
||||||
content: '\f187';
|
content: '\f187';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--external:before {
|
||||||
|
content: '\f14c'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.TagComponent-button {
|
.TagComponent-button {
|
||||||
|
|||||||
@@ -153,17 +153,22 @@ function httpPost (config = {}) {
|
|||||||
const req = {
|
const req = {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: this.path,
|
url: this.path,
|
||||||
data: config.data
|
data: config.data,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (config.url) {
|
if (config.url) {
|
||||||
req.url = `${this.path}${config.url}`;
|
req.url = `${this.path}${config.url}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!('replace' in config)) {
|
||||||
|
config.replace = true;
|
||||||
|
}
|
||||||
|
|
||||||
return $http(req)
|
return $http(req)
|
||||||
.then(res => {
|
.then(res => {
|
||||||
this.model.GET = res.data;
|
if (config.replace) {
|
||||||
|
this.model.GET = res.data;
|
||||||
|
}
|
||||||
return res;
|
return res;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint camelcase: 0 */
|
||||||
const ENCRYPTED_VALUE = '$encrypted$';
|
const ENCRYPTED_VALUE = '$encrypted$';
|
||||||
|
|
||||||
let Base;
|
let Base;
|
||||||
@@ -29,12 +30,23 @@ function createFormSchema (method, config) {
|
|||||||
return schema;
|
return schema;
|
||||||
}
|
}
|
||||||
|
|
||||||
function assignInputGroupValues (inputs) {
|
function assignInputGroupValues (apiConfig, credentialType, sourceCredentials) {
|
||||||
|
let inputs = credentialType.get('inputs.fields');
|
||||||
|
|
||||||
if (!inputs) {
|
if (!inputs) {
|
||||||
return [];
|
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}`);
|
const value = this.get(`inputs.${input.id}`);
|
||||||
|
|
||||||
input._value = value;
|
input._value = value;
|
||||||
@@ -42,6 +54,46 @@ function assignInputGroupValues (inputs) {
|
|||||||
|
|
||||||
return input;
|
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) {
|
function setDependentResources (id) {
|
||||||
|
|||||||
@@ -15,18 +15,18 @@ function categorizeByKind () {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeInputProperties () {
|
function mergeInputProperties (key = 'fields') {
|
||||||
if (!this.has('inputs.fields')) {
|
if (!this.has(`inputs.${key}`)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const required = this.get('inputs.required');
|
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) {
|
if (!required || required.indexOf(field.id) === -1) {
|
||||||
this.set(`inputs.fields[${i}].required`, false);
|
this.set(`inputs.${key}[${i}].required`, false);
|
||||||
} else {
|
} 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.CANCEL = t.s('CANCEL');
|
||||||
this.CLOSE = t.s('CLOSE');
|
this.CLOSE = t.s('CLOSE');
|
||||||
this.SAVE = t.s('SAVE');
|
this.SAVE = t.s('SAVE');
|
||||||
|
this.SELECT = t.s('SELECT');
|
||||||
this.OK = t.s('OK');
|
this.OK = t.s('OK');
|
||||||
|
this.RUN = t.s('RUN');
|
||||||
this.NEXT = t.s('NEXT');
|
this.NEXT = t.s('NEXT');
|
||||||
this.SHOW = t.s('SHOW');
|
this.SHOW = t.s('SHOW');
|
||||||
this.HIDE = t.s('HIDE');
|
this.HIDE = t.s('HIDE');
|
||||||
@@ -73,6 +75,7 @@ function BaseStringService (namespace) {
|
|||||||
this.COPY = t.s('COPY');
|
this.COPY = t.s('COPY');
|
||||||
this.YES = t.s('YES');
|
this.YES = t.s('YES');
|
||||||
this.CLOSE = t.s('CLOSE');
|
this.CLOSE = t.s('CLOSE');
|
||||||
|
this.TEST = t.s('TEST');
|
||||||
this.SUCCESSFUL_CREATION = resource => t.s('{{ resource }} successfully created', { resource: $filter('sanitize')(resource) });
|
this.SUCCESSFUL_CREATION = resource => t.s('{{ resource }} successfully created', { resource: $filter('sanitize')(resource) });
|
||||||
|
|
||||||
this.deleteResource = {
|
this.deleteResource = {
|
||||||
|
|||||||
@@ -102,8 +102,9 @@ function SmartSearchController (
|
|||||||
const rootField = termParts[0].split('.')[0].replace(/^-/, '');
|
const rootField = termParts[0].split('.')[0].replace(/^-/, '');
|
||||||
const listName = $scope.list.name;
|
const listName = $scope.list.name;
|
||||||
const baseRelatedTypePath = `models.${listName}.base.${rootField}.type`;
|
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');
|
const isBaseModelRelatedSearchTermField = (_.get($scope, baseRelatedTypePath) === 'field');
|
||||||
|
|
||||||
return (isRelatedSearchTermField || isBaseModelRelatedSearchTermField);
|
return (isRelatedSearchTermField || isBaseModelRelatedSearchTermField);
|
||||||
|
|||||||
@@ -21,11 +21,11 @@ const inputContainerElements = {
|
|||||||
},
|
},
|
||||||
show: {
|
show: {
|
||||||
locateStrategy: 'xpath',
|
locateStrategy: 'xpath',
|
||||||
selector: `.//button[${normalized}='show']`
|
selector: './/i[contains(@class, "fa fa-eye")]'
|
||||||
},
|
},
|
||||||
hide: {
|
hide: {
|
||||||
locateStrategy: 'xpath',
|
locateStrategy: 'xpath',
|
||||||
selector: `.//button[${normalized}='hide']`
|
selector: './/i[contains(@class, "fa fa-eye-slash")]'
|
||||||
},
|
},
|
||||||
on: {
|
on: {
|
||||||
locateStrategy: 'xpath',
|
locateStrategy: 'xpath',
|
||||||
@@ -37,11 +37,11 @@ const inputContainerElements = {
|
|||||||
},
|
},
|
||||||
replace: {
|
replace: {
|
||||||
locateStrategy: 'xpath',
|
locateStrategy: 'xpath',
|
||||||
selector: `.//button[${normalized}='replace']`
|
selector: './/i[contains(@class, "fa fa-undo")]'
|
||||||
},
|
},
|
||||||
revert: {
|
revert: {
|
||||||
locateStrategy: 'xpath',
|
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}`;
|
const input = `@${f.id}`;
|
||||||
|
|
||||||
group.expect.element('@show').visible;
|
group.expect.element('@show').visible;
|
||||||
group.expect.element('@hide').not.present;
|
group.expect.element('@hide').not.visible;
|
||||||
|
|
||||||
type.setValue(input, 'SECRET');
|
type.setValue(input, 'SECRET');
|
||||||
type.expect.element(input).text.equal('');
|
type.expect.element(input).text.equal('');
|
||||||
|
|
||||||
group.click('@show');
|
group.click('@show');
|
||||||
group.expect.element('@show').not.present;
|
group.expect.element('@show').not.visible;
|
||||||
group.expect.element('@hide').visible;
|
group.expect.element('@hide').visible;
|
||||||
type.expect.element(input).value.contain('SECRET');
|
type.expect.element(input).value.contain('SECRET');
|
||||||
|
|
||||||
group.click('@hide');
|
group.click('@hide');
|
||||||
group.expect.element('@show').visible;
|
group.expect.element('@show').visible;
|
||||||
group.expect.element('@hide').not.present;
|
group.expect.element('@hide').not.visible;
|
||||||
type.expect.element(input).text.equal('');
|
type.expect.element(input).text.equal('');
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -216,14 +216,22 @@ module.exports = {
|
|||||||
edit.section.gce.expect.element('@sshKeyData').not.enabled;
|
edit.section.gce.expect.element('@sshKeyData').not.enabled;
|
||||||
},
|
},
|
||||||
'select and deselect credential file when replacing private key': client => {
|
'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;
|
const { gce } = credentials.section.edit.section.details.section;
|
||||||
|
|
||||||
gce.section.sshKeyData.waitForElementVisible('@replace');
|
gce.waitForElementVisible(replace);
|
||||||
gce.section.sshKeyData.click('@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('@email').enabled;
|
||||||
gce.expect.element('@project').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.expect.element('@serviceAccountFile').enabled;
|
||||||
|
|
||||||
gce.section.sshKeyData.expect.element('@error').visible;
|
gce.section.sshKeyData.expect.element('@error').visible;
|
||||||
@@ -247,10 +255,9 @@ module.exports = {
|
|||||||
gce.section.sshKeyData.expect.element('@error').not.present;
|
gce.section.sshKeyData.expect.element('@error').not.present;
|
||||||
gce.section.serviceAccountFile.expect.element('@error').not.present;
|
gce.section.serviceAccountFile.expect.element('@error').not.present;
|
||||||
|
|
||||||
gce.section.sshKeyData.expect.element('@replace').not.present;
|
gce.expect.element(replace).not.present;
|
||||||
gce.section.sshKeyData.expect.element('@revert').present;
|
gce.expect.element(revert).present;
|
||||||
gce.section.sshKeyData.expect.element('@revert').not.enabled;
|
gce.expect.element('.input-group-append button').not.enabled;
|
||||||
|
|
||||||
gce.section.serviceAccountFile.click('form i[class*="trash"]');
|
gce.section.serviceAccountFile.click('form i[class*="trash"]');
|
||||||
|
|
||||||
gce.expect.element('@email').enabled;
|
gce.expect.element('@email').enabled;
|
||||||
@@ -264,9 +271,11 @@ module.exports = {
|
|||||||
gce.section.project.expect.element('@error').not.present;
|
gce.section.project.expect.element('@error').not.present;
|
||||||
gce.section.serviceAccountFile.expect.element('@error').not.present;
|
gce.section.serviceAccountFile.expect.element('@error').not.present;
|
||||||
|
|
||||||
gce.section.sshKeyData.expect.element('@revert').enabled;
|
gce.expect.element('.input-group-append button').enabled;
|
||||||
|
// eslint-disable-next-line prefer-arrow-callback
|
||||||
gce.section.sshKeyData.click('@revert');
|
client.execute(function clickRevert (selector) {
|
||||||
|
document.querySelector(selector).click();
|
||||||
|
}, [revert]);
|
||||||
|
|
||||||
gce.expect.element('@email').enabled;
|
gce.expect.element('@email').enabled;
|
||||||
gce.expect.element('@project').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').visible;
|
||||||
machine.section.password.expect.element('@replace').enabled;
|
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.expect.element('@password').not.enabled;
|
||||||
|
|
||||||
machine.section.password.click('@replace');
|
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.section.password.expect.element('@revert').visible;
|
||||||
|
|
||||||
machine.expect.element('@password').enabled;
|
machine.expect.element('@password').enabled;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
} from '../fixtures';
|
} from '../fixtures';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
AWX_E2E_TIMEOUT_MEDIUM,
|
||||||
AWX_E2E_TIMEOUT_SHORT
|
AWX_E2E_TIMEOUT_SHORT
|
||||||
} from '../settings';
|
} from '../settings';
|
||||||
|
|
||||||
@@ -23,16 +24,19 @@ module.exports = {
|
|||||||
Promise.all(resources)
|
Promise.all(resources)
|
||||||
.then(([jt, jt2]) => {
|
.then(([jt, jt2]) => {
|
||||||
data = { 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();
|
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 => {
|
'Test max character limit when creating a job template': client => {
|
||||||
client
|
client
|
||||||
@@ -49,18 +53,21 @@ module.exports = {
|
|||||||
'//input[@name="project_name"]',
|
'//input[@name="project_name"]',
|
||||||
['test-form-error-handling-project', client.Keys.ENTER]
|
['test-form-error-handling-project', client.Keys.ENTER]
|
||||||
)
|
)
|
||||||
|
// After the test sets a value for the project, a few seconds are
|
||||||
// clicked twice to make the element an active field
|
// needed while the UI fetches the playbooks names with a network
|
||||||
.findThenClick('//*[@id="select2-playbook-select-container"]')
|
// 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('//*[@id="select2-playbook-select-container"]')
|
||||||
.findThenClick('//li[text()="hello_world.yml"]')
|
.findThenClick('//li[text()="hello_world.yml"]')
|
||||||
.findThenClick('//*[@id="job_template_save_btn"]')
|
.findThenClick('//*[@id="job_template_save_btn"]')
|
||||||
.findThenClick('//*[@id="alert_ok_btn"]');
|
.findThenClick('//*[@id="alert_ok_btn"]');
|
||||||
|
|
||||||
client.expect.element('//div[@id="job_template_name_group"]' +
|
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 => {
|
'Test duplicate template name handling when creating a job template': client => {
|
||||||
client
|
client
|
||||||
.waitForElementNotVisible('//*[@id="alert_ok_btn"]')
|
.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
|
appdirs==1.4.2
|
||||||
asgi-amqp==1.1.3
|
asgi-amqp==1.1.3
|
||||||
asgiref==1.1.2
|
asgiref==1.1.2
|
||||||
|
azure-keyvault==1.1.0
|
||||||
boto==2.47.0
|
boto==2.47.0
|
||||||
channels==1.1.8
|
channels==1.1.8
|
||||||
celery==4.2.1
|
celery==4.2.1
|
||||||
@@ -39,7 +40,7 @@ python-radius==1.0
|
|||||||
python3-saml==1.4.0
|
python3-saml==1.4.0
|
||||||
social-auth-core==3.0.0
|
social-auth-core==3.0.0
|
||||||
social-auth-app-django==2.1.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
|
requests-futures==0.9.7
|
||||||
service-identity==17.0.0
|
service-identity==17.0.0
|
||||||
slackclient==1.1.2
|
slackclient==1.1.2
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
#
|
#
|
||||||
# pip-compile requirements/requirements.in
|
# pip-compile requirements/requirements.in
|
||||||
#
|
#
|
||||||
|
adal==1.2.1 # via msrestazure
|
||||||
amqp==2.3.2 # via kombu
|
amqp==2.3.2 # via kombu
|
||||||
ansible-runner==1.3.1
|
ansible-runner==1.3.1
|
||||||
appdirs==1.4.2
|
appdirs==1.4.2
|
||||||
@@ -14,13 +15,18 @@ asn1crypto==0.24.0 # via cryptography
|
|||||||
attrs==18.2.0 # via automat, service-identity, twisted
|
attrs==18.2.0 # via automat, service-identity, twisted
|
||||||
autobahn==19.2.1 # via daphne
|
autobahn==19.2.1 # via daphne
|
||||||
automat==0.7.0 # via twisted
|
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
|
billiard==3.5.0.5 # via celery
|
||||||
boto==2.47.0
|
boto==2.47.0
|
||||||
celery==4.2.1
|
celery==4.2.1
|
||||||
|
certifi==2018.11.29 # via msrest, requests
|
||||||
cffi==1.12.1 # via cryptography
|
cffi==1.12.1 # via cryptography
|
||||||
channels==1.1.8
|
channels==1.1.8
|
||||||
|
chardet==3.0.4 # via requests
|
||||||
constantly==15.1.0 # via twisted
|
constantly==15.1.0 # via twisted
|
||||||
cryptography==2.5 # via pyopenssl
|
cryptography==2.5 # via adal, azure-keyvault, pyopenssl
|
||||||
daphne==1.3.0
|
daphne==1.3.0
|
||||||
defusedxml==0.5.0
|
defusedxml==0.5.0
|
||||||
django-auth-ldap==1.7.0
|
django-auth-ldap==1.7.0
|
||||||
@@ -40,11 +46,11 @@ djangorestframework-yaml==1.0.3
|
|||||||
djangorestframework==3.7.7
|
djangorestframework==3.7.7
|
||||||
future==0.16.0 # via django-radius
|
future==0.16.0 # via django-radius
|
||||||
hyperlink==18.0.0 # via twisted
|
hyperlink==18.0.0 # via twisted
|
||||||
idna==2.8 # via hyperlink
|
idna==2.8 # via hyperlink, requests
|
||||||
incremental==17.5.0 # via twisted
|
incremental==17.5.0 # via twisted
|
||||||
inflect==2.1.0 # via jaraco.itertools
|
inflect==2.1.0 # via jaraco.itertools
|
||||||
irc==16.2
|
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.classes==2.0 # via jaraco.collections
|
||||||
jaraco.collections==2.0 # via irc, jaraco.text
|
jaraco.collections==2.0 # via irc, jaraco.text
|
||||||
jaraco.functools==2.0 # via irc, jaraco.text, tempora
|
jaraco.functools==2.0 # via irc, jaraco.text, tempora
|
||||||
@@ -61,6 +67,8 @@ markdown==2.6.11
|
|||||||
markupsafe==1.1.0 # via jinja2
|
markupsafe==1.1.0 # via jinja2
|
||||||
more-itertools==6.0.0 # via irc, jaraco.functools, jaraco.itertools
|
more-itertools==6.0.0 # via irc, jaraco.functools, jaraco.itertools
|
||||||
msgpack-python==0.5.6 # via asgi-amqp
|
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
|
netaddr==0.7.19 # via pyrad
|
||||||
oauthlib==2.0.6 # via django-oauth-toolkit, requests-oauthlib, social-auth-core
|
oauthlib==2.0.6 # via django-oauth-toolkit, requests-oauthlib, social-auth-core
|
||||||
ordereddict==1.1
|
ordereddict==1.1
|
||||||
@@ -74,7 +82,7 @@ pyasn1==0.4.5 # via pyasn1-modules, python-ldap, service-identity
|
|||||||
pycparser==2.19 # via cffi
|
pycparser==2.19 # via cffi
|
||||||
pygerduty==0.37.0
|
pygerduty==0.37.0
|
||||||
pyhamcrest==1.9.0 # via twisted
|
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
|
pyopenssl==19.0.0 # via service-identity
|
||||||
pyparsing==2.2.0
|
pyparsing==2.2.0
|
||||||
pyrad==2.1 # via django-radius
|
pyrad==2.1 # via django-radius
|
||||||
@@ -89,8 +97,8 @@ python3-saml==1.4.0
|
|||||||
pytz==2018.9 # via celery, django, irc, tempora, twilio
|
pytz==2018.9 # via celery, django, irc, tempora, twilio
|
||||||
pyyaml==3.13 # via djangorestframework-yaml
|
pyyaml==3.13 # via djangorestframework-yaml
|
||||||
requests-futures==0.9.7
|
requests-futures==0.9.7
|
||||||
requests-oauthlib==1.2.0 # via social-auth-core
|
requests-oauthlib==1.2.0 # via msrest, social-auth-core
|
||||||
requests[security]==2.15.1
|
requests[security]==2.21.0
|
||||||
service-identity==17.0.0
|
service-identity==17.0.0
|
||||||
simplejson==3.16.0 # via uwsgitop
|
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
|
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
|
twisted==18.9.0 # via daphne
|
||||||
txaio==18.8.1 # via autobahn
|
txaio==18.8.1 # via autobahn
|
||||||
typing==3.6.6 # via django-extensions
|
typing==3.6.6 # via django-extensions
|
||||||
|
urllib3==1.24.1 # via requests
|
||||||
uwsgi==2.0.17
|
uwsgi==2.0.17
|
||||||
uwsgitop==0.10.0
|
uwsgitop==0.10.0
|
||||||
vine==1.2.0 # via amqp
|
vine==1.2.0 # via amqp
|
||||||
|
|||||||
7
setup.py
7
setup.py
@@ -114,6 +114,13 @@ setup(
|
|||||||
'console_scripts': [
|
'console_scripts': [
|
||||||
'awx-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'
|
||||||
|
]
|
||||||
},
|
},
|
||||||
data_files = proc_data_files([
|
data_files = proc_data_files([
|
||||||
("%s" % homedir, ["config/wsgi.py",
|
("%s" % homedir, ["config/wsgi.py",
|
||||||
|
|||||||
@@ -2,3 +2,9 @@
|
|||||||
tower-manage = awx:manage
|
tower-manage = awx:manage
|
||||||
awx-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"
|
||||||
Reference in New Issue
Block a user