Merge pull request #3098 from ansible/credential_plugins

Credential Plugins

Reviewed-by: https://github.com/softwarefactory-project-zuul[bot]
This commit is contained in:
softwarefactory-project-zuul[bot] 2019-04-02 16:19:00 +00:00 committed by GitHub
commit 3f73176ef2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
87 changed files with 3508 additions and 594 deletions

View File

@ -575,6 +575,10 @@ docker-compose: docker-auth
docker-compose-cluster: docker-auth
CURRENT_UID=$(shell id -u) TAG=$(COMPOSE_TAG) DEV_DOCKER_TAG_BASE=$(DEV_DOCKER_TAG_BASE) docker-compose -f tools/docker-compose-cluster.yml up
docker-compose-credential-plugins: docker-auth
echo -e "\033[0;31mTo generate a CyberArk Conjur API key: docker exec -it tools_conjur_1 conjurctl account create quick-start\033[0m"
CURRENT_UID=$(shell id -u) TAG=$(COMPOSE_TAG) DEV_DOCKER_TAG_BASE=$(DEV_DOCKER_TAG_BASE) docker-compose -f tools/docker-compose.yml -f tools/docker-credential-plugins-override.yml up --no-recreate awx
docker-compose-test: docker-auth
cd tools && CURRENT_UID=$(shell id -u) TAG=$(COMPOSE_TAG) DEV_DOCKER_TAG_BASE=$(DEV_DOCKER_TAG_BASE) docker-compose run --rm --service-ports awx /bin/bash

View File

@ -46,7 +46,7 @@ from awx.main.constants import (
CENSOR_VALUE,
)
from awx.main.models import (
ActivityStream, AdHocCommand, AdHocCommandEvent, Credential,
ActivityStream, AdHocCommand, AdHocCommandEvent, Credential, CredentialInputSource,
CredentialType, CustomInventoryScript, Fact, Group, Host, Instance,
InstanceGroup, Inventory, InventorySource, InventoryUpdate,
InventoryUpdateEvent, Job, JobEvent, JobHostSummary, JobLaunchConfig,
@ -133,6 +133,8 @@ SUMMARIZABLE_FK_FIELDS = {
'notification_template': DEFAULT_SUMMARY_FIELDS,
'instance_group': {'id', 'name', 'controller_id'},
'insights_credential': DEFAULT_SUMMARY_FIELDS,
'source_credential': DEFAULT_SUMMARY_FIELDS + ('kind', 'cloud', 'credential_type_id'),
'target_credential': DEFAULT_SUMMARY_FIELDS + ('kind', 'cloud', 'credential_type_id'),
}
@ -2582,7 +2584,7 @@ class V2CredentialFields(BaseSerializer, metaclass=BaseSerializerMetaclass):
class CredentialSerializer(BaseSerializer):
show_capabilities = ['edit', 'delete', 'copy']
show_capabilities = ['edit', 'delete', 'copy', 'use']
capabilities_prefetch = ['admin', 'use']
class Meta:
@ -2629,6 +2631,7 @@ class CredentialSerializer(BaseSerializer):
))
if self.version > 1:
res['copy'] = self.reverse('api:credential_copy', kwargs={'pk': obj.pk})
res['input_sources'] = self.reverse('api:credential_input_source_sublist', kwargs={'pk': obj.pk})
# TODO: remove when API v1 is removed
if self.version > 1:
@ -2815,6 +2818,32 @@ class CredentialSerializerCreate(CredentialSerializer):
return credential
class CredentialInputSourceSerializer(BaseSerializer):
show_capabilities = ['delete']
class Meta:
model = CredentialInputSource
fields = (
'*',
'input_field_name',
'metadata',
'target_credential',
'source_credential',
'-name',
)
extra_kwargs = {
'input_field_name': {'required': True},
'target_credential': {'required': True},
'source_credential': {'required': True},
}
def get_related(self, obj):
res = super(CredentialInputSourceSerializer, self).get_related(obj)
res['source_credential'] = obj.source_credential.get_absolute_url(request=self.context.get('request'))
res['target_credential'] = obj.target_credential.get_absolute_url(request=self.context.get('request'))
return res
class UserCredentialSerializerCreate(CredentialSerializerCreate):
class Meta:

View File

@ -12,6 +12,8 @@ from awx.api.views import (
CredentialOwnerUsersList,
CredentialOwnerTeamsList,
CredentialCopy,
CredentialInputSourceSubList,
CredentialExternalTest,
)
@ -24,6 +26,8 @@ urls = [
url(r'^(?P<pk>[0-9]+)/owner_users/$', CredentialOwnerUsersList.as_view(), name='credential_owner_users_list'),
url(r'^(?P<pk>[0-9]+)/owner_teams/$', CredentialOwnerTeamsList.as_view(), name='credential_owner_teams_list'),
url(r'^(?P<pk>[0-9]+)/copy/$', CredentialCopy.as_view(), name='credential_copy'),
url(r'^(?P<pk>[0-9]+)/input_sources/$', CredentialInputSourceSubList.as_view(), name='credential_input_source_sublist'),
url(r'^(?P<pk>[0-9]+)/test/$', CredentialExternalTest.as_view(), name='credential_external_test'),
]
__all__ = ['urls']

View 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']

View File

@ -8,6 +8,7 @@ from awx.api.views import (
CredentialTypeDetail,
CredentialTypeCredentialList,
CredentialTypeActivityStreamList,
CredentialTypeExternalTest,
)
@ -16,6 +17,7 @@ urls = [
url(r'^(?P<pk>[0-9]+)/$', CredentialTypeDetail.as_view(), name='credential_type_detail'),
url(r'^(?P<pk>[0-9]+)/credentials/$', CredentialTypeCredentialList.as_view(), name='credential_type_credential_list'),
url(r'^(?P<pk>[0-9]+)/activity_stream/$', CredentialTypeActivityStreamList.as_view(), name='credential_type_activity_stream_list'),
url(r'^(?P<pk>[0-9]+)/test/$', CredentialTypeExternalTest.as_view(), name='credential_type_external_test'),
]
__all__ = ['urls']

View File

@ -47,6 +47,7 @@ from .inventory_update import urls as inventory_update_urls
from .inventory_script import urls as inventory_script_urls
from .credential_type import urls as credential_type_urls
from .credential import urls as credential_urls
from .credential_input_source import urls as credential_input_source_urls
from .role import urls as role_urls
from .job_template import urls as job_template_urls
from .job import urls as job_urls
@ -119,6 +120,7 @@ v1_urls = [
v2_urls = [
url(r'^$', ApiV2RootView.as_view(), name='api_v2_root_view'),
url(r'^credential_types/', include(credential_type_urls)),
url(r'^credential_input_sources/', include(credential_input_source_urls)),
url(r'^hosts/(?P<pk>[0-9]+)/ansible_facts/$', HostAnsibleFactsDetail.as_view(), name='host_ansible_facts_detail'),
url(r'^jobs/(?P<pk>[0-9]+)/extra_credentials/$', JobExtraCredentialsList.as_view(), name='job_extra_credentials_list'),
url(r'^jobs/(?P<pk>[0-9]+)/credentials/$', JobCredentialsList.as_view(), name='job_credentials_list'),

View File

@ -1419,6 +1419,88 @@ class CredentialCopy(CopyAPIView):
copy_return_serializer_class = serializers.CredentialSerializer
class CredentialExternalTest(SubDetailAPIView):
"""
Test updates to the input values and metadata of an external credential
before saving them.
"""
view_name = _('External Credential Test')
model = models.Credential
serializer_class = serializers.EmptySerializer
def post(self, request, *args, **kwargs):
obj = self.get_object()
backend_kwargs = {}
for field_name, value in obj.inputs.items():
backend_kwargs[field_name] = obj.get_input(field_name)
for field_name, value in request.data.get('inputs', {}).items():
if value != '$encrypted$':
backend_kwargs[field_name] = value
backend_kwargs.update(request.data.get('metadata', {}))
try:
obj.credential_type.plugin.backend(**backend_kwargs)
return Response({}, status=status.HTTP_202_ACCEPTED)
except requests.exceptions.HTTPError as exc:
message = 'HTTP {}\n{}'.format(exc.response.status_code, exc.response.text)
return Response({'inputs': message}, status=status.HTTP_400_BAD_REQUEST)
except Exception as exc:
return Response({'inputs': str(exc)}, status=status.HTTP_400_BAD_REQUEST)
class CredentialInputSourceDetail(RetrieveUpdateDestroyAPIView):
view_name = _("Credential Input Source Detail")
model = models.CredentialInputSource
serializer_class = serializers.CredentialInputSourceSerializer
class CredentialInputSourceList(ListCreateAPIView):
view_name = _("Credential Input Sources")
model = models.CredentialInputSource
serializer_class = serializers.CredentialInputSourceSerializer
class CredentialInputSourceSubList(SubListCreateAPIView):
view_name = _("Credential Input Sources")
model = models.CredentialInputSource
serializer_class = serializers.CredentialInputSourceSerializer
parent_model = models.Credential
relationship = 'input_sources'
parent_key = 'target_credential'
class CredentialTypeExternalTest(SubDetailAPIView):
"""
Test a complete set of input values for an external credential before
saving it.
"""
view_name = _('External Credential Type Test')
model = models.CredentialType
serializer_class = serializers.EmptySerializer
def post(self, request, *args, **kwargs):
obj = self.get_object()
backend_kwargs = request.data.get('inputs', {})
backend_kwargs.update(request.data.get('metadata', {}))
try:
obj.plugin.backend(**backend_kwargs)
return Response({}, status=status.HTTP_202_ACCEPTED)
except requests.exceptions.HTTPError as exc:
message = 'HTTP {}\n{}'.format(exc.response.status_code, exc.response.text)
return Response({'inputs': message}, status=status.HTTP_400_BAD_REQUEST)
except Exception as exc:
return Response({'inputs': str(exc)}, status=status.HTTP_400_BAD_REQUEST)
class HostRelatedSearchMixin(object):
@property

View File

@ -101,6 +101,7 @@ class ApiVersionRootView(APIView):
data['credentials'] = reverse('api:credential_list', request=request)
if get_request_version(request) > 1:
data['credential_types'] = reverse('api:credential_type_list', request=request)
data['credential_input_sources'] = reverse('api:credential_input_source_list', request=request)
data['applications'] = reverse('api:o_auth2_application_list', request=request)
data['tokens'] = reverse('api:o_auth2_token_list', request=request)
data['inventory'] = reverse('api:inventory_list', request=request)

View File

@ -30,8 +30,8 @@ from awx.main.utils import (
)
from awx.main.models import (
ActivityStream, AdHocCommand, AdHocCommandEvent, Credential, CredentialType,
CustomInventoryScript, Group, Host, Instance, InstanceGroup, Inventory,
InventorySource, InventoryUpdate, InventoryUpdateEvent, Job, JobEvent,
CredentialInputSource, CustomInventoryScript, Group, Host, Instance, InstanceGroup,
Inventory, InventorySource, InventoryUpdate, InventoryUpdateEvent, Job, JobEvent,
JobHostSummary, JobLaunchConfig, JobTemplate, Label, Notification,
NotificationTemplate, Organization, Project, ProjectUpdate,
ProjectUpdateEvent, Role, Schedule, SystemJob, SystemJobEvent,
@ -426,7 +426,7 @@ class BaseAccess(object):
if display_method == 'schedule':
user_capabilities['schedule'] = user_capabilities['start']
continue
elif display_method == 'delete' and not isinstance(obj, (User, UnifiedJob, CustomInventoryScript)):
elif display_method == 'delete' and not isinstance(obj, (User, UnifiedJob, CustomInventoryScript, CredentialInputSource)):
user_capabilities['delete'] = user_capabilities['edit']
continue
elif display_method == 'copy' and isinstance(obj, (Group, Host)):
@ -1162,6 +1162,55 @@ class CredentialAccess(BaseAccess):
# return True
return self.can_change(obj, None)
def get_user_capabilities(self, obj, **kwargs):
user_capabilities = super(CredentialAccess, self).get_user_capabilities(obj, **kwargs)
user_capabilities['use'] = self.can_use(obj)
return user_capabilities
class CredentialInputSourceAccess(BaseAccess):
'''
I can see a CredentialInputSource when:
- I can see the associated target_credential
I can create/change a CredentialInputSource when:
- I'm an admin of the associated target_credential
- I have use access to the associated source credential
I can delete a CredentialInputSource when:
- I'm an admin of the associated target_credential
'''
model = CredentialInputSource
select_related = ('target_credential', 'source_credential')
def filtered_queryset(self):
return CredentialInputSource.objects.filter(
target_credential__in=Credential.accessible_pk_qs(self.user, 'read_role'))
@check_superuser
def can_read(self, obj):
return self.user in obj.target_credential.read_role
@check_superuser
def can_add(self, data):
return (
self.check_related('target_credential', Credential, data, role_field='admin_role') and
self.check_related('source_credential', Credential, data, role_field='use_role')
)
@check_superuser
def can_change(self, obj, data):
if self.can_add(data) is False:
return False
return (
self.user in obj.target_credential.admin_role and
self.user in obj.source_credential.use_role
)
@check_superuser
def can_delete(self, obj):
return self.user in obj.target_credential.admin_role
class TeamAccess(BaseAccess):
'''

View File

View 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
)

View 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
)

View 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
)

View 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
)

View File

@ -0,0 +1,3 @@
from collections import namedtuple
CredentialPlugin = namedtuple('CredentialPlugin', ['name', 'inputs', 'backend'])

View File

@ -480,6 +480,69 @@ def format_ssh_private_key(value):
return True
class DynamicCredentialInputField(JSONSchemaField):
"""
Used to validate JSON for
`awx.main.models.credential:CredentialInputSource().metadata`.
Metadata for input sources is represented as a dictionary e.g.,
{'secret_path': '/kv/somebody', 'secret_key': 'password'}
For the data to be valid, the keys of this dictionary should correspond
with the metadata field (and datatypes) defined in the associated
target CredentialType e.g.,
"""
def schema(self, credential_type):
# determine the defined fields for the associated credential type
properties = {}
for field in credential_type.inputs.get('metadata', []):
field = field.copy()
properties[field['id']] = field
if field.get('choices', []):
field['enum'] = list(field['choices'])[:]
return {
'type': 'object',
'properties': properties,
'additionalProperties': False,
}
def validate(self, value, model_instance):
if not isinstance(value, dict):
return super(DynamicCredentialInputField, self).validate(value, model_instance)
super(JSONSchemaField, self).validate(value, model_instance)
credential_type = model_instance.source_credential.credential_type
errors = {}
for error in Draft4Validator(
self.schema(credential_type),
format_checker=self.format_checker
).iter_errors(value):
if error.validator == 'pattern' and 'error' in error.schema:
error.message = error.schema['error'].format(instance=error.instance)
if 'id' not in error.schema:
# If the error is not for a specific field, it's specific to
# `inputs` in general
raise django_exceptions.ValidationError(
error.message,
code='invalid',
params={'value': value},
)
errors[error.schema['id']] = [error.message]
defined_metadata = [field.get('id') for field in credential_type.inputs.get('metadata', [])]
for field in credential_type.inputs.get('required', []):
if field in defined_metadata and not value.get(field, None):
errors[field] = [_('required for %s') % (
credential_type.name
)]
if errors:
raise serializers.ValidationError({
'metadata': errors
})
class CredentialInputField(JSONSchemaField):
"""
Used to validate JSON for
@ -592,18 +655,13 @@ class CredentialInputField(JSONSchemaField):
)
errors[error.schema['id']] = [error.message]
inputs = model_instance.credential_type.inputs
for field in inputs.get('required', []):
if not value.get(field, None):
errors[field] = [_('required for %s') % (
model_instance.credential_type.name
)]
defined_fields = model_instance.credential_type.defined_fields
# `ssh_key_unlock` requirements are very specific and can't be
# represented without complicated JSON schema
if (
model_instance.credential_type.managed_by_tower is True and
'ssh_key_unlock' in model_instance.credential_type.defined_fields
'ssh_key_unlock' in defined_fields
):
# in order to properly test the necessity of `ssh_key_unlock`, we

View File

@ -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()

View 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),
]

View File

@ -16,7 +16,7 @@ from awx.main.models.organization import ( # noqa
Organization, Profile, Team, UserSessionMembership
)
from awx.main.models.credential import ( # noqa
Credential, CredentialType, ManagedCredentialType, V1Credential, build_safe_env
Credential, CredentialType, CredentialInputSource, ManagedCredentialType, V1Credential, build_safe_env
)
from awx.main.models.projects import Project, ProjectUpdate # noqa
from awx.main.models.inventory import ( # noqa

View File

@ -4,6 +4,7 @@ import functools
import inspect
import logging
import os
from pkg_resources import iter_entry_points
import re
import stat
import tempfile
@ -17,16 +18,22 @@ from django.db import models
from django.utils.translation import ugettext_lazy as _, ugettext_noop
from django.core.exceptions import ValidationError
from django.utils.encoding import force_text
from django.utils.functional import cached_property
# AWX
from awx.api.versioning import reverse
from awx.main.fields import (ImplicitRoleField, CredentialInputField,
CredentialTypeInputField,
CredentialTypeInjectorField)
CredentialTypeInjectorField,
DynamicCredentialInputField,)
from awx.main.utils import decrypt_field, classproperty
from awx.main.utils.safe_yaml import safe_dump
from awx.main.validators import validate_ssh_private_key
from awx.main.models.base import CommonModelNameNotUnique, PasswordFieldsModel
from awx.main.models.base import (
CommonModelNameNotUnique,
PasswordFieldsModel,
PrimordialModel
)
from awx.main.models.mixins import ResourceMixin
from awx.main.models.rbac import (
ROLE_SINGLETON_SYSTEM_ADMINISTRATOR,
@ -35,9 +42,13 @@ from awx.main.models.rbac import (
from awx.main.utils import encrypt_field
from . import injectors as builtin_injectors
__all__ = ['Credential', 'CredentialType', 'V1Credential', 'build_safe_env']
__all__ = ['Credential', 'CredentialType', 'CredentialInputSource', 'V1Credential', 'build_safe_env']
logger = logging.getLogger('awx.main.models.credential')
credential_plugins = dict(
(ep.name, ep.load())
for ep in iter_entry_points('awx.credential_plugins')
)
HIDDEN_PASSWORD = '**********'
@ -220,7 +231,6 @@ class V1Credential(object):
}
class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin):
'''
A credential contains information about how to talk to a remote resource
@ -364,6 +374,10 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin):
needed.append('vault_password')
return needed
@cached_property
def dynamic_input_fields(self):
return [obj.input_field_name for obj in self.input_sources.all()]
def _password_field_allows_ask(self, field):
return field in self.credential_type.askable_fields
@ -441,6 +455,8 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin):
:param field_name(str): The name of the input field.
:param default(optional[str]): A default return value to use.
"""
if self.kind != 'external' and field_name in self.dynamic_input_fields:
return self._get_dynamic_input(field_name)
if field_name in self.credential_type.secret_fields:
try:
return decrypt_field(self, field_name)
@ -461,8 +477,17 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin):
raise AttributeError(field_name)
def has_input(self, field_name):
if field_name in self.dynamic_input_fields:
return True
return field_name in self.inputs and self.inputs[field_name] not in ('', None)
def _get_dynamic_input(self, field_name):
for input_source in self.input_sources.all():
if input_source.input_field_name == field_name:
return input_source.get_input_value()
else:
raise ValueError('{} is not a dynamic input field'.format(field_name))
class CredentialType(CommonModelNameNotUnique):
'''
@ -484,6 +509,7 @@ class CredentialType(CommonModelNameNotUnique):
('scm', _('Source Control')),
('cloud', _('Cloud')),
('insights', _('Insights')),
('external', _('External')),
)
kind = models.CharField(
@ -552,6 +578,16 @@ class CredentialType(CommonModelNameNotUnique):
if field.get('ask_at_runtime', False) is True
]
@property
def plugin(self):
if self.kind != 'external':
raise AttributeError('plugin')
[plugin] = [
plugin for ns, plugin in credential_plugins.items()
if ns == self.namespace
]
return plugin
def default_for_field(self, field_id):
for field in self.inputs.get('fields', []):
if field['id'] == field_id:
@ -583,6 +619,15 @@ class CredentialType(CommonModelNameNotUnique):
created.inputs = created.injectors = {}
created.save()
@classmethod
def load_plugin(cls, ns, plugin):
ManagedCredentialType(
namespace=ns,
name=plugin.name,
kind='external',
inputs=plugin.inputs
)
@classmethod
def from_v1_kind(cls, kind, data={}):
match = None
@ -653,15 +698,15 @@ class CredentialType(CommonModelNameNotUnique):
# build a normal namespace with secret values decrypted (for
# ansible-playbook) and a safe namespace with secret values hidden (for
# DB storage)
for field_name, value in credential.inputs.items():
injectable_fields = list(credential.inputs.keys()) + credential.dynamic_input_fields
for field_name in list(set(injectable_fields)):
value = credential.get_input(field_name)
if type(value) is bool:
# boolean values can't be secret/encrypted
# boolean values can't be secret/encrypted/external
safe_namespace[field_name] = namespace[field_name] = value
continue
value = credential.get_input(field_name)
if field_name in self.secret_fields:
safe_namespace[field_name] = '**********'
elif len(value):
@ -774,6 +819,12 @@ ManagedCredentialType(
'format': 'ssh_private_key',
'secret': True,
'multiline': True
}, {
'id': 'ssh_public_key_data',
'label': ugettext_noop('Signed SSH Certificate'),
'type': 'string',
'multiline': True,
'secret': True,
}, {
'id': 'ssh_key_unlock',
'label': ugettext_noop('Private Key Passphrase'),
@ -1253,3 +1304,70 @@ ManagedCredentialType(
}
},
)
class CredentialInputSource(PrimordialModel):
class Meta:
app_label = 'main'
unique_together = (('target_credential', 'input_field_name'),)
target_credential = models.ForeignKey(
'Credential',
related_name='input_sources',
on_delete=models.CASCADE,
null=True,
)
source_credential = models.ForeignKey(
'Credential',
related_name='target_input_sources',
on_delete=models.CASCADE,
null=True,
)
input_field_name = models.CharField(
max_length=1024,
)
metadata = DynamicCredentialInputField(
blank=True,
default={}
)
def clean_target_credential(self):
if self.target_credential.kind == 'external':
raise ValidationError(_('Target must be a non-external credential'))
return self.target_credential
def clean_source_credential(self):
if self.source_credential.kind != 'external':
raise ValidationError(_('Source must be an external credential'))
return self.source_credential
def clean_input_field_name(self):
defined_fields = self.target_credential.credential_type.defined_fields
if self.input_field_name not in defined_fields:
raise ValidationError(_(
'Input field must be defined on target credential (options are {}).'.format(
', '.join(sorted(defined_fields))
)
))
return self.input_field_name
def get_input_value(self):
backend = self.source_credential.credential_type.plugin.backend
backend_kwargs = {}
for field_name, value in self.source_credential.inputs.items():
if field_name in self.source_credential.credential_type.secret_fields:
backend_kwargs[field_name] = decrypt_field(self.source_credential, field_name)
else:
backend_kwargs[field_name] = value
backend_kwargs.update(self.metadata)
return backend(**backend_kwargs)
def get_absolute_url(self, request=None):
view_name = 'api:credential_input_source_detail'
return reverse(view_name, kwargs={'pk': self.pk}, request=request)
for ns, plugin in credential_plugins.items():
CredentialType.load_plugin(ns, plugin)

View File

@ -1230,6 +1230,23 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique
self.save(update_fields=['job_explanation'])
return (False, None)
# verify that any associated credentials aren't missing required field data
missing_credential_inputs = []
for credential in self.credentials.all():
defined_fields = credential.credential_type.defined_fields
for required in credential.credential_type.inputs.get('required', []):
if required in defined_fields and not credential.has_input(required):
missing_credential_inputs.append(required)
if missing_credential_inputs:
self.job_explanation = '{} cannot start because Credential {} does not provide one or more required fields ({}).'.format(
self._meta.verbose_name.title(),
credential.name,
', '.join(sorted(missing_credential_inputs))
)
self.save(update_fields=['job_explanation'])
return (False, None)
needed = self.get_passwords_needed_to_start()
try:
start_args = json.loads(decrypt_field(self, 'start_args'))

View File

@ -772,7 +772,12 @@ class BaseTask(object):
'credentials': {
<awx.main.models.Credential>: '/path/to/decrypted/data',
<awx.main.models.Credential>: '/path/to/decrypted/data',
<awx.main.models.Credential>: '/path/to/decrypted/data',
...
},
'certificates': {
<awx.main.models.Credential>: /path/to/signed/ssh/certificate,
<awx.main.models.Credential>: /path/to/signed/ssh/certificate,
...
}
}
'''
@ -787,7 +792,6 @@ class BaseTask(object):
# and we're running an earlier version (<6.5).
if 'OPENSSH PRIVATE KEY' in data and not openssh_keys_supported:
raise RuntimeError(OPENSSH_KEY_ERROR)
for credential, data in private_data.get('credentials', {}).items():
# OpenSSH formatted keys must have a trailing newline to be
# accepted by ssh-add.
if 'OPENSSH PRIVATE KEY' in data and not data.endswith('\n'):
@ -813,6 +817,13 @@ class BaseTask(object):
f.close()
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
private_data_files['credentials'][credential] = path
for credential, data in private_data.get('certificates', {}).items():
name = 'credential_%d-cert.pub' % credential.pk
path = os.path.join(private_data_dir, name)
with open(path, 'w') as f:
f.write(data)
f.close()
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
return private_data_files
def build_passwords(self, instance, runtime_passwords):
@ -1269,16 +1280,23 @@ class RunJob(BaseTask):
'credentials': {
<awx.main.models.Credential>: <credential_decrypted_ssh_key_data>,
<awx.main.models.Credential>: <credential_decrypted_ssh_key_data>,
<awx.main.models.Credential>: <credential_decrypted_ssh_key_data>
...
},
'certificates': {
<awx.main.models.Credential>: <signed SSH certificate data>,
<awx.main.models.Credential>: <signed SSH certificate data>,
...
}
}
'''
private_data = {'credentials': {}}
for credential in job.credentials.all():
for credential in job.credentials.prefetch_related('input_sources__source_credential').all():
# If we were sent SSH credentials, decrypt them and send them
# back (they will be written to a temporary file).
if credential.has_input('ssh_key_data'):
private_data['credentials'][credential] = credential.get_input('ssh_key_data', default='')
if credential.has_input('ssh_public_key_data'):
private_data.setdefault('certificates', {})[credential] = credential.get_input('ssh_public_key_data', default='')
if credential.kind == 'openstack':
openstack_auth = dict(auth_url=credential.get_input('host', default=''),
@ -1503,7 +1521,7 @@ class RunJob(BaseTask):
return self._write_extra_vars_file(private_data_dir, extra_vars, safe_dict)
def build_credentials_list(self, job):
return job.credentials.all()
return job.credentials.prefetch_related('input_sources__source_credential').all()
def get_password_prompts(self, passwords={}):
d = super(RunJob, self).get_password_prompts(passwords)
@ -2187,7 +2205,12 @@ class RunAdHocCommand(BaseTask):
'credentials': {
<awx.main.models.Credential>: <credential_decrypted_ssh_key_data>,
<awx.main.models.Credential>: <credential_decrypted_ssh_key_data>,
<awx.main.models.Credential>: <credential_decrypted_ssh_key_data>
...
},
'certificates': {
<awx.main.models.Credential>: <signed SSH certificate data>,
<awx.main.models.Credential>: <signed SSH certificate data>,
...
}
}
'''
@ -2197,6 +2220,8 @@ class RunAdHocCommand(BaseTask):
private_data = {'credentials': {}}
if creds and creds.has_input('ssh_key_data'):
private_data['credentials'][creds] = creds.get_input('ssh_key_data', default='')
if creds and creds.has_input('ssh_public_key_data'):
private_data.setdefault('certificates', {})[creds] = creds.get_input('ssh_public_key_data', default='')
return private_data
def build_passwords(self, ad_hoc_command, runtime_passwords):

View File

@ -4,6 +4,7 @@ import pytest
from unittest import mock
from contextlib import contextmanager
from awx.main.models import Credential
from awx.main.tests.factories import (
create_organization,
create_job_template,
@ -139,3 +140,11 @@ def pytest_runtest_teardown(item, nextitem):
# this is a local test cache, so we want every test to start with empty cache
cache.clear()
@pytest.fixture(scope='session', autouse=True)
def mock_external_credential_input_sources():
# Credential objects query their related input sources on initialization.
# We mock that behavior out of credentials by default unless we need to
# test it explicitly.
with mock.patch.object(Credential, 'dynamic_input_fields', new=[]) as _fixture:
yield _fixture

View File

@ -763,7 +763,7 @@ def test_falsey_field_data(get, post, organization, admin, field_value):
'credential_type': net.pk,
'organization': organization.id,
'inputs': {
'username': 'joe-user', # username is required
'username': 'joe-user',
'authorize': field_value
}
}
@ -952,9 +952,15 @@ def test_vault_password_required(post, organization, admin):
},
admin
)
assert response.status_code == 400
assert response.data['inputs'] == {'vault_password': ['required for Vault']}
assert Credential.objects.count() == 0
assert response.status_code == 201
assert Credential.objects.count() == 1
# vault_password must be specified by launch time
j = Job()
j.save()
j.credentials.add(Credential.objects.first())
assert j.pre_start() == (False, None)
assert 'required fields (vault_password)' in j.job_explanation
#
@ -1236,14 +1242,15 @@ def test_aws_create_fail_required_fields(post, organization, admin, version, par
params,
admin
)
assert response.status_code == 400
assert response.status_code == 201
assert Credential.objects.count() == 1
assert Credential.objects.count() == 0
errors = response.data
if version == 'v2':
errors = response.data['inputs']
assert errors['username'] == ['required for %s' % aws.name]
assert errors['password'] == ['required for %s' % aws.name]
# username and password must be specified by launch time
j = Job()
j.save()
j.credentials.add(Credential.objects.first())
assert j.pre_start() == (False, None)
assert 'required fields (password, username)' in j.job_explanation
#
@ -1307,15 +1314,15 @@ def test_vmware_create_fail_required_fields(post, organization, admin, version,
params,
admin
)
assert response.status_code == 400
assert response.status_code == 201
assert Credential.objects.count() == 1
assert Credential.objects.count() == 0
errors = response.data
if version == 'v2':
errors = response.data['inputs']
assert errors['username'] == ['required for %s' % vmware.name]
assert errors['password'] == ['required for %s' % vmware.name]
assert errors['host'] == ['required for %s' % vmware.name]
# username, password, and host must be specified by launch time
j = Job()
j.save()
j.credentials.add(Credential.objects.first())
assert j.pre_start() == (False, None)
assert 'required fields (host, password, username)' in j.job_explanation
#
@ -1406,14 +1413,14 @@ def test_openstack_create_fail_required_fields(post, organization, admin, versio
params,
admin
)
assert response.status_code == 400
errors = response.data
if version == 'v2':
errors = response.data['inputs']
assert errors['username'] == ['required for %s' % openstack.name]
assert errors['password'] == ['required for %s' % openstack.name]
assert errors['host'] == ['required for %s' % openstack.name]
assert errors['project'] == ['required for %s' % openstack.name]
assert response.status_code == 201
# username, password, host, and project must be specified by launch time
j = Job()
j.save()
j.credentials.add(Credential.objects.first())
assert j.pre_start() == (False, None)
assert 'required fields (host, password, project, username)' in j.job_explanation
@pytest.mark.django_db

View 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

View File

@ -250,6 +250,42 @@ def credentialtype_insights():
return insights_type
@pytest.fixture
def credentialtype_external():
external_type_inputs = {
'fields': [{
'id': 'url',
'label': 'Server URL',
'type': 'string',
'help_text': 'The server url.'
}, {
'id': 'token',
'label': 'Token',
'type': 'string',
'secret': True,
'help_text': 'An access token for the server.'
}],
'metadata': [{
'id': 'key',
'label': 'Key',
'type': 'string'
}, {
'id': 'version',
'label': 'Version',
'type': 'string'
}],
'required': ['url', 'token', 'key'],
}
external_type = CredentialType(
kind='external',
managed_by_tower=True,
name='External Service',
inputs=external_type_inputs
)
external_type.save()
return external_type
@pytest.fixture
def credential(credentialtype_aws):
return Credential.objects.create(credential_type=credentialtype_aws, name='test-cred',
@ -293,6 +329,18 @@ def org_credential(organization, credentialtype_aws):
organization=organization)
@pytest.fixture
def external_credential(credentialtype_external):
return Credential.objects.create(credential_type=credentialtype_external, name='external-cred',
inputs={'url': 'http://testhost.com', 'token': 'secret1'})
@pytest.fixture
def other_external_credential(credentialtype_external):
return Credential.objects.create(credential_type=credentialtype_external, name='other-external-cred',
inputs={'url': 'http://testhost.com', 'token': 'secret2'})
@pytest.fixture
def inventory(organization):
return organization.inventories.create(name="test-inv")

View File

@ -75,10 +75,15 @@ GLqbpJyX2r3p/Rmo6mLY71SqpA==
@pytest.mark.django_db
def test_default_cred_types():
assert sorted(CredentialType.defaults.keys()) == [
'aim',
'aws',
'azure_kv',
'azure_rm',
'cloudforms',
'conjur',
'gce',
'hashivault_kv',
'hashivault_ssh',
'insights',
'net',
'openstack',
@ -181,7 +186,7 @@ def test_ssh_key_data_validation(organization, kind, ssh_key_data, ssh_key_unloc
@pytest.mark.django_db
@pytest.mark.parametrize('inputs, valid', [
({'vault_password': 'some-pass'}, True),
({}, False),
({}, True),
({'vault_password': 'dev-pass', 'vault_id': 'dev'}, True),
({'vault_password': 'dev-pass', 'vault_id': 'dev@prompt'}, False), # @ not allowed
])

View File

@ -700,7 +700,8 @@ class TestJobCredentials(TestJobExecution):
__iter__ = lambda *args: iter(job._credentials),
first = lambda: job._credentials[0]
),
'spec_set': ['all', 'add', 'filter']
'prefetch_related': lambda _: credentials_mock,
'spec_set': ['all', 'add', 'filter', 'prefetch_related'],
})
with mock.patch.object(UnifiedJob, 'credentials', credentials_mock):

View File

@ -1,5 +1,6 @@
@import 'portalMode/_index';
@import 'output/_index';
@import 'credentials/_index';
/** @define Popup Modal after create new token and applicaiton and save form */
.PopupModal {

View 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;
}

View File

@ -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;

View File

@ -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;

View File

@ -23,6 +23,7 @@
</at-input-group>
<at-action-group col="12" pos="right">
<at-form-action type="secondary" ng-if="vm.isTestable"></at-form-action>
<at-form-action type="cancel" to="credentials"></at-form-action>
<at-form-action type="save"></at-form-action>
</at-action-group>
@ -42,5 +43,24 @@
<div class="at-CredentialsPermissions" ui-view="related"></div>
</at-panel-body>
</at-panel>
<at-input-source-lookup
ng-if="vm.inputSources.field"
selected-id="vm.inputSources.credentialId"
selected-name="vm.inputSources.credentialName"
tabs="vm.inputSources.tabs"
form="vm.inputSources.form"
on-close="vm.onInputSourceClose"
on-next="vm.onInputSourceNext"
on-select="vm.onInputSourceSelect"
on-tab-select="vm.onInputSourceTabSelect"
on-item-select="vm.onInputSourceItemSelect"
on-test="vm.onInputSourceTest"
results-filter="vm.filterInputSourceCredentialResults"
/>
<at-external-credential-test
ng-if="vm.externalTest.metadataInputs"
on-close="vm.onExternalTestClose"
on-submit="vm.onExternalTest"
form="vm.externalTest.form"
/>
<div ng-if="$state.current.name.includes('permissions.add')" ui-view="modal"></div>

View File

@ -11,7 +11,7 @@ function CredentialsStrings (BaseString) {
ns.tab = {
DETAILS: t.s('Details'),
PERMISSIONS: t.s('Permissions')
PERMISSIONS: t.s('Permissions'),
};
ns.inputs = {
@ -22,10 +22,29 @@ function CredentialsStrings (BaseString) {
GCE_FILE_INPUT_HELP_TEXT: t.s('Provide account information using Google Compute Engine JSON credentials file.')
};
ns.externalTest = {
TITLE: t.s('Test External Credential')
};
ns.inputSources = {
TITLE: t.s('Set Input Source'),
CREDENTIAL: t.s('CREDENTIAL'),
METADATA: t.s('METADATA'),
NO_MATCH: t.s('No records matched your search.'),
NO_RECORDS: t.s('No external credentials available.'),
SELECTED: t.s('selected'),
NONE_SELECTED: t.s('No credential selected'),
};
ns.add = {
PANEL_TITLE: t.s('NEW CREDENTIAL')
};
ns.edit = {
TEST_PASSED: t.s('Test passed.'),
TEST_FAILED: t.s('Test failed.')
};
ns.permissions = {
TITLE: t.s('CREDENTIALS PERMISSIONS')
};

View File

@ -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;

View File

@ -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: '=',
},
};

View File

@ -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>

View File

@ -1,7 +1,8 @@
import LegacyCredentials from './legacy.credentials';
import AddController from './add-credentials.controller';
import EditController from './edit-credentials.controller';
import AddEditController from './add-edit-credentials.controller';
import CredentialsStrings from './credentials.strings';
import InputSourceLookupComponent from './input-source-lookup.component';
import ExternalTestModalComponent from './external-test-modal.component';
const MODULE_NAME = 'at.features.credentials';
@ -15,7 +16,9 @@ function CredentialsResolve (
CredentialType,
Organization,
ProcessErrors,
strings
strings,
Rest,
GetBasePath,
) {
const id = $stateParams.credential_id;
@ -27,6 +30,7 @@ function CredentialsResolve (
promises.credential = new Credential('options');
promises.credentialType = new CredentialType();
promises.organization = new Organization();
promises.sourceCredentials = $q.resolve({ data: { count: 0, results: [] } });
return $q.all(promises);
}
@ -38,17 +42,32 @@ function CredentialsResolve (
const typeId = models.credential.get('credential_type');
const orgId = models.credential.get('organization');
Rest.setUrl(GetBasePath('credentials'));
const params = { target_input_sources__target_credential: id };
const sourceCredentialsPromise = Rest.get({ params });
const dependents = {
credentialType: new CredentialType('get', typeId),
organization: new Organization('get', orgId)
organization: new Organization('get', orgId),
credentialInputSources: models.credential.extend('GET', 'input_sources'),
sourceCredentials: sourceCredentialsPromise
};
dependents.isOrgCredAdmin = dependents.organization.then((org) => org.search({ role_level: 'credential_admin_role' }));
return $q.all(dependents)
.then(related => {
models.credentialType = related.credentialType;
models.organization = related.organization;
models.isOrgCredAdmin = related.isOrgCredAdmin;
models.sourceCredentials = related.sourceCredentials;
const isOrgAdmin = _.some(models.me.get('related.admin_of_organizations.results'), (org) => org.id === models.organization.get('id'));
const isSuperuser = models.me.get('is_superuser');
const isCurrentAuthor = Boolean(models.credential.get('summary_fields.created_by.id') === models.me.get('id'));
models.isOrgEditableByUser = (isSuperuser || isOrgAdmin
|| related.isOrgCredAdmin
|| (models.credential.get('organization') === null && isCurrentAuthor));
return models;
});
@ -69,7 +88,9 @@ CredentialsResolve.$inject = [
'CredentialTypeModel',
'OrganizationModel',
'ProcessErrors',
'CredentialsStrings'
'CredentialsStrings',
'Rest',
'GetBasePath',
];
function CredentialsRun ($stateExtender, legacy, strings) {
@ -86,7 +107,7 @@ function CredentialsRun ($stateExtender, legacy, strings) {
views: {
'add@credentials': {
templateUrl: addEditTemplate,
controller: AddController,
controller: AddEditController,
controllerAs: 'vm'
}
},
@ -109,7 +130,7 @@ function CredentialsRun ($stateExtender, legacy, strings) {
views: {
'edit@credentials': {
templateUrl: addEditTemplate,
controller: EditController,
controller: AddEditController,
controllerAs: 'vm'
}
},
@ -135,10 +156,11 @@ CredentialsRun.$inject = [
angular
.module(MODULE_NAME, [])
.controller('AddController', AddController)
.controller('EditController', EditController)
.controller('AddEditController', AddEditController)
.service('LegacyCredentialsService', LegacyCredentials)
.service('CredentialsStrings', CredentialsStrings)
.component('atInputSourceLookup', InputSourceLookupComponent)
.component('atExternalCredentialTest', ExternalTestModalComponent)
.run(CredentialsRun);
export default MODULE_NAME;

View File

@ -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: '=',
},
};

View File

@ -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>

View File

@ -465,7 +465,13 @@ function getCredentialDetails () {
}
function buildCredentialDetails (credential) {
const icon = `${credential.kind}`;
let icon;
if (credential.cloud) {
icon = 'cloud';
} else {
icon = `${credential.kind}`;
}
const link = `/#/credentials/${credential.id}`;
const tooltip = strings.get('tooltips.CREDENTIAL');
const value = $filter('sanitize')(credential.name);

View File

@ -2262,6 +2262,7 @@ body {
.Toast-wrapper {
display: flex;
max-width: 250px;
word-break: break-word;
}
.Toast-icon {

View File

@ -1,4 +1,5 @@
@import 'action/_index';
@import 'dialog/_index';
@import 'input/_index';
@import 'launchTemplateButton/_index';
@import 'layout/_index';

View File

@ -1,7 +1,7 @@
.at-ActionGroup {
margin-top: @at-margin-panel;
button:last-child {
margin-left: @at-margin-panel-inset;
button {
margin-left: 15px;
}
}

View File

@ -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;

View File

@ -0,0 +1,3 @@
<button class="btn at-Button{{ vm.fill }}{{ vm.color ? '--' + vm.color : '' }}">
<ng-transclude></ng-transclude>
</button>

View File

@ -4,12 +4,12 @@ function ComponentsStrings (BaseString) {
const { t } = this;
const ns = this.components;
ns.REPLACE = t.s('REPLACE');
ns.REVERT = t.s('REVERT');
ns.REPLACE = t.s('Replace');
ns.REVERT = t.s('Revert');
ns.ENCRYPTED = t.s('ENCRYPTED');
ns.OPTIONS = t.s('OPTIONS');
ns.SHOW = t.s('SHOW');
ns.HIDE = t.s('HIDE');
ns.SHOW = t.s('Show');
ns.HIDE = t.s('Hide');
ns.message = {
REQUIRED_INPUT_MISSING: t.s('Please enter a value.'),
@ -40,7 +40,7 @@ function ComponentsStrings (BaseString) {
};
ns.textarea = {
SSH_KEY_HINT: t.s('HINT: Drag and drop an SSH private key file on the field below.')
SSH_KEY_HINT: t.s('HINT: Drag and drop private file on the field below.')
};
ns.lookup = {

View 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);
}

View 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: '=',
},
};

View 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>

View File

@ -23,6 +23,9 @@ function atFormActionController ($state, strings) {
case 'save':
vm.setSaveDefaults();
break;
case 'secondary':
vm.setSecondaryDefaults();
break;
default:
vm.setCustomDefaults();
}
@ -43,6 +46,13 @@ function atFormActionController ($state, strings) {
scope.color = 'success';
scope.action = () => { form.submit(); };
};
vm.setSecondaryDefaults = () => {
scope.text = strings.get('TEST');
scope.fill = '';
scope.color = 'info';
scope.action = () => { form.submitSecondary(); };
};
}
atFormActionController.$inject = ['$state', 'ComponentsStrings'];

View File

@ -1,5 +1,7 @@
<button class="btn at-Button{{ fill }}--{{ color }}"
ng-disabled="type !== 'cancel' && (form.disabled || (type === 'save' && !form.isValid))"
ng-disabled="type !== 'cancel' && (form.disabled
|| (type === 'save' && !form.isValid))
|| (type === 'secondary' && !form.isValid)"
ng-click="action()">
{{::text}}
</button>

View File

@ -30,7 +30,6 @@ function AtFormController (eventService, strings) {
({ modal } = scope[scope.ns]);
vm.state.disabled = scope.state.disabled;
vm.setListeners();
};
@ -62,6 +61,35 @@ function AtFormController (eventService, strings) {
scope.$apply(vm.submit);
};
vm.getSubmitData = () => vm.components
.filter(component => component.category === 'input')
.reduce((values, component) => {
if (component.state._value === undefined) {
return values;
}
if (component.state._format === 'selectFromOptions') {
values[component.state.id] = component.state._value[0];
} else if (component.state._key && typeof component.state._value === 'object') {
values[component.state.id] = component.state._value[component.state._key];
} else if (component.state._group) {
values[component.state._key] = values[component.state._key] || {};
values[component.state._key][component.state.id] = component.state._value;
} else {
values[component.state.id] = component.state._value;
}
return values;
}, {});
vm.submitSecondary = () => {
if (!vm.state.isValid) {
return;
}
const data = vm.getSubmitData();
scope.state.secondary(data);
};
vm.submit = () => {
if (!vm.state.isValid) {
return;
@ -69,26 +97,7 @@ function AtFormController (eventService, strings) {
vm.state.disabled = true;
const data = vm.components
.filter(component => component.category === 'input')
.reduce((values, component) => {
if (component.state._value === undefined) {
return values;
}
if (component.state._format === 'selectFromOptions') {
values[component.state.id] = component.state._value[0];
} else if (component.state._key && typeof component.state._value === 'object') {
values[component.state.id] = component.state._value[component.state._key];
} else if (component.state._group) {
values[component.state._key] = values[component.state._key] || {};
values[component.state._key][component.state.id] = component.state._value;
} else {
values[component.state.id] = component.state._value;
}
return values;
}, {});
const data = vm.getSubmitData();
scope.state.save(data)
.then(scope.state.onSaveSuccess)
@ -179,6 +188,10 @@ function AtFormController (eventService, strings) {
continue;
}
if (vm.components[i].state.asTag) {
continue;
}
if (!vm.components[i].state._isValid) {
isValid = false;
break;
@ -194,6 +207,10 @@ function AtFormController (eventService, strings) {
if (isValid !== vm.state.isValid) {
vm.state.isValid = isValid;
}
if (isValid !== scope.state.isValid) {
scope.state.isValid = isValid;
}
};
vm.deregisterInputGroup = components => {

View File

@ -1,6 +1,8 @@
import atLibServices from '~services';
import actionGroup from '~components/action/action-group.directive';
import actionButton from '~components/action/action-button.directive';
import dialog from '~components/dialog/dialog.component';
import divider from '~components/utility/divider.directive';
import dynamicSelect from '~components/input/dynamic-select.directive';
import form from '~components/form/form.directive';
@ -20,6 +22,7 @@ import inputTextareaSecret from '~components/input/textarea-secret.directive';
import launchTemplate from '~components/launchTemplateButton/launchTemplateButton.component';
import layout from '~components/layout/layout.directive';
import list from '~components/list/list.directive';
import lookupList from '~components/lookup-list/lookup-list.component';
import modal from '~components/modal/modal.directive';
import panel from '~components/panel/panel.directive';
import panelBody from '~components/panel/body.directive';
@ -53,6 +56,8 @@ angular
atCodeMirror
])
.directive('atActionGroup', actionGroup)
.directive('atActionButton', actionButton)
.component('atDialog', dialog)
.directive('atDivider', divider)
.directive('atDynamicSelect', dynamicSelect)
.directive('atForm', form)
@ -72,6 +77,7 @@ angular
.component('atLaunchTemplate', launchTemplate)
.directive('atLayout', layout)
.directive('atList', list)
.component('atLookupList', lookupList)
.directive('atListToolbar', toolbar)
.component('atRelaunch', relaunch)
.directive('atRow', row)

View File

@ -75,6 +75,12 @@
height: @at-height-textarea;
}
.at-Input-button--long-sm {
.at-mixin-InputButton();
max-width: @at-width-input-button-md;
min-height: @at-height-textarea;
}
.at-Input-button--active {
.at-mixin-ButtonColor(at-color-info, at-color-default);
}
@ -328,3 +334,26 @@
margin: 0;
}
}
.at-InputTaggedTextarea {
.at-mixin-FontFixedWidth();
min-height: @at-height-textarea;
padding: 6px @at-padding-input 0 @at-padding-input;
border-radius: @at-border-radius;
}
.at-InputTagContainer {
display: flex;
width: 100%;
flex-wrap: wrap;
.TagComponent {
max-height: @at-space-4x;
}
.TagComponent-name {
align-self: auto;
word-break: break-all;
font-family: 'Open Sans', sans-serif;
}
}

View File

@ -51,6 +51,10 @@ function BaseInputController (strings) {
let isValid = true;
let message = '';
if (scope.state.asTag) {
return (isValid, message);
}
if (scope.state._value || scope.state._displayValue) {
scope.state._touched = true;
}

View File

@ -48,7 +48,7 @@ function AtInputGroupController ($scope, $compile) {
state._value = source._value;
const inputs = state._get(source._value);
const inputs = state._get(form);
const group = vm.createComponentConfigs(inputs);
vm.insert(group);
@ -66,7 +66,9 @@ function AtInputGroupController ($scope, $compile) {
_element: vm.createComponent(input, i),
_key: 'inputs',
_group: true,
_groupIndex: i
_groupIndex: i,
_onInputLookup: state._onInputLookup,
_onRemoveTag: state._onRemoveTag,
}, input));
});
}
@ -97,6 +99,7 @@ function AtInputGroupController ($scope, $compile) {
if (input.secret) {
config._component = 'at-input-textarea-secret';
input.format = 'ssh_private_key';
} else {
config._component = 'at-input-textarea';
}
@ -111,12 +114,16 @@ function AtInputGroupController ($scope, $compile) {
config._component = 'at-input-checkbox';
} else if (input.type === 'file') {
config._component = 'at-input-file';
} else if (input.choices) {
}
if (input.choices) {
config._component = 'at-input-select';
config._format = 'array';
config._data = input.choices;
config._exp = 'choice for (index, choice) in state._data';
} else {
}
if (!config._component) {
const preface = vm.strings.get('group.UNSUPPORTED_ERROR_PREFACE');
throw new Error(`${preface}: ${input.type}`);
}
@ -160,7 +167,6 @@ function AtInputGroupController ($scope, $compile) {
</${input._component}>`);
$compile(component)(scope.$parent);
return component;
};

View File

@ -1,7 +1,7 @@
<div ng-show="state._group" class="col-sm-12 at-InputGroup">
<div class="at-InputGroup-border"></div>
<div ng-if="state.border" class="at-InputGroup-border"></div>
<div class="at-InputGroup-inset">
<div class="row">
<div ng-if="state.title" class="row">
<div class="col-sm-12">
<h4 class="at-InputGroup-title">
<ng-transclude></ng-transclude>

View File

@ -2,10 +2,11 @@
<span ng-if="state.required" class="at-InputLabel-required">*</span>
<span class="at-InputLabel-name" >{{::state.label | translate}}</span>
<at-popover state="state"></at-popover>
<span ng-if="state._displayHint" class="at-InputLabel-hint" translate>{{::state._hint}}</span>
<span ng-if="state._displayHint && !state.asTag" class="at-InputLabel-hint" translate>{{::state._hint}}</span>
<div ng-if="state._displayPromptOnLaunch" class="at-InputLabel-checkbox pull-right">
<label class="at-InputLabel-checkboxLabel">
<input type="checkbox"
ng-disabled="state.asTag"
ng-model="state._promptOnLaunch"
ng-change="vm.togglePromptOnLaunch()" />
<p>{{:: vm.strings.get('label.PROMPT_ON_LAUNCH') }}</p>

View File

@ -21,17 +21,15 @@ function AtInputSecretController (baseInputController) {
scope = _scope_;
scope.type = 'password';
scope.state._show = false;
scope.state._showHideText = vm.strings.get('SHOW');
if (!scope.state._value || scope.state._promptOnLaunch) {
scope.mode = 'input';
scope.state._buttonText = vm.strings.get('SHOW');
vm.toggle = vm.toggleShowHide;
} else {
scope.mode = 'encrypted';
scope.state._buttonText = vm.strings.get('REPLACE');
scope.state._placeholder = vm.strings.get('ENCRYPTED');
vm.toggle = vm.toggleRevertReplace;
scope.state._buttonText = vm.strings.get('REPLACE');
}
vm.check();
@ -41,15 +39,30 @@ function AtInputSecretController (baseInputController) {
scope.state._isBeingReplaced = !scope.state._isBeingReplaced;
vm.onRevertReplaceToggle();
if (scope.state._isBeingReplaced) {
if (scope.type !== 'password') {
vm.toggleShowHide();
}
}
};
vm.toggleShowHide = () => {
if (scope.type === 'password') {
scope.type = 'text';
scope.state._buttonText = vm.strings.get('HIDE');
scope.state._show = true;
scope.state._showHideText = vm.strings.get('HIDE');
} else {
scope.type = 'password';
scope.state._buttonText = vm.strings.get('SHOW');
scope.state._show = false;
scope.state._showHideText = vm.strings.get('SHOW');
}
};
vm.onLookupClick = () => {
if (scope.state._onInputLookup) {
const { id, label, required, type } = scope.state;
scope.state._onInputLookup({ id, label, required, type });
}
};
}

View File

@ -3,26 +3,67 @@
<at-input-label></at-input-label>
<div class="input-group">
<span class="input-group-btn at-InputGroup-button input-group-prepend">
<button class="btn at-ButtonHollow--white"
ng-class="{
'at-Input-button--fixed-xs': mode === 'input',
'at-Input-button--fixed-sm': mode === 'encrypted'
}"
ng-disabled="!state._enableToggle && (state._disabled || form.disabled)"
ng-click="vm.toggle()">
{{ state._buttonText }}
<span ng-if="state.tagMode" class="input-group-btn input-group-prepend">
<button
class="btn at-ButtonHollow--default at-Input-button"
ng-disabled="state._disabled || form.disabled"
ng-click="vm.onLookupClick()">
<i class="fa fa-search"></i>
</button>
</span>
<input type="{{ type }}"
<span
ng-if="state.tagMode && state.asTag"
ng-disabled="state._disabled || form.disabled"
class="form-control at-Input"
>
<div class="at-InputTagContainer">
<at-tag
ng-show="(!state._disabled) && state._tagValue"
icon="external"
tag="state._tagValue"
remove-tag="state._onRemoveTag(state)"
/>
<at-tag
ng-show="state._disabled && state._tagValue"
icon="external"
tag="state._tagValue"
/>
</div>
</span>
<input ng-if="!state.asTag" type="{{ type }}"
class="form-control at-Input"
ng-model="state[state._activeModel]"
ng-class="{ 'at-Input--rejected': state._rejected }"
ng-attr-maxlength="{{ state.max_length || undefined }}"
ng-attr-tabindex="{{ tab || undefined }}"
ng-attr-placeholder="{{state._placeholder || undefined }}"
ng-attr-placeholder="{{ state._promptOnLaunch ? '' : state._placeholder || undefined }}"
ng-change="vm.check()"
ng-disabled="state._disabled || form.disabled" />
ng-disabled="state._disabled || form.disabled"
/>
<span ng-show="mode == 'encrypted'" class="input-group-btn input-group-append">
<button
class="btn at-ButtonHollow--default at-Input-button"
ng-disabled="state.asTag || (!state._enableToggle && (state._disabled || form.disabled))"
ng-click="vm.toggleRevertReplace()"
aw-tool-tip="{{ state._buttonText }}"
data-tip-watch="state._buttonText"
data-placement="top">
<i ng-show="!state._isBeingReplaced" class="fa fa-undo"></i>
<i ng-show="state._isBeingReplaced" class="fa fa-undo fa-flip-horizontal"></i>
</button>
</span>
<span class="input-group-btn input-group-append">
<button
class="btn at-ButtonHollow--default at-Input-button"
ng-disabled="state.asTag || state._disabled || form.disabled"
ng-click="vm.toggleShowHide()"
aw-tool-tip="{{ state._showHideText }}"
data-tip-watch="state._showHideText"
data-placement="top">
<i ng-show="!state._show" class="fa fa-eye"></i>
<i ng-show="state._show" class="fa fa-eye-slash"></i>
</button>
</span>
</div>
<at-input-message></at-input-message>

View File

@ -5,7 +5,10 @@ function atInputTextLink (scope, element, attrs, controllers) {
const inputController = controllers[1];
if (scope.tab === '1') {
element.find('input')[0].focus();
const el = element.find('input')[0];
if (el) {
el.focus();
}
}
inputController.init(scope, element, formController);
@ -20,9 +23,15 @@ function AtInputTextController (baseInputController) {
baseInputController.call(vm, 'input', _scope_, element, form);
scope = _scope_;
vm.check();
scope.$watch('state._value', () => vm.check());
};
vm.onLookupClick = () => {
if (scope.state._onInputLookup) {
const { id, label, required, type } = scope.state;
scope.state._onInputLookup({ id, label, required, type });
}
};
}
AtInputTextController.$inject = ['BaseInputController'];

View File

@ -1,15 +1,51 @@
<div class="col-sm-{{::col}} at-InputContainer">
<div class="form-group at-u-flat">
<at-input-label></at-input-label>
<input type="text" class="form-control at-Input"
ng-class="{ 'at-Input--rejected': state._rejected }"
ng-model="state._value"
ng-attr-maxlength="{{ state.max_length || undefined }}"
ng-attr-tabindex="{{ tab || undefined }}"
ng-attr-placeholder="{{::state._placeholder || undefined }}"
ng-disabled="state._disabled || form.disabled" />
<div ng-if="state.tagMode" class="input-group">
<span class="input-group-btn input-group-prepend">
<button
class="btn at-ButtonHollow--default at-Input-button"
ng-disabled="state._disabled || form.disabled"
ng-click="vm.onLookupClick()">
<i class="fa fa-search"></i>
</button>
</span>
<span
ng-if="state.asTag"
ng-disabled="state._disabled || form.disabled"
class="form-control at-Input"
>
<div class="at-InputTagContainer">
<at-tag
ng-show="(!state._disabled) && state._tagValue"
icon="external"
tag="state._tagValue"
remove-tag="state._onRemoveTag(state)"
/>
<at-tag
ng-show="state._disabled && state._tagValue"
icon="external"
tag="state._tagValue"
/>
</div>
</span>
<input ng-if="!state.asTag" type="text" class="form-control at-Input"
ng-class="{ 'at-Input--rejected': state._rejected }"
ng-model="state._value"
ng-attr-maxlength="{{ state.max_length || undefined }}"
ng-attr-tabindex="{{ tab || undefined }}"
ng-attr-placeholder="{{::state._placeholder || undefined }}"
ng-disabled="state._disabled || form.disabled"
/>
</div>
<input ng-if="!state.tagMode" type="text" class="form-control at-Input"
ng-class="{ 'at-Input--rejected': state._rejected }"
ng-model="state._value"
ng-attr-maxlength="{{ state.max_length || undefined }}"
ng-attr-tabindex="{{ tab || undefined }}"
ng-attr-placeholder="{{::state._placeholder || undefined }}"
ng-disabled="state._disabled || form.disabled"
/>
<at-input-message></at-input-message>
</div>
</div>

View File

@ -45,6 +45,7 @@ function AtInputTextareaSecretController (baseInputController, eventService) {
};
vm.onIsBeingReplacedChanged = () => {
if (!scope.state) return;
if (!scope.state._touched) return;
vm.onRevertReplaceToggle();
@ -92,6 +93,13 @@ function AtInputTextareaSecretController (baseInputController, eventService) {
input.value = '';
});
};
vm.onLookupClick = () => {
if (scope.state._onInputLookup) {
const { id, label, required, type } = scope.state;
scope.state._onInputLookup({ id, label, required, type });
}
};
}
AtInputTextareaSecretController.$inject = [

View File

@ -1,32 +1,68 @@
<div class="col-sm-{{::col}} at-InputContainer">
<div class="form-group at-u-flat">
<at-input-label></at-input-label>
<div ng-class="{ 'input-group': state._edit }">
<div ng-if="state._edit" class="input-group-btn at-InputGroup-button input-group-prepend">
<button class="btn at-ButtonHollow--white at-Input-button--fixed-md"
ng-disabled="!state._enableToggle && (state._disabled || form.disabled)"
ng-click="state._isBeingReplaced = !state._isBeingReplaced">
{{ state._buttonText }}
<div class="input-group">
<div class="input-group-btn at-InputGroup-button input-group-prepend" ng-show="state.tagMode">
<button
class="btn at-ButtonHollow--white at-Input-button--long-sm"
ng-disabled="state._disabled || form.disabled"
ng-click="vm.onLookupClick()"
>
<i class="fa fa-search"></i>
</button>
</div>
<input
ng-show="ssh && !state.asTag"
ng-disabled="state._disabled || form.disabled"
class="at-InputFile--hidden"
ng-class="{'at-InputFile--drag': drag }"
type="file"
name="files"
/>
<div
ng-if="state.asTag"
ng-disabled="state._disabled || form.disabled"
class="form-control at-Input at-InputTaggedTextarea"
>
<div class="at-InputTagContainer">
<at-tag
ng-show="(!state._disabled) && state._tagValue"
icon="external"
tag="state._tagValue"
remove-tag="state._onRemoveTag(state)"
/>
<at-tag
ng-show="state._disabled && state._tagValue"
icon="external"
tag="state._tagValue"
/>
</div>
</div>
<textarea
class="form-control at-Input at-InputTextarea"
ng-show="!state.asTag"
ng-model="state[state._activeModel]"
ng-class="{ 'at-Input--rejected': state._rejected }"
ng-attr-rows="{{::state._rows || 6 }}"
ng-attr-maxlength="{{ state.max_length || undefined }}"
ng-attr-tabindex="{{ tab || undefined }}"
ng-attr-placeholder="{{state._placeholder || undefined }}"
ng-disabled="state._disabled || form.disabled"
/>
<div ng-if="state._edit" class="input-group-btn at-InputGroup-button input-group-append">
<button
class="btn at-ButtonHollow--white at-Input-button--long-sm"
ng-disabled="state.asTag || (!state._enableToggle && (state._disabled || form.disabled))"
ng-click="state._isBeingReplaced = !state._isBeingReplaced"
aw-tool-tip="{{ state._buttonText }}"
data-tip-watch="state._buttonText"
data-placement="top"
>
<i ng-show="!state._isBeingReplaced" class="fa fa-undo"></i>
<i ng-show="state._isBeingReplaced" class="fa fa-undo fa-flip-horizontal"></i>
</button>
</div>
<input ng-show="ssh"
ng-disabled="state._disabled || form.disabled"
class="at-InputFile--hidden"
ng-class="{'at-InputFile--drag': drag }"
type="file"
name="files" />
<textarea class="form-control at-Input at-InputTextarea"
ng-model="state[state._activeModel]"
ng-class="{ 'at-Input--rejected': state._rejected }"
ng-attr-rows="{{::state._rows || 6 }}"
ng-attr-maxlength="{{ state.max_length || undefined }}"
ng-attr-tabindex="{{ tab || undefined }}"
ng-attr-placeholder="{{state._placeholder || undefined }}"
ng-disabled="state._disabled || form.disabled" />
</textarea>
</div>
<at-input-message></at-input-message>
</div>
</div>

View File

@ -13,12 +13,21 @@ function atInputTextareaLink (scope, element, attrs, controllers) {
function AtInputTextareaController (baseInputController) {
const vm = this || {};
let scope;
vm.init = (scope, element, form) => {
baseInputController.call(vm, 'input', scope, element, form);
vm.init = (_scope_, element, form) => {
baseInputController.call(vm, 'input', _scope_, element, form);
scope = _scope_;
vm.check();
};
vm.onLookupClick = () => {
if (scope.state._onInputLookup) {
const { id, label, required, type } = scope.state;
scope.state._onInputLookup({ id, label, required, type });
}
};
}
AtInputTextareaController.$inject = ['BaseInputController'];

View File

@ -1,17 +1,45 @@
<div class="col-sm-{{::col}} at-InputContainer">
<div class="form-group at-u-flat">
<at-input-label></at-input-label>
<textarea class="form-control at-Input at-InputTextarea"
ng-model="state._value"
ng-class="{ 'at-Input--rejected': state._rejected }"
ng-attr-rows="{{::state._rows || 6 }}"
ng-attr-maxlength="{{ state.max_length || undefined }}"
ng-attr-tabindex="{{ tab || undefined }}"
ng-attr-placeholder="{{::state._placeholder || undefined }}"
ng-change="vm.check()"
ng-disabled="state._disabled || form.disabled" /></textarea>
<div ng-class="{ 'input-group': state.tagMode }">
<div class="input-group-btn at-InputGroup-button input-group-prepend" ng-show="state.tagMode">
<button class="btn at-ButtonHollow--white at-Input-button--long-sm"
ng-disabled="state._disabled || form.disabled"
ng-click="vm.onLookupClick()">
<i class="fa fa-search"></i>
</button>
</div>
<div
ng-show="state.asTag"
ng-disabled="state._disabled || form.disabled"
class="form-control at-Input at-InputTaggedTextarea"
>
<div class="at-InputTagContainer">
<at-tag
ng-show="(!state._disabled) && state._tagValue"
icon="external"
tag="state._tagValue"
remove-tag="state._onRemoveTag(state)"
/>
<at-tag
ng-show="state._disabled && state._tagValue"
icon="external"
tag="state._tagValue"
remove-tag="state._onRemoveTag(state)"
/>
</div>
</div>
<textarea class="form-control at-Input at-InputTextarea"
ng-show="!state.asTag"
ng-model="state._value"
ng-class="{ 'at-Input--rejected': state._rejected }"
ng-attr-rows="{{::state._rows || 6 }}"
ng-attr-maxlength="{{ state.max_length || undefined }}"
ng-attr-tabindex="{{ tab || undefined }}"
ng-attr-placeholder="{{::state._placeholder || undefined }}"
ng-change="vm.check()"
ng-disabled="state._disabled || form.disabled" />
</div>
<at-input-message></at-input-message>
</div>
</div>

View File

@ -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: '=',
},
};

View File

@ -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>

View File

@ -2,6 +2,6 @@
ng-attr-disabled="{{ state._disabled || undefined }}"
ng-class="{ 'at-Tab--active': state._active, 'at-Tab--disabled': state._disabled }"
ng-hide="{{ state._hide }}"
ng-click="vm.go()">
ng-click="state._go && vm.go();">
<ng-transclude></ng-transclude>
</button>

View File

@ -61,6 +61,10 @@
&--vault:before {
content: '\f187';
}
&--external:before {
content: '\f14c'
}
}
.TagComponent-button {

View File

@ -153,17 +153,22 @@ function httpPost (config = {}) {
const req = {
method: 'POST',
url: this.path,
data: config.data
data: config.data,
};
if (config.url) {
req.url = `${this.path}${config.url}`;
}
if (!('replace' in config)) {
config.replace = true;
}
return $http(req)
.then(res => {
this.model.GET = res.data;
if (config.replace) {
this.model.GET = res.data;
}
return res;
});
}

View File

@ -1,3 +1,4 @@
/* eslint camelcase: 0 */
const ENCRYPTED_VALUE = '$encrypted$';
let Base;
@ -29,12 +30,23 @@ function createFormSchema (method, config) {
return schema;
}
function assignInputGroupValues (inputs) {
function assignInputGroupValues (apiConfig, credentialType, sourceCredentials) {
let inputs = credentialType.get('inputs.fields');
if (!inputs) {
return [];
}
return inputs.map(input => {
if (this.has('credential_type')) {
if (credentialType.get('id') !== this.get('credential_type')) {
inputs.forEach(field => {
field.tagMode = this.isEditable() && credentialType.get('kind') !== 'external';
});
return inputs;
}
}
inputs = inputs.map(input => {
const value = this.get(`inputs.${input.id}`);
input._value = value;
@ -42,6 +54,46 @@ function assignInputGroupValues (inputs) {
return input;
});
if (credentialType.get('name') === 'Machine') {
const become = inputs.find((field) => field.id === 'become_method');
become._isDynamic = true;
become._choices = Array.from(apiConfig.become_methods, method => method[0]);
// Add the value to the choices if it doesn't exist in the preset list
if (become._value && become._value !== '') {
const optionMatches = become._choices
.findIndex((option) => option === become._value);
if (optionMatches === -1) {
become._choices.push(become._value);
}
}
}
const linkedFieldNames = (this.get('related.input_sources.results') || [])
.map(({ input_field_name }) => input_field_name);
inputs = inputs.map((field) => {
field.tagMode = this.isEditable() && credentialType.get('kind') !== 'external';
if (linkedFieldNames.includes(field.id)) {
field.tagMode = true;
field.asTag = true;
const { summary_fields } = this.get('related.input_sources.results')
.find(({ input_field_name }) => input_field_name === field.id);
field._tagValue = summary_fields.source_credential.name;
const { source_credential: { id } } = summary_fields;
const src = sourceCredentials.data.results.find(obj => obj.id === id);
const canRemove = _.get(src, ['summary_fields', 'user_capabilities', 'delete'], false);
if (!canRemove) {
field._disabled = true;
}
}
return field;
});
return inputs;
}
function setDependentResources (id) {

View File

@ -15,18 +15,18 @@ function categorizeByKind () {
}));
}
function mergeInputProperties () {
if (!this.has('inputs.fields')) {
function mergeInputProperties (key = 'fields') {
if (!this.has(`inputs.${key}`)) {
return undefined;
}
const required = this.get('inputs.required');
return this.get('inputs.fields').forEach((field, i) => {
return this.get(`inputs.${key}`).forEach((field, i) => {
if (!required || required.indexOf(field.id) === -1) {
this.set(`inputs.fields[${i}].required`, false);
this.set(`inputs.${key}[${i}].required`, false);
} else {
this.set(`inputs.fields[${i}].required`, true);
this.set(`inputs.${key}[${i}].required`, true);
}
});
}

View File

@ -61,7 +61,9 @@ function BaseStringService (namespace) {
this.CANCEL = t.s('CANCEL');
this.CLOSE = t.s('CLOSE');
this.SAVE = t.s('SAVE');
this.SELECT = t.s('SELECT');
this.OK = t.s('OK');
this.RUN = t.s('RUN');
this.NEXT = t.s('NEXT');
this.SHOW = t.s('SHOW');
this.HIDE = t.s('HIDE');
@ -73,6 +75,7 @@ function BaseStringService (namespace) {
this.COPY = t.s('COPY');
this.YES = t.s('YES');
this.CLOSE = t.s('CLOSE');
this.TEST = t.s('TEST');
this.SUCCESSFUL_CREATION = resource => t.s('{{ resource }} successfully created', { resource: $filter('sanitize')(resource) });
this.deleteResource = {

View File

@ -102,8 +102,9 @@ function SmartSearchController (
const rootField = termParts[0].split('.')[0].replace(/^-/, '');
const listName = $scope.list.name;
const baseRelatedTypePath = `models.${listName}.base.${rootField}.type`;
const relatedTypePath = `models.${listName}.related`;
const isRelatedSearchTermField = (_.includes($scope.models[listName].related, rootField));
const isRelatedSearchTermField = (_.includes(_.get($scope, relatedTypePath), rootField));
const isBaseModelRelatedSearchTermField = (_.get($scope, baseRelatedTypePath) === 'field');
return (isRelatedSearchTermField || isBaseModelRelatedSearchTermField);

View File

@ -21,11 +21,11 @@ const inputContainerElements = {
},
show: {
locateStrategy: 'xpath',
selector: `.//button[${normalized}='show']`
selector: './/i[contains(@class, "fa fa-eye")]'
},
hide: {
locateStrategy: 'xpath',
selector: `.//button[${normalized}='hide']`
selector: './/i[contains(@class, "fa fa-eye-slash")]'
},
on: {
locateStrategy: 'xpath',
@ -37,11 +37,11 @@ const inputContainerElements = {
},
replace: {
locateStrategy: 'xpath',
selector: `.//button[${normalized}='replace']`
selector: './/i[contains(@class, "fa fa-undo")]'
},
revert: {
locateStrategy: 'xpath',
selector: `.//button[${normalized}='revert']`
selector: './/i[contains(@class, "fa fa-undo fa-flip-horizontal")]'
}
};

View File

@ -146,19 +146,19 @@ module.exports = {
const input = `@${f.id}`;
group.expect.element('@show').visible;
group.expect.element('@hide').not.present;
group.expect.element('@hide').not.visible;
type.setValue(input, 'SECRET');
type.expect.element(input).text.equal('');
group.click('@show');
group.expect.element('@show').not.present;
group.expect.element('@show').not.visible;
group.expect.element('@hide').visible;
type.expect.element(input).value.contain('SECRET');
group.click('@hide');
group.expect.element('@show').visible;
group.expect.element('@hide').not.present;
group.expect.element('@hide').not.visible;
type.expect.element(input).text.equal('');
});
},

View File

@ -216,14 +216,22 @@ module.exports = {
edit.section.gce.expect.element('@sshKeyData').not.enabled;
},
'select and deselect credential file when replacing private key': client => {
const taggedTextArea = '.at-InputTaggedTextarea';
const textArea = '.at-InputTextarea';
const replace = 'button i[class="fa fa-undo"]';
const revert = 'button i[class="fa fa-undo fa-flip-horizontal"]';
const { gce } = credentials.section.edit.section.details.section;
gce.section.sshKeyData.waitForElementVisible('@replace');
gce.section.sshKeyData.click('@replace');
gce.waitForElementVisible(replace);
// eslint-disable-next-line prefer-arrow-callback
client.execute(function clickReplace (selector) {
document.querySelector(selector).click();
}, [replace]);
gce.expect.element('@email').enabled;
gce.expect.element('@project').enabled;
gce.expect.element('@sshKeyData').enabled;
gce.expect.element(textArea).enabled;
gce.expect.element(taggedTextArea).not.present;
gce.expect.element('@serviceAccountFile').enabled;
gce.section.sshKeyData.expect.element('@error').visible;
@ -247,10 +255,9 @@ module.exports = {
gce.section.sshKeyData.expect.element('@error').not.present;
gce.section.serviceAccountFile.expect.element('@error').not.present;
gce.section.sshKeyData.expect.element('@replace').not.present;
gce.section.sshKeyData.expect.element('@revert').present;
gce.section.sshKeyData.expect.element('@revert').not.enabled;
gce.expect.element(replace).not.present;
gce.expect.element(revert).present;
gce.expect.element('.input-group-append button').not.enabled;
gce.section.serviceAccountFile.click('form i[class*="trash"]');
gce.expect.element('@email').enabled;
@ -264,9 +271,11 @@ module.exports = {
gce.section.project.expect.element('@error').not.present;
gce.section.serviceAccountFile.expect.element('@error').not.present;
gce.section.sshKeyData.expect.element('@revert').enabled;
gce.section.sshKeyData.click('@revert');
gce.expect.element('.input-group-append button').enabled;
// eslint-disable-next-line prefer-arrow-callback
client.execute(function clickRevert (selector) {
document.querySelector(selector).click();
}, [revert]);
gce.expect.element('@email').enabled;
gce.expect.element('@project').enabled;

View File

@ -173,13 +173,13 @@ module.exports = {
machine.section.password.expect.element('@replace').visible;
machine.section.password.expect.element('@replace').enabled;
machine.section.password.expect.element('@revert').not.present;
machine.section.password.expect.element('@revert').not.visible;
machine.expect.element('@password').not.enabled;
machine.section.password.click('@replace');
machine.section.password.expect.element('@replace').not.present;
machine.section.password.expect.element('@replace').not.visible;
machine.section.password.expect.element('@revert').visible;
machine.expect.element('@password').enabled;

View File

@ -5,6 +5,7 @@ import {
} from '../fixtures';
import {
AWX_E2E_TIMEOUT_MEDIUM,
AWX_E2E_TIMEOUT_SHORT
} from '../settings';
@ -23,16 +24,19 @@ module.exports = {
Promise.all(resources)
.then(([jt, jt2]) => {
data = { jt, jt2 };
// We login and load the test page *after* data setup is finished.
// This helps avoid flakiness due to unpredictable spinners, etc.
// caused by first-time project syncs when creating the job templates.
client
.login()
.waitForAngular()
.resizeWindow(1200, 1000)
.useXpath()
.findThenClick(templatesNavTab)
.findThenClick('//*[@id="button-add"]')
.findThenClick('//a[@ui-sref="templates.addJobTemplate"]');
done();
});
client
.login()
.waitForAngular()
.resizeWindow(1200, 1000)
.useXpath()
.findThenClick(templatesNavTab)
.findThenClick('//*[@id="button-add"]')
.findThenClick('//a[@ui-sref="templates.addJobTemplate"]');
},
'Test max character limit when creating a job template': client => {
client
@ -49,18 +53,21 @@ module.exports = {
'//input[@name="project_name"]',
['test-form-error-handling-project', client.Keys.ENTER]
)
// clicked twice to make the element an active field
.findThenClick('//*[@id="select2-playbook-select-container"]')
// After the test sets a value for the project, a few seconds are
// needed while the UI fetches the playbooks names with a network
// call. There's no obvious dom element here to poll, so we wait a
// reasonably safe amount of time for the form to settle down
// before proceeding.
.pause(AWX_E2E_TIMEOUT_MEDIUM)
.waitForElementNotVisible('//*[contains(@class, "spinny")]')
.findThenClick('//*[@id="select2-playbook-select-container"]')
.findThenClick('//li[text()="hello_world.yml"]')
.findThenClick('//*[@id="job_template_save_btn"]')
.findThenClick('//*[@id="alert_ok_btn"]');
client.expect.element('//div[@id="job_template_name_group"]' +
'//div[@id="job_template-name-api-error"]').to.be.visible.before(AWX_E2E_TIMEOUT_SHORT);
'//div[@id="job_template-name-api-error"]').to.be.visible;
},
'Test duplicate template name handling when creating a job template': client => {
client
.waitForElementNotVisible('//*[@id="alert_ok_btn"]')

313
docs/credential_plugins.md Normal file
View 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`.

View File

@ -2,6 +2,7 @@ ansible-runner==1.3.1
appdirs==1.4.2
asgi-amqp==1.1.3
asgiref==1.1.2
azure-keyvault==1.1.0
boto==2.47.0
channels==1.1.8
celery==4.2.1
@ -39,7 +40,7 @@ python-radius==1.0
python3-saml==1.4.0
social-auth-core==3.0.0
social-auth-app-django==2.1.0
requests<2.16 # Older versions rely on certify
requests==2.21.0
requests-futures==0.9.7
service-identity==17.0.0
slackclient==1.1.2

View File

@ -4,6 +4,7 @@
#
# pip-compile requirements/requirements.in
#
adal==1.2.1 # via msrestazure
amqp==2.3.2 # via kombu
ansible-runner==1.3.1
appdirs==1.4.2
@ -14,13 +15,18 @@ asn1crypto==0.24.0 # via cryptography
attrs==18.2.0 # via automat, service-identity, twisted
autobahn==19.2.1 # via daphne
automat==0.7.0 # via twisted
azure-common==1.1.18 # via azure-keyvault
azure-keyvault==1.1.0
azure-nspkg==3.0.2 # via azure-keyvault
billiard==3.5.0.5 # via celery
boto==2.47.0
celery==4.2.1
certifi==2018.11.29 # via msrest, requests
cffi==1.12.1 # via cryptography
channels==1.1.8
chardet==3.0.4 # via requests
constantly==15.1.0 # via twisted
cryptography==2.5 # via pyopenssl
cryptography==2.5 # via adal, azure-keyvault, pyopenssl
daphne==1.3.0
defusedxml==0.5.0
django-auth-ldap==1.7.0
@ -40,11 +46,11 @@ djangorestframework-yaml==1.0.3
djangorestframework==3.7.7
future==0.16.0 # via django-radius
hyperlink==18.0.0 # via twisted
idna==2.8 # via hyperlink
idna==2.8 # via hyperlink, requests
incremental==17.5.0 # via twisted
inflect==2.1.0 # via jaraco.itertools
irc==16.2
isodate==0.6.0 # via python3-saml
isodate==0.6.0 # via msrest, python3-saml
jaraco.classes==2.0 # via jaraco.collections
jaraco.collections==2.0 # via irc, jaraco.text
jaraco.functools==2.0 # via irc, jaraco.text, tempora
@ -61,6 +67,8 @@ markdown==2.6.11
markupsafe==1.1.0 # via jinja2
more-itertools==6.0.0 # via irc, jaraco.functools, jaraco.itertools
msgpack-python==0.5.6 # via asgi-amqp
msrest==0.6.4 # via azure-keyvault, msrestazure
msrestazure==0.6.0 # via azure-keyvault
netaddr==0.7.19 # via pyrad
oauthlib==2.0.6 # via django-oauth-toolkit, requests-oauthlib, social-auth-core
ordereddict==1.1
@ -74,7 +82,7 @@ pyasn1==0.4.5 # via pyasn1-modules, python-ldap, service-identity
pycparser==2.19 # via cffi
pygerduty==0.37.0
pyhamcrest==1.9.0 # via twisted
pyjwt==1.7.1 # via social-auth-core, twilio
pyjwt==1.7.1 # via adal, social-auth-core, twilio
pyopenssl==19.0.0 # via service-identity
pyparsing==2.2.0
pyrad==2.1 # via django-radius
@ -89,8 +97,8 @@ python3-saml==1.4.0
pytz==2018.9 # via celery, django, irc, tempora, twilio
pyyaml==3.13 # via djangorestframework-yaml
requests-futures==0.9.7
requests-oauthlib==1.2.0 # via social-auth-core
requests[security]==2.15.1
requests-oauthlib==1.2.0 # via msrest, social-auth-core
requests[security]==2.21.0
service-identity==17.0.0
simplejson==3.16.0 # via uwsgitop
six==1.12.0 # via asgi-amqp, asgiref, autobahn, automat, cryptography, django-extensions, irc, isodate, jaraco.classes, jaraco.collections, jaraco.itertools, jaraco.logging, jaraco.stream, pygerduty, pyhamcrest, pyopenssl, pyrad, python-dateutil, python-memcached, slackclient, social-auth-app-django, social-auth-core, tacacs-plus, tempora, twilio, txaio, websocket-client
@ -103,6 +111,7 @@ twilio==6.10.4
twisted==18.9.0 # via daphne
txaio==18.8.1 # via autobahn
typing==3.6.6 # via django-extensions
urllib3==1.24.1 # via requests
uwsgi==2.0.17
uwsgitop==0.10.0
vine==1.2.0 # via amqp

View File

@ -114,6 +114,13 @@ setup(
'console_scripts': [
'awx-manage = awx:manage',
],
'awx.credential_plugins': [
'conjur = awx.main.credential_plugins.conjur:conjur_plugin',
'hashivault_kv = awx.main.credential_plugins.hashivault:hashivault_kv_plugin',
'hashivault_ssh = awx.main.credential_plugins.hashivault:hashivault_ssh_plugin',
'azure_kv = awx.main.credential_plugins.azure_kv:azure_keyvault_plugin',
'aim = awx.main.credential_plugins.aim:aim_plugin'
]
},
data_files = proc_data_files([
("%s" % homedir, ["config/wsgi.py",

View File

@ -2,3 +2,9 @@
tower-manage = awx:manage
awx-manage = awx:manage
[awx.credential_plugins]
conjur = awx.main.credential_plugins.conjur:conjur_plugin
hashivault_kv = awx.main.credential_plugins.hashivault:hashivault_kv_plugin
hashivault_ssh = awx.main.credential_plugins.hashivault:hashivault_ssh_plugin
azure_kv = awx.main.credential_plugins.azure_kv:azure_keyvault_plugin
aim = awx.main.credential_plugins.aim:aim_plugin

View 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"