diff --git a/lower-constraints.txt b/lower-constraints.txt
index 6f40a6d8c9..40549cee3a 100644
--- a/lower-constraints.txt
+++ b/lower-constraints.txt
@@ -99,7 +99,7 @@ pyScss==1.3.4
python-cinderclient==3.3.0
python-dateutil==2.5.3
python-glanceclient==2.8.0
-python-keystoneclient==3.8.0
+python-keystoneclient==3.15.0
python-mimeparse==1.6.0
python-neutronclient==6.7.0
python-novaclient==9.1.0
diff --git a/openstack_dashboard/api/keystone.py b/openstack_dashboard/api/keystone.py
index 2f338a17a8..7ae5bbe273 100644
--- a/openstack_dashboard/api/keystone.py
+++ b/openstack_dashboard/api/keystone.py
@@ -1082,3 +1082,37 @@ def protocol_delete(request, identity_provider, protocol):
def protocol_list(request, identity_provider):
manager = keystoneclient(request).federation.protocols
return manager.list(identity_provider)
+
+
+@profiler.trace
+def application_credential_list(request, filters=None):
+ user = request.user.id
+ manager = keystoneclient(request).application_credentials
+ return manager.list(user=user, **filters)
+
+
+@profiler.trace
+def application_credential_get(request, application_credential_id):
+ user = request.user.id
+ manager = keystoneclient(request).application_credentials
+ return manager.get(application_credential=application_credential_id,
+ user=user)
+
+
+@profiler.trace
+def application_credential_delete(request, application_credential_id):
+ user = request.user.id
+ manager = keystoneclient(request).application_credentials
+ return manager.delete(application_credential=application_credential_id,
+ user=user)
+
+
+@profiler.trace
+def application_credential_create(request, name, secret=None,
+ description=None, expires_at=None,
+ roles=None, unrestricted=False):
+ user = request.user.id
+ manager = keystoneclient(request).application_credentials
+ return manager.create(name=name, user=user, secret=secret,
+ description=description, expires_at=expires_at,
+ roles=roles, unrestricted=unrestricted)
diff --git a/openstack_dashboard/dashboards/identity/application_credentials/__init__.py b/openstack_dashboard/dashboards/identity/application_credentials/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/openstack_dashboard/dashboards/identity/application_credentials/forms.py b/openstack_dashboard/dashboards/identity/application_credentials/forms.py
new file mode 100644
index 0000000000..5b6286fc5c
--- /dev/null
+++ b/openstack_dashboard/dashboards/identity/application_credentials/forms.py
@@ -0,0 +1,125 @@
+# Copyright 2018 SUSE Linux GmbH
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+import datetime
+import logging
+
+from django.utils.translation import ugettext_lazy as _
+from django.views.decorators.debug import sensitive_variables
+
+from horizon import exceptions
+from horizon import forms
+from horizon import messages
+
+from openstack_dashboard import api
+
+LOG = logging.getLogger(__name__)
+
+
+class CreateApplicationCredentialForm(forms.SelfHandlingForm):
+ # Hide the domain_id and domain_name by default
+ name = forms.CharField(max_length=255, label=_("Name"))
+ description = forms.CharField(
+ widget=forms.widgets.Textarea(attrs={'rows': 4}),
+ label=_("Description"),
+ required=False)
+ secret = forms.CharField(max_length=255, label=_("Secret"), required=False)
+ expiration_date = forms.DateField(
+ widget=forms.widgets.DateInput(attrs={'type': 'date'}),
+ label=_("Expiration Date"),
+ required=False)
+ expiration_time = forms.TimeField(
+ widget=forms.widgets.TimeInput(attrs={'type': 'time'}),
+ label=_("Expiration Time"),
+ required=False)
+ roles = forms.MultipleChoiceField(
+ widget=forms.widgets.SelectMultiple(),
+ label=_("Roles"),
+ required=False)
+ unrestricted = forms.BooleanField(label=_("Unrestricted (dangerous)"),
+ required=False)
+
+ def __init__(self, request, *args, **kwargs):
+ self.next_view = kwargs.pop('next_view', None)
+ super(CreateApplicationCredentialForm, self).__init__(request, *args,
+ **kwargs)
+ role_list = self.request.user.roles
+ role_names = [role['name'] for role in role_list]
+ role_choices = ((name, name) for name in role_names)
+ self.fields['roles'].choices = role_choices
+
+ # We have to protect the entire "data" dict because it contains the
+ # secret string.
+ @sensitive_variables('data')
+ def handle(self, request, data):
+ try:
+ LOG.info('Creating application credential with name "%s"',
+ data['name'])
+
+ expiration = None
+ if data['expiration_date']:
+ if data['expiration_time']:
+ expiration_time = data['expiration_time']
+ else:
+ expiration_time = datetime.datetime.min.time()
+ expiration = datetime.datetime.combine(
+ data['expiration_date'], expiration_time)
+ else:
+ if data['expiration_time']:
+ expiration_time = data['expiration_time']
+ expiration_date = datetime.date.today()
+ expiration = datetime.datetime.combine(expiration_date,
+ expiration_time)
+ if data['roles']:
+ # the role list received from the form is a list of dicts
+ # encoded as strings
+ roles = [{'name': role_name} for role_name in data['roles']]
+ else:
+ roles = None
+ new_app_cred = api.keystone.application_credential_create(
+ request,
+ name=data['name'],
+ description=data['description'] or None,
+ secret=data['secret'] or None,
+ expires_at=expiration or None,
+ roles=roles,
+ unrestricted=data['unrestricted'] or None
+ )
+ self.request.session['application_credential'] = \
+ new_app_cred.to_dict()
+ request.method = 'GET'
+ return self.next_view.as_view()(request)
+ except exceptions.Conflict:
+ msg = (_('Application credential name "%s" is already used.')
+ % data['name'])
+ messages.error(request, msg)
+ except Exception:
+ exceptions.handle(request,
+ _('Unable to create application credential.'))
+
+
+class CreateSuccessfulForm(forms.SelfHandlingForm):
+ app_cred_id = forms.CharField(
+ label=_("ID"),
+ widget=forms.TextInput(attrs={'readonly': 'readonly'}))
+ app_cred_name = forms.CharField(
+ label=_("Name"),
+ widget=forms.TextInput(attrs={'readonly': 'readonly'}))
+ app_cred_secret = forms.CharField(
+ label=_("Secret"),
+ widget=forms.widgets.Textarea(
+ attrs={'rows': 3, 'readonly': 'readonly'}))
+
+ def handle(self, request, data):
+ pass
diff --git a/openstack_dashboard/dashboards/identity/application_credentials/panel.py b/openstack_dashboard/dashboards/identity/application_credentials/panel.py
new file mode 100644
index 0000000000..ab13db57a5
--- /dev/null
+++ b/openstack_dashboard/dashboards/identity/application_credentials/panel.py
@@ -0,0 +1,34 @@
+# Copyright 2018 SUSE Linux GmbH
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+from django.utils.translation import ugettext_lazy as _
+
+import horizon
+
+from openstack_dashboard.api import keystone
+
+
+class ApplicationCredentialsPanel(horizon.Panel):
+ name = _("Application Credentials")
+ slug = 'application_credentials'
+ policy_rules = (('identity', 'identity:list_application_credentials'),)
+
+ @staticmethod
+ def can_register():
+ return keystone.VERSIONS.active >= 3
+
+ def can_access(self, context):
+ request = context['request']
+ keystone_version = keystone.get_identity_api_version(request)
+ return keystone_version >= (3, 10)
diff --git a/openstack_dashboard/dashboards/identity/application_credentials/tables.py b/openstack_dashboard/dashboards/identity/application_credentials/tables.py
new file mode 100644
index 0000000000..b2ea49be81
--- /dev/null
+++ b/openstack_dashboard/dashboards/identity/application_credentials/tables.py
@@ -0,0 +1,84 @@
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+from django.utils.translation import ugettext_lazy as _
+from django.utils.translation import ungettext_lazy
+
+from horizon import tables
+
+from openstack_dashboard import api
+from openstack_dashboard import policy
+
+APP_CRED_DETAILS_LINK = "horizon:identity:application_credentials:detail"
+
+
+class CreateApplicationCredentialLink(tables.LinkAction):
+ name = "create"
+ verbose_name = _("Create Application Credential")
+ url = "horizon:identity:application_credentials:create"
+ classes = ("ajax-modal",)
+ icon = "plus"
+ policy_rules = (('identity', 'identity:create_application_credential'),)
+
+
+class DeleteApplicationCredentialAction(policy.PolicyTargetMixin,
+ tables.DeleteAction):
+ @staticmethod
+ def action_present(count):
+ return ungettext_lazy(
+ "Delete Application Credential",
+ "Delete Application Credentials",
+ count
+ )
+
+ @staticmethod
+ def action_past(count):
+ return ungettext_lazy(
+ "Deleted Application Credential",
+ "Deleted Application Credentialss",
+ count
+ )
+
+ policy_rules = (("identity", "identity:delete_application_credential"),)
+
+ def delete(self, request, obj_id):
+ api.keystone.application_credential_delete(request, obj_id)
+
+
+class ApplicationCredentialFilterAction(tables.FilterAction):
+ filter_type = "query"
+ filter_choices = (("name", _("Application Credential Name ="), True))
+
+
+def _role_names(obj):
+ return [role['name'].encode('utf-8') for role in obj.roles]
+
+
+class ApplicationCredentialsTable(tables.DataTable):
+ name = tables.WrappingColumn('name',
+ link=APP_CRED_DETAILS_LINK,
+ verbose_name=_('Name'))
+ project_id = tables.Column('project_id', verbose_name=_('Project ID'))
+ description = tables.Column('description',
+ verbose_name=_('Description'))
+ expires_at = tables.Column('expires_at',
+ verbose_name=_('Expiration'))
+ id = tables.Column('id', verbose_name=_('ID'))
+ roles = tables.Column(_role_names, verbose_name=_('Roles'))
+
+ class Meta(object):
+ name = "application_credentials"
+ verbose_name = _("Application Credentials")
+ row_actions = (DeleteApplicationCredentialAction,)
+ table_actions = (CreateApplicationCredentialLink,
+ DeleteApplicationCredentialAction,
+ ApplicationCredentialFilterAction,)
diff --git a/openstack_dashboard/dashboards/identity/application_credentials/templates/application_credentials/_create.html b/openstack_dashboard/dashboards/identity/application_credentials/templates/application_credentials/_create.html
new file mode 100644
index 0000000000..ac781abc52
--- /dev/null
+++ b/openstack_dashboard/dashboards/identity/application_credentials/templates/application_credentials/_create.html
@@ -0,0 +1,43 @@
+{% extends "horizon/common/_modal_form.html" %}
+{% load i18n %}
+
+{% block modal-body-right %}
+
{% trans "Description:" %}
+ {% trans "Create a new application credential." %}
+
+ {% blocktrans trimmed %}
+ The application credential will be created for the currently selected
+ project.
+ {% endblocktrans %}
+
+
+ {% blocktrans trimmed %}
+ You may provide your own secret, or one will be generated for you. Once your
+ application credential is created, the secret will be revealed once. If you
+ lose the secret, you will have to generate a new application credential.
+ {% endblocktrans %}
+
+
+ {% blocktrans trimmed %}
+ You may give the application credential an expiration. The expiration will
+ be in UTC. If you provide an expiration date with no expiration time, the
+ time will be assumed to be 00:00:00. If you provide an expiration time with
+ no expiration date, the date will be assumed to be today.
+ {% endblocktrans %}
+
+
+ {% blocktrans trimmed %}
+ You may select one or more roles for this application credential. If you do
+ not select any, all of the roles you have assigned on the current project
+ will be applied to the application credential.
+ {% endblocktrans %}
+
+
+ {% blocktrans trimmed %}
+ By default, for security reasons, application credentials are forbidden from
+ being used for creating additional application credentials or keystone
+ trusts. If your application credential needs to be able to perform these
+ actions, check "unrestricted".
+ {% endblocktrans %}
+
+{% endblock %}
diff --git a/openstack_dashboard/dashboards/identity/application_credentials/templates/application_credentials/_detail_overview.html b/openstack_dashboard/dashboards/identity/application_credentials/templates/application_credentials/_detail_overview.html
new file mode 100644
index 0000000000..c9e7296327
--- /dev/null
+++ b/openstack_dashboard/dashboards/identity/application_credentials/templates/application_credentials/_detail_overview.html
@@ -0,0 +1,39 @@
+{% load i18n %}
+
+
+
+ - {% trans "Name" %}
+ - {{ application_credential.name }}
+ - {% trans "ID" %}
+ - {{ application_credential.id }}
+ - {% trans "Project ID" %}
+ - {{ application_credential.project_id }}
+ - {% trans "Description" %}
+ - {{ application_credential.description | default:_("-") }}
+ - {% trans "Roles" %}
+ -
+
+
+
+ {% trans "Name" %} |
+ {% trans "ID" %} |
+ {% trans "Domain" %} |
+
+
+
+ {% for role in application_credential.roles %}
+
+ {{ role.name }} |
+ {{ role.id }} |
+ {{ role.domain_id | default:_("-") }} |
+ {% endfor %}
+
+
+
+ - {% trans "Expires" %}
+ - {{ application_credential.expires_at | default:_("-") }}
+ - {% trans "Unrestricted" %}
+ - {{ application_credential.unrestricted | yesno }}
+
+
+
diff --git a/openstack_dashboard/dashboards/identity/application_credentials/templates/application_credentials/_success.html b/openstack_dashboard/dashboards/identity/application_credentials/templates/application_credentials/_success.html
new file mode 100644
index 0000000000..1f56049c79
--- /dev/null
+++ b/openstack_dashboard/dashboards/identity/application_credentials/templates/application_credentials/_success.html
@@ -0,0 +1,35 @@
+{% extends "horizon/common/_modal_form.html" %}
+{% load i18n %}
+
+{% block header %}
+
+{% endblock %}
+{% block modal-body-right %}
+ {% trans "Your application credential" %}
+
+ {% blocktrans trimmed %}
+ Please capture the application credential ID and secret in order to
+ provide them to your application.
+ {% endblocktrans %}
+
+
+ {% blocktrans trimmed %}
+ The application credential secret will not be available after closing this
+ page, so you must capture it now or download it. If you lose this secret,
+ you must generate a new application credential.
+ {% endblocktrans %}
+
+{% endblock %}
+{% block modal-footer %}
+
+
+ {{ download_openrc_label }}
+
+
+
+ {{ download_clouds_yaml_label }}
+
+ {{ cancel_label }}
+{% endblock %}
diff --git a/openstack_dashboard/dashboards/identity/application_credentials/templates/application_credentials/clouds.yaml.template b/openstack_dashboard/dashboards/identity/application_credentials/templates/application_credentials/clouds.yaml.template
new file mode 100644
index 0000000000..1b07868adb
--- /dev/null
+++ b/openstack_dashboard/dashboards/identity/application_credentials/templates/application_credentials/clouds.yaml.template
@@ -0,0 +1,34 @@
+# This is a clouds.yaml file, which can be used by OpenStack tools as a source
+# of configuration on how to connect to a cloud. If this is your only cloud,
+# just put this file in ~/.config/openstack/clouds.yaml and tools like
+# python-openstackclient will just work with no further config. (You will need
+# to add your password to the auth section)
+# If you have more than one cloud account, add the cloud entry to the clouds
+# section of your existing file and you can refer to them by name with
+# OS_CLOUD={{ cloud_name }} or --os-cloud={{ cloud_name }}
+clouds:
+ {{ cloud_name }}:
+ {% if profile %}
+ profile: {{ profile }}
+ {% endif %}
+ auth:
+ {% if not profile %}
+ auth_url: {{ auth_url }}
+ {% endif %}
+ application_credential_id: "{{ application_credential_id }}"
+ application_credential_secret: "{{ application_credential_secret }}"
+ {% if not profile %}
+ {% if regions %}
+ regions:
+ {% for r in regions %}
+ - {{ r }}
+ {% endfor %}
+ {% else %}
+ {% if region %}
+ region_name: "{{ region }}"
+ {% endif %}
+ {% endif %}
+ interface: "{{ interface }}"
+ identity_api_version: 3
+ auth_type: "v3applicationcredential"
+ {% endif %}
diff --git a/openstack_dashboard/dashboards/identity/application_credentials/templates/application_credentials/create.html b/openstack_dashboard/dashboards/identity/application_credentials/templates/application_credentials/create.html
new file mode 100644
index 0000000000..81ca3396fb
--- /dev/null
+++ b/openstack_dashboard/dashboards/identity/application_credentials/templates/application_credentials/create.html
@@ -0,0 +1,7 @@
+{% extends 'base.html' %}
+{% load i18n %}
+{% block title %}{% trans "Create Application Credential" %}{% endblock %}
+
+{% block main %}
+ {% include 'identity/application_credentials/_create.html' %}
+{% endblock %}
diff --git a/openstack_dashboard/dashboards/identity/application_credentials/templates/application_credentials/detail.html b/openstack_dashboard/dashboards/identity/application_credentials/templates/application_credentials/detail.html
new file mode 100644
index 0000000000..1c342b4a3f
--- /dev/null
+++ b/openstack_dashboard/dashboards/identity/application_credentials/templates/application_credentials/detail.html
@@ -0,0 +1,17 @@
+{% extends 'base.html' %}
+{% load i18n %}
+
+{% block title %}{% trans "Application Credential Details" %}{% endblock %}
+
+{% block page_header %}
+ {% include "horizon/common/_detail_header.html" %}
+{% endblock %}
+
+{% block main %}
+
+
+ {% include "identity/application_credentials/_detail_overview.html" %}
+
+
+{% endblock %}
+
diff --git a/openstack_dashboard/dashboards/identity/application_credentials/templates/application_credentials/index.html b/openstack_dashboard/dashboards/identity/application_credentials/templates/application_credentials/index.html
new file mode 100644
index 0000000000..f81918853d
--- /dev/null
+++ b/openstack_dashboard/dashboards/identity/application_credentials/templates/application_credentials/index.html
@@ -0,0 +1,11 @@
+{% extends 'base.html' %}
+{% load i18n %}
+{% block title %}{% trans "Application Credentials" %}{% endblock %}
+
+{% block page_header %}
+ {% include "horizon/common/_domain_page_header.html" with title=page_title %}
+{% endblock page_header %}
+
+{% block main %}
+ {{ table.render }}
+{% endblock %}
diff --git a/openstack_dashboard/dashboards/identity/application_credentials/templates/application_credentials/openrc.sh.template b/openstack_dashboard/dashboards/identity/application_credentials/templates/application_credentials/openrc.sh.template
new file mode 100644
index 0000000000..670cc331c3
--- /dev/null
+++ b/openstack_dashboard/dashboards/identity/application_credentials/templates/application_credentials/openrc.sh.template
@@ -0,0 +1,9 @@
+{% load shellfilter %}#!/usr/bin/env bash
+
+export OS_AUTH_TYPE=v3applicationcredential
+export OS_AUTH_URL={{ auth_url }}
+export OS_IDENTITY_API_VERSION=3
+export OS_REGION_NAME="{{ region|shellfilter }}"
+export OS_INTERFACE={{ interface }}
+export OS_APPLICATION_CREDENTIAL_ID={{ application_credential_id }}
+export OS_APPLICATION_CREDENTIAL_SECRET={{ application_credential_secret }}
diff --git a/openstack_dashboard/dashboards/identity/application_credentials/templates/application_credentials/success.html b/openstack_dashboard/dashboards/identity/application_credentials/templates/application_credentials/success.html
new file mode 100644
index 0000000000..424e416a10
--- /dev/null
+++ b/openstack_dashboard/dashboards/identity/application_credentials/templates/application_credentials/success.html
@@ -0,0 +1,7 @@
+{% extends 'base.html' %}
+{% load i18n %}
+{% block title %}{% trans "Application Credential Details" %}{% endblock %}
+
+{% block main %}
+ {% include 'identity/application_credentials/_success.html' %}
+{% endblock %}
diff --git a/openstack_dashboard/dashboards/identity/application_credentials/tests.py b/openstack_dashboard/dashboards/identity/application_credentials/tests.py
new file mode 100644
index 0000000000..809ca235bb
--- /dev/null
+++ b/openstack_dashboard/dashboards/identity/application_credentials/tests.py
@@ -0,0 +1,137 @@
+# Copyright 2018 SUSE Linux GmbH
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+import mock
+import six
+
+from django.urls import reverse
+
+from openstack_dashboard import api
+from openstack_dashboard.test import helpers as test
+
+
+APP_CREDS_INDEX_URL = reverse('horizon:identity:application_credentials:index')
+
+
+class ApplicationCredentialViewTests(test.TestCase):
+ def test_application_credential_create_get(self):
+ url = reverse('horizon:identity:application_credentials:create')
+ res = self.client.get(url)
+
+ self.assertTemplateUsed(res,
+ 'identity/application_credentials/create.html')
+
+ @mock.patch.object(api.keystone, 'application_credential_create')
+ @mock.patch.object(api.keystone, 'application_credential_list')
+ def test_application_credential_create(self, mock_app_cred_list,
+ mock_app_cred_create):
+ new_app_cred = self.application_credentials.first()
+ mock_app_cred_create.return_value = new_app_cred
+ data = {
+ 'name': new_app_cred.name,
+ 'description': new_app_cred.description
+ }
+ api_data = {
+ 'name': new_app_cred.name,
+ 'description': new_app_cred.description,
+ 'expires_at': new_app_cred.expires_at,
+ 'roles': None,
+ 'unrestricted': None,
+ 'secret': None
+ }
+
+ url = reverse('horizon:identity:application_credentials:create')
+ res = self.client.post(url, data)
+
+ self.assertNoFormErrors(res)
+ self.assertEqual(res.status_code, 200)
+
+ mock_app_cred_create.assert_called_once_with(test.IsHttpRequest(),
+ **api_data)
+
+ @mock.patch.object(api.keystone, 'application_credential_get')
+ def test_application_credential_detail_get(self, mock_app_cred_get):
+ app_cred = self.application_credentials.list()[1]
+ mock_app_cred_get.return_value = app_cred
+
+ res = self.client.get(
+ reverse('horizon:identity:application_credentials:detail',
+ args=[app_cred.id]))
+
+ self.assertTemplateUsed(
+ res, 'identity/application_credentials/detail.html')
+ self.assertEqual(res.context['application_credential'].name,
+ app_cred.name)
+ mock_app_cred_get.assert_called_once_with(test.IsHttpRequest(),
+ six.text_type(app_cred.id))
+
+ @mock.patch.object(api.keystone, 'application_credential_get')
+ def test_application_credential_detail_get_with_exception(
+ self, mock_app_cred_get):
+ app_cred = self.application_credentials.list()[1]
+
+ mock_app_cred_get.side_effect = self.exceptions.keystone
+
+ url = reverse('horizon:identity:application_credentials:detail',
+ args=[app_cred.id])
+ res = self.client.get(url)
+ self.assertRedirectsNoFollow(res, APP_CREDS_INDEX_URL)
+ mock_app_cred_get.assert_called_once_with(test.IsHttpRequest(),
+ six.text_type(app_cred.id))
+
+ @mock.patch.object(api.keystone, 'application_credential_create')
+ @mock.patch.object(api.keystone, 'application_credential_list')
+ def test_application_credential_openrc(self, mock_app_cred_list,
+ mock_app_cred_create):
+
+ new_app_cred = self.application_credentials.first()
+ mock_app_cred_create.return_value = new_app_cred
+ data = {
+ 'name': new_app_cred.name,
+ 'description': new_app_cred.description
+ }
+ url = reverse('horizon:identity:application_credentials:create')
+ res = self.client.post(url, data)
+
+ download_url = (
+ 'horizon:identity:application_credentials:download_openrc'
+ )
+ url = reverse(download_url)
+ res = self.client.get(url)
+ self.assertEqual(res.status_code, 200)
+ self.assertTemplateUsed(
+ res, 'identity/application_credentials/openrc.sh.template')
+
+ @mock.patch.object(api.keystone, 'application_credential_create')
+ @mock.patch.object(api.keystone, 'application_credential_list')
+ def test_application_credential_cloudsyaml(self, mock_app_cred_list,
+ mock_app_cred_create):
+
+ new_app_cred = self.application_credentials.first()
+ mock_app_cred_create.return_value = new_app_cred
+ data = {
+ 'name': new_app_cred.name,
+ 'description': new_app_cred.description
+ }
+ url = reverse('horizon:identity:application_credentials:create')
+ res = self.client.post(url, data)
+
+ download_url = (
+ 'horizon:identity:application_credentials:download_clouds_yaml'
+ )
+ url = reverse(download_url)
+ res = self.client.get(url)
+ self.assertEqual(res.status_code, 200)
+ self.assertTemplateUsed(
+ res, 'identity/application_credentials/clouds.yaml.template')
diff --git a/openstack_dashboard/dashboards/identity/application_credentials/urls.py b/openstack_dashboard/dashboards/identity/application_credentials/urls.py
new file mode 100644
index 0000000000..3de9d13114
--- /dev/null
+++ b/openstack_dashboard/dashboards/identity/application_credentials/urls.py
@@ -0,0 +1,33 @@
+# Copyright 2018 SUSE Linux GmbH
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+
+from django.conf.urls import url
+
+from openstack_dashboard.dashboards.identity.application_credentials \
+ import views
+
+
+urlpatterns = [
+ url(r'^$', views.IndexView.as_view(), name='index'),
+ url(r'^create/$', views.CreateView.as_view(), name='create'),
+ url(r'^(?P[^/]+)/detail/$',
+ views.DetailView.as_view(), name='detail'),
+ url(r'^success/$',
+ views.CreateSuccessfulView.as_view(), name='success'),
+ url(r'^download_openrc/$',
+ views.download_rc_file, name='download_openrc'),
+ url(r'^download_clouds_yaml/$',
+ views.download_clouds_yaml_file, name='download_clouds_yaml'),
+]
diff --git a/openstack_dashboard/dashboards/identity/application_credentials/views.py b/openstack_dashboard/dashboards/identity/application_credentials/views.py
new file mode 100644
index 0000000000..3475158cfc
--- /dev/null
+++ b/openstack_dashboard/dashboards/identity/application_credentials/views.py
@@ -0,0 +1,195 @@
+# Copyright 2018 SUSE Linux GmbH
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+from django.conf import settings
+from django import http
+from django.template.loader import render_to_string
+from django.urls import reverse
+from django.urls import reverse_lazy
+from django.utils.translation import ugettext_lazy as _
+
+from openstack_auth import utils
+
+from horizon import exceptions
+from horizon import forms
+from horizon import tables
+from horizon.utils import memoized
+from horizon import views
+
+from openstack_dashboard import api
+
+from openstack_dashboard.dashboards.identity.application_credentials \
+ import forms as project_forms
+from openstack_dashboard.dashboards.identity.application_credentials \
+ import tables as project_tables
+
+INDEX_URL = "horizon:identity:application_credentials:index"
+
+
+class IndexView(tables.DataTableView):
+ table_class = project_tables.ApplicationCredentialsTable
+ template_name = 'identity/application_credentials/index.html'
+ page_title = _("Application Credentials")
+
+ def needs_filter_first(self, table):
+ return self._needs_filter_first
+
+ def get_data(self):
+ app_creds = []
+ filters = self.get_filters()
+
+ self._needs_filter_first = False
+
+ # If filter_first is set and if there are not other filters
+ # selected, then search criteria must be provided
+ # and return an empty list
+ filter_first = getattr(settings, 'FILTER_DATA_FIRST', {})
+ if (filter_first.get('identity.application_credentials', False) and
+ not filters):
+ self._needs_filter_first = True
+ return app_creds
+
+ try:
+ app_creds = api.keystone.application_credential_list(
+ self.request, filters=filters)
+ except Exception:
+ exceptions.handle(
+ self.request,
+ _('Unable to retrieve application credential list.'))
+
+ return app_creds
+
+
+class CreateView(forms.ModalFormView):
+ template_name = 'identity/application_credentials/create.html'
+ form_id = 'create_application_credential_form'
+ form_class = project_forms.CreateApplicationCredentialForm
+ submit_label = _("Create Application Credential")
+ submit_url = reverse_lazy(
+ 'horizon:identity:application_credentials:create')
+ success_url = reverse_lazy(
+ 'horizon:identity:application_credentials:success')
+ page_title = _("Create Application Credential")
+
+ def get_form_kwargs(self):
+ kwargs = super(CreateView, self).get_form_kwargs()
+ kwargs['next_view'] = CreateSuccessfulView
+ return kwargs
+
+
+class CreateSuccessfulView(forms.ModalFormView):
+ template_name = 'identity/application_credentials/success.html'
+ page_title = _("Your Application Credential")
+ form_class = project_forms.CreateSuccessfulForm
+ model_id = "create_application_credential_successful_modal"
+ success_url = reverse_lazy(
+ 'horizon:identity:application_credentials:index')
+ cancel_label = _("Close")
+ download_openrc_label = _("Download openrc file")
+ download_clouds_yaml_label = _("Download clouds.yaml")
+
+ def get_context_data(self, **kwargs):
+ context = super(CreateSuccessfulView, self).get_context_data(**kwargs)
+ context['download_openrc_label'] = self.download_openrc_label
+ context['download_clouds_yaml_label'] = self.download_clouds_yaml_label
+ context['download_openrc_url'] = reverse(
+ 'horizon:identity:application_credentials:download_openrc')
+ context['download_clouds_yaml_url'] = reverse(
+ 'horizon:identity:application_credentials:download_clouds_yaml')
+ return context
+
+ def get_initial(self):
+ app_cred = self.request.session['application_credential']
+ return {
+ 'app_cred_id': app_cred['id'],
+ 'app_cred_name': app_cred['name'],
+ 'app_cred_secret': app_cred['secret']
+ }
+
+
+def _get_context(request):
+ auth_url = api.base.url_for(request,
+ 'identity',
+ endpoint_type='publicURL')
+ auth_url, url_fixed = utils.fix_auth_url_version_prefix(auth_url)
+ interface = 'public'
+ region = getattr(request.user, 'services_region', '')
+ app_cred = request.session['application_credential']
+ context = dict(auth_url=auth_url,
+ interface=interface,
+ region=region,
+ application_credential_id=app_cred['id'],
+ application_credential_name=app_cred['name'],
+ application_credential_secret=app_cred['secret'])
+ return context
+
+
+def _render_attachment(filename, template, context, request):
+ content = render_to_string(template, context, request=request)
+ disposition = 'attachment; filename="%s"' % filename
+ response = http.HttpResponse(content, content_type="text/plain")
+ response['Content-Disposition'] = disposition.encode('utf-8')
+ response['Content-Length'] = str(len(response.content))
+ return response
+
+
+def download_rc_file(request):
+ context = _get_context(request)
+ template = 'identity/application_credentials/openrc.sh.template'
+ filename = 'app-cred-%s-openrc.sh' % context['application_credential_name']
+ response = _render_attachment(filename, template, context, request)
+ return response
+
+
+def download_clouds_yaml_file(request):
+ context = _get_context(request)
+ context['cloud_name'] = getattr(
+ settings, "OPENSTACK_CLOUDS_YAML_NAME", 'openstack')
+ context['profile'] = getattr(
+ settings, "OPENSTACK_CLOUDS_YAML_PROFILE", None)
+ context['regions'] = [
+ region_tuple[1] for region_tuple in getattr(
+ settings, "AVAILABLE_REGIONS", [])
+ ]
+ template = 'identity/application_credentials/clouds.yaml.template'
+ filename = 'clouds.yaml'
+ return _render_attachment(filename, template, context, request)
+
+
+class DetailView(views.HorizonTemplateView):
+ template_name = 'identity/application_credentials/detail.html'
+ page_title = "{{ application_credential.name }}"
+
+ def get_context_data(self, **kwargs):
+ context = super(DetailView, self).get_context_data(**kwargs)
+ app_cred = self.get_data()
+ table = project_tables.ApplicationCredentialsTable(self.request)
+ context["application_credential"] = app_cred
+ context["url"] = reverse(INDEX_URL)
+ context["actions"] = table.render_row_actions(app_cred)
+
+ return context
+
+ @memoized.memoized_method
+ def get_data(self):
+ try:
+ app_cred_id = self.kwargs['application_credential_id']
+ app_cred = api.keystone.application_credential_get(self.request,
+ app_cred_id)
+ except Exception:
+ exceptions.handle(
+ self.request,
+ _('Unable to retrieve application credential details.'),
+ redirect=reverse(INDEX_URL))
+ return app_cred
diff --git a/openstack_dashboard/enabled/_3090_identity_application_credentials_panel.py b/openstack_dashboard/enabled/_3090_identity_application_credentials_panel.py
new file mode 100644
index 0000000000..1bd15e42e2
--- /dev/null
+++ b/openstack_dashboard/enabled/_3090_identity_application_credentials_panel.py
@@ -0,0 +1,10 @@
+# The slug of the panel to be added to HORIZON_CONFIG. Required.
+PANEL = 'application_credentials'
+# The slug of the dashboard the PANEL associated with. Required.
+PANEL_DASHBOARD = 'identity'
+# The slug of the panel group the PANEL is associated with.
+PANEL_GROUP = 'default'
+
+# Python panel class of the PANEL to be added.
+ADD_PANEL = ('openstack_dashboard.dashboards.identity.application_credentials'
+ '.panel.ApplicationCredentialsPanel')
diff --git a/openstack_dashboard/test/settings.py b/openstack_dashboard/test/settings.py
index 0253cced86..bf4aff109d 100644
--- a/openstack_dashboard/test/settings.py
+++ b/openstack_dashboard/test/settings.py
@@ -328,4 +328,10 @@ TEST_GLOBAL_MOCKS_ON_PANELS = {
'.network_qos.panel.NetworkQoS.can_access'),
'return_value': True,
},
+ 'application_credentials': {
+ 'method': ('openstack_dashboard.dashboards.identity'
+ '.application_credentials.panel'
+ '.ApplicationCredentialsPanel.can_access'),
+ 'return_value': True,
+ },
}
diff --git a/openstack_dashboard/test/test_data/keystone_data.py b/openstack_dashboard/test/test_data/keystone_data.py
index f2eabd8a0a..eede16d757 100644
--- a/openstack_dashboard/test/test_data/keystone_data.py
+++ b/openstack_dashboard/test/test_data/keystone_data.py
@@ -23,6 +23,7 @@ from keystoneclient.v2_0 import ec2
from keystoneclient.v2_0 import roles
from keystoneclient.v2_0 import tenants
from keystoneclient.v2_0 import users
+from keystoneclient.v3 import application_credentials
from keystoneclient.v3.contrib.federation import identity_providers
from keystoneclient.v3.contrib.federation import mappings
from keystoneclient.v3.contrib.federation import protocols
@@ -135,6 +136,8 @@ def data(TEST):
TEST.idp_mappings = utils.TestDataContainer()
TEST.idp_protocols = utils.TestDataContainer()
+ TEST.application_credentials = utils.TestDataContainer()
+
admin_role_dict = {'id': '1',
'name': 'admin'}
admin_role = roles.Role(roles.RoleManager, admin_role_dict, loaded=True)
@@ -430,3 +433,42 @@ def data(TEST):
idp_protocol_dict_1,
loaded=True)
TEST.idp_protocols.add(idp_protocol)
+
+ app_cred_dict = {
+ 'id': 'ac1',
+ 'name': 'created',
+ 'secret': 'secret',
+ 'project': 'p1',
+ 'description': 'newly created application credential',
+ 'expires_at': None,
+ 'unrestricted': False,
+ 'roles': [
+ {'id': 'r1',
+ 'name': 'Member',
+ 'domain': None},
+ {'id': 'r2',
+ 'name': 'admin',
+ 'domain': None}
+ ]
+ }
+ app_cred_create = application_credentials.ApplicationCredential(
+ None, app_cred_dict)
+ app_cred_dict = {
+ 'id': 'ac2',
+ 'name': 'detail',
+ 'project': 'p1',
+ 'description': 'existing application credential',
+ 'expires_at': None,
+ 'unrestricted': False,
+ 'roles': [
+ {'id': 'r1',
+ 'name': 'Member',
+ 'domain': None},
+ {'id': 'r2',
+ 'name': 'admin',
+ 'domain': None}
+ ]
+ }
+ app_cred_detail = application_credentials.ApplicationCredential(
+ None, app_cred_dict)
+ TEST.application_credentials.add(app_cred_create, app_cred_detail)
diff --git a/releasenotes/notes/bp-application-credentials-26aa907271e467c2.yaml b/releasenotes/notes/bp-application-credentials-26aa907271e467c2.yaml
new file mode 100644
index 0000000000..c8e8b84ea6
--- /dev/null
+++ b/releasenotes/notes/bp-application-credentials-26aa907271e467c2.yaml
@@ -0,0 +1,6 @@
+---
+features:
+ - |
+ [`blueprint application-credentials `_]
+ Adds a new panel for creating, viewing, and deleting keystone application
+ credentials.
diff --git a/requirements.txt b/requirements.txt
index f3a13ed6a2..2f42b88b39 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -30,7 +30,7 @@ pymongo!=3.1,>=3.0.2 # Apache-2.0
pyScss!=1.3.5,>=1.3.4 # MIT License
python-cinderclient>=3.3.0 # Apache-2.0
python-glanceclient>=2.8.0 # Apache-2.0
-python-keystoneclient>=3.8.0 # Apache-2.0
+python-keystoneclient>=3.15.0 # Apache-2.0
python-neutronclient>=6.7.0 # Apache-2.0
python-novaclient>=9.1.0 # Apache-2.0
python-swiftclient>=3.2.0 # Apache-2.0