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" %}
+
+ + + + + + + + + + {% for role in application_credential.roles %} + + + + + {% endfor %} + +
{% trans "Name" %}{% trans "ID" %}{% trans "Domain" %}
{{ role.name }}{{ role.id }}{{ role.domain_id | default:_("-") }}
+
+
{% 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