From edad0dbfc40e7aa87722e4ef10c84eb56e60644d Mon Sep 17 00:00:00 2001 From: Tatiana Ovchinnikova Date: Wed, 11 Dec 2024 14:42:46 -0600 Subject: [PATCH] Improve two factor authentication config in Horizon User Credentials panel is added to Identity dashboard. Credentials table has Create, Update and Delete credential actions. Credentials tab is added to the user details for Identity -> Users table. Credentials panel is added to user settings. Change-Id: Icaabed327604d39b0bf6ac3e3cacf9c62f9e5d5d --- openstack_dashboard/api/keystone.py | 38 ++++++ .../identity/credentials/__init__.py | 0 .../dashboards/identity/credentials/forms.py | 115 ++++++++++++++++++ .../dashboards/identity/credentials/panel.py | 34 ++++++ .../dashboards/identity/credentials/tables.py | 103 ++++++++++++++++ .../templates/credentials/_create.html | 13 ++ .../templates/credentials/_update.html | 13 ++ .../templates/credentials/create.html | 7 ++ .../templates/credentials/update.html | 7 ++ .../dashboards/identity/credentials/tests.py | 39 ++++++ .../dashboards/identity/credentials/urls.py | 23 ++++ .../dashboards/identity/credentials/views.py | 107 ++++++++++++++++ .../identity/users/credentials/__init__.py | 0 .../identity/users/credentials/tables.py | 26 ++++ .../dashboards/identity/users/tabs.py | 33 ++++- .../settings/credentials/__init__.py | 0 .../dashboards/settings/credentials/forms.py | 33 +++++ .../dashboards/settings/credentials/panel.py | 25 ++++ .../dashboards/settings/credentials/tables.py | 42 +++++++ .../templates/credentials/_create.html | 13 ++ .../templates/credentials/_update.html | 13 ++ .../templates/credentials/create.html | 7 ++ .../templates/credentials/update.html | 7 ++ .../dashboards/settings/credentials/tests.py | 39 ++++++ .../dashboards/settings/credentials/urls.py | 23 ++++ .../dashboards/settings/credentials/views.py | 47 +++++++ .../dashboards/settings/dashboard.py | 2 +- .../_3100_identity_credentials_panel.py | 10 ++ .../test/test_data/keystone_data.py | 20 +++ 29 files changed, 837 insertions(+), 2 deletions(-) create mode 100644 openstack_dashboard/dashboards/identity/credentials/__init__.py create mode 100644 openstack_dashboard/dashboards/identity/credentials/forms.py create mode 100644 openstack_dashboard/dashboards/identity/credentials/panel.py create mode 100644 openstack_dashboard/dashboards/identity/credentials/tables.py create mode 100644 openstack_dashboard/dashboards/identity/credentials/templates/credentials/_create.html create mode 100644 openstack_dashboard/dashboards/identity/credentials/templates/credentials/_update.html create mode 100644 openstack_dashboard/dashboards/identity/credentials/templates/credentials/create.html create mode 100644 openstack_dashboard/dashboards/identity/credentials/templates/credentials/update.html create mode 100644 openstack_dashboard/dashboards/identity/credentials/tests.py create mode 100644 openstack_dashboard/dashboards/identity/credentials/urls.py create mode 100644 openstack_dashboard/dashboards/identity/credentials/views.py create mode 100644 openstack_dashboard/dashboards/identity/users/credentials/__init__.py create mode 100644 openstack_dashboard/dashboards/identity/users/credentials/tables.py create mode 100644 openstack_dashboard/dashboards/settings/credentials/__init__.py create mode 100644 openstack_dashboard/dashboards/settings/credentials/forms.py create mode 100644 openstack_dashboard/dashboards/settings/credentials/panel.py create mode 100644 openstack_dashboard/dashboards/settings/credentials/tables.py create mode 100644 openstack_dashboard/dashboards/settings/credentials/templates/credentials/_create.html create mode 100644 openstack_dashboard/dashboards/settings/credentials/templates/credentials/_update.html create mode 100644 openstack_dashboard/dashboards/settings/credentials/templates/credentials/create.html create mode 100644 openstack_dashboard/dashboards/settings/credentials/templates/credentials/update.html create mode 100644 openstack_dashboard/dashboards/settings/credentials/tests.py create mode 100644 openstack_dashboard/dashboards/settings/credentials/urls.py create mode 100644 openstack_dashboard/dashboards/settings/credentials/views.py create mode 100644 openstack_dashboard/enabled/_3100_identity_credentials_panel.py diff --git a/openstack_dashboard/api/keystone.py b/openstack_dashboard/api/keystone.py index dde58a02b3..3eafafa740 100644 --- a/openstack_dashboard/api/keystone.py +++ b/openstack_dashboard/api/keystone.py @@ -499,6 +499,44 @@ def user_update_tenant(request, user, project, admin=True): return manager.update(user, project=project) +@profiler.trace +def credential_create(request, user, type, blob, project=None): + manager = keystoneclient(request).credentials + return manager.create(user=user, type=type, blob=blob, project=project) + + +@profiler.trace +def credential_delete(request, credential_id): + manager = keystoneclient(request, admin=True).credentials + return manager.delete(credential_id) + + +@profiler.trace +def credential_get(request, credential_id, admin=True): + manager = keystoneclient(request, admin=admin).credentials + return manager.get(credential_id) + + +@profiler.trace +def credentials_list(request, user=None): + manager = keystoneclient(request).credentials + return manager.list(user=user) + + +@profiler.trace +def credential_update(request, credential_id, user, + type=None, blob=None, project=None): + manager = keystoneclient(request, admin=True).credentials + try: + return manager.update(credential=credential_id, + user=user, + type=type, + blob=blob, + project=project) + except keystone_exceptions.Conflict: + raise exceptions.Conflict() + + @profiler.trace def group_create(request, domain_id, name, description=None): manager = keystoneclient(request, admin=True).groups diff --git a/openstack_dashboard/dashboards/identity/credentials/__init__.py b/openstack_dashboard/dashboards/identity/credentials/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openstack_dashboard/dashboards/identity/credentials/forms.py b/openstack_dashboard/dashboards/identity/credentials/forms.py new file mode 100644 index 0000000000..5947e60fca --- /dev/null +++ b/openstack_dashboard/dashboards/identity/credentials/forms.py @@ -0,0 +1,115 @@ +# 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 gettext_lazy as _ + +from horizon import exceptions +from horizon import forms +from horizon import messages + +from openstack_dashboard.api import keystone + +# Available credential type choices +TYPE_CHOICES = ( + ('totp', _('TOTP')), + ('ec2', _('EC2')), + ('cert', _('cert')), +) + + +class CreateCredentialForm(forms.SelfHandlingForm): + user_name = forms.ThemableChoiceField(label=_('User')) + cred_type = forms.ThemableChoiceField(label=_('Type'), + choices=TYPE_CHOICES) + data = forms.CharField(label=_('Data')) + project = forms.ThemableChoiceField(label=_('Project'), required=False) + failure_url = 'horizon:identity:credentials:index' + + def __init__(self, request, *args, **kwargs): + super().__init__(request, *args, **kwargs) + + users = keystone.user_list(request) + user_choices = [(user.id, user.name) for user in users] + self.fields['user_name'].choices = user_choices + + project_choices = [('', _("Select a project"))] + projects, __ = keystone.tenant_list(request) + for project in projects: + if project.enabled: + project_choices.append((project.id, project.name)) + self.fields['project'].choices = project_choices + + def handle(self, request, data): + try: + params = { + 'user': data['user_name'], + 'type': data["cred_type"], + 'blob': data["data"], + } + if data["project"]: + params['project'] = data['project'] + new_credential = keystone.credential_create(request, **params) + messages.success( + request, _("User credential created successfully.")) + return new_credential + except Exception: + exceptions.handle(request, _('Unable to create user credential.')) + + +class UpdateCredentialForm(forms.SelfHandlingForm): + id = forms.CharField(label=_("ID"), widget=forms.HiddenInput) + user_name = forms.ThemableChoiceField(label=_('User')) + cred_type = forms.ThemableChoiceField(label=_('Type'), + choices=TYPE_CHOICES) + data = forms.CharField(label=_("Data")) + project = forms.ThemableChoiceField(label=_('Project'), required=False) + failure_url = 'horizon:identity:credentials:index' + + def __init__(self, request, *args, **kwargs): + super().__init__(request, *args, **kwargs) + + users = keystone.user_list(request) + user_choices = [(user.id, user.name) for user in users] + self.fields['user_name'].choices = user_choices + + initial = kwargs.get('initial', {}) + cred_type = initial.get('cred_type') + self.fields['cred_type'].initial = cred_type + + # Keystone does not change project to None. If this field is left as + # "Select a project", the project will not be changed. If this field + # is set to another project, the project will be changed. + project_choices = [('', _("Select a project"))] + projects, __ = keystone.tenant_list(request) + for project in projects: + if project.enabled: + project_choices.append((project.id, project.name)) + self.fields['project'].choices = project_choices + + project = initial.get('project_name') + self.fields['project'].initial = project + + def handle(self, request, data): + try: + params = { + 'user': data['user_name'], + 'type': data["cred_type"], + 'blob': data["data"], + } + params['project'] = data['project'] if data['project'] else None + + keystone.credential_update(request, data['id'], **params) + messages.success( + request, _("User credential updated successfully.")) + return True + except Exception: + exceptions.handle(request, _('Unable to update user credential.')) diff --git a/openstack_dashboard/dashboards/identity/credentials/panel.py b/openstack_dashboard/dashboards/identity/credentials/panel.py new file mode 100644 index 0000000000..5cfd8649ab --- /dev/null +++ b/openstack_dashboard/dashboards/identity/credentials/panel.py @@ -0,0 +1,34 @@ +# 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.utils.translation import gettext_lazy as _ + +import horizon + +from openstack_dashboard.api import keystone +from openstack_dashboard.dashboards.identity import dashboard + + +class CredentialsPanel(horizon.Panel): + name = _("User Credentials") + slug = 'credentials' + policy_rules = (("identity", "identity:list_credentials"),) + + def can_access(self, context): + if (settings.OPENSTACK_KEYSTONE_MULTIDOMAIN_SUPPORT and + not keystone.is_domain_admin(context['request'])): + return False + return super().can_access(context) + + +dashboard.Identity.register(CredentialsPanel) diff --git a/openstack_dashboard/dashboards/identity/credentials/tables.py b/openstack_dashboard/dashboards/identity/credentials/tables.py new file mode 100644 index 0000000000..45a21fcf5c --- /dev/null +++ b/openstack_dashboard/dashboards/identity/credentials/tables.py @@ -0,0 +1,103 @@ +# 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 import urls +from django.utils.translation import gettext_lazy as _ +from django.utils.translation import ngettext_lazy + +from horizon import tables + +from openstack_dashboard.api import keystone +from openstack_dashboard import policy + + +class CreateCredentialAction(tables.LinkAction): + name = "create" + verbose_name = _("Create User Credential") + url = 'horizon:identity:credentials:create' + classes = ("ajax-modal",) + policy_rules = (("identity", "identity:create_credential"),) + icon = "plus" + + +class UpdateCredentialAction(tables.LinkAction): + name = "update" + verbose_name = _("Edit User Credential") + url = 'horizon:identity:credentials:update' + classes = ("ajax-modal",) + policy_rules = (("identity", "identity:update_credential"),) + icon = "pencil" + + +class DeleteCredentialAction(tables.DeleteAction): + help_text = _("Deleted user credentials are not recoverable.") + policy_rules = (("identity", "identity:delete_credential"),) + + @staticmethod + def action_present(count): + return ngettext_lazy( + "Delete User Credential", + "Delete User Credentials", + count + ) + + @staticmethod + def action_past(count): + return ngettext_lazy( + "Deleted User Credential", + "Deleted User Credentials", + count + ) + + def delete(self, request, obj_id): + keystone.credential_delete(request, obj_id) + + +def get_user_link(datum): + if datum.user_id is not None: + return urls.reverse("horizon:identity:users:detail", + args=(datum.user_id,)) + + +def get_project_link(datum, request): + if policy.check((("identity", "identity:get_project"),), + request, target={"project": datum}): + if datum.project_id is not None: + return urls.reverse("horizon:identity:projects:detail", + args=(datum.project_id,)) + + +class CredentialsTable(tables.DataTable): + user_name = tables.WrappingColumn('user_name', + verbose_name=_('User'), + link=get_user_link) + cred_type = tables.WrappingColumn('type', verbose_name=_('Type')) + data = tables.Column('blob', verbose_name=_('Data')) + project_name = tables.WrappingColumn('project_name', + verbose_name=_('Project'), + link=get_project_link) + + def get_object_id(self, datum): + """Identifier of the credential.""" + return datum.id + + def get_object_display(self, datum): + """Display data of the credential.""" + return datum.blob + + class Meta(object): + name = "credentialstable" + verbose_name = _("User Credentials") + table_actions = (CreateCredentialAction, + DeleteCredentialAction) + row_actions = (UpdateCredentialAction, + DeleteCredentialAction) diff --git a/openstack_dashboard/dashboards/identity/credentials/templates/credentials/_create.html b/openstack_dashboard/dashboards/identity/credentials/templates/credentials/_create.html new file mode 100644 index 0000000000..12de21ceaf --- /dev/null +++ b/openstack_dashboard/dashboards/identity/credentials/templates/credentials/_create.html @@ -0,0 +1,13 @@ +{% extends "horizon/common/_modal_form.html" %} +{% load i18n %} + +{% block modal-body-right %} +

{% trans "Description:" %}

+

{% trans "Create a new user credential." %}

+

{% blocktrans trimmed %} + Project limits the scope of the credential. It is is mandatory if the credential type is EC2. + {% endblocktrans %}

+

{% blocktrans trimmed %} + If the credential type is EC2, credential data has to be {"access": <access>, "secret": <secret>}. + {% endblocktrans %}

+{% endblock %} diff --git a/openstack_dashboard/dashboards/identity/credentials/templates/credentials/_update.html b/openstack_dashboard/dashboards/identity/credentials/templates/credentials/_update.html new file mode 100644 index 0000000000..1f405874e6 --- /dev/null +++ b/openstack_dashboard/dashboards/identity/credentials/templates/credentials/_update.html @@ -0,0 +1,13 @@ +{% extends "horizon/common/_modal_form.html" %} +{% load i18n %} + +{% block modal-body-right %} +

{% trans "Description:" %}

+

{% trans "Edit the credential's details." %}

+

{% blocktrans trimmed %} + Project limits the scope of the credential. It is is mandatory if the credential type is EC2. + {% endblocktrans %}

+

{% blocktrans trimmed %} + If the credential type is EC2, credential data has to be {"access": <access>, "secret": <secret>}. + {% endblocktrans %}

+{% endblock %} diff --git a/openstack_dashboard/dashboards/identity/credentials/templates/credentials/create.html b/openstack_dashboard/dashboards/identity/credentials/templates/credentials/create.html new file mode 100644 index 0000000000..77b01297b0 --- /dev/null +++ b/openstack_dashboard/dashboards/identity/credentials/templates/credentials/create.html @@ -0,0 +1,7 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{% trans "Create User Credential" %}{% endblock %} + +{% block main %} + {% include 'identity/credentials/_create.html' %} +{% endblock %} diff --git a/openstack_dashboard/dashboards/identity/credentials/templates/credentials/update.html b/openstack_dashboard/dashboards/identity/credentials/templates/credentials/update.html new file mode 100644 index 0000000000..79256b563d --- /dev/null +++ b/openstack_dashboard/dashboards/identity/credentials/templates/credentials/update.html @@ -0,0 +1,7 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{% trans "Update User Credential" %}{% endblock %} + +{% block main %} + {% include 'identity/credentials/_update.html' %} +{% endblock %} diff --git a/openstack_dashboard/dashboards/identity/credentials/tests.py b/openstack_dashboard/dashboards/identity/credentials/tests.py new file mode 100644 index 0000000000..916438a811 --- /dev/null +++ b/openstack_dashboard/dashboards/identity/credentials/tests.py @@ -0,0 +1,39 @@ +# 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.urls import reverse + +from openstack_dashboard import api +from openstack_dashboard.test import helpers as test + +INDEX_URL = reverse('horizon:identity:credentials:index') +INDEX_VIEW_TEMPLATE = 'horizon/common/_data_table_view.html' + + +class UserCredentialsViewTests(test.TestCase): + + def _get_credentials(self, user): + credentials = [cred for cred in self.credentials.list() + if cred.user_id == user.id] + return credentials + + @test.create_mocks({api.keystone: ('credentials_list', + 'user_get', 'tenant_get')}) + def test_index(self): + user = self.users.list()[0] + self.mock_user_get.return_value = user + credentials = self._get_credentials(user) + self.mock_credentials_list.return_value = credentials + + res = self.client.get(INDEX_URL) + self.assertTemplateUsed(res, INDEX_VIEW_TEMPLATE) + self.assertCountEqual(res.context['table'].data, credentials) diff --git a/openstack_dashboard/dashboards/identity/credentials/urls.py b/openstack_dashboard/dashboards/identity/credentials/urls.py new file mode 100644 index 0000000000..896a42b23e --- /dev/null +++ b/openstack_dashboard/dashboards/identity/credentials/urls.py @@ -0,0 +1,23 @@ +# 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.urls import re_path + +from openstack_dashboard.dashboards.identity.credentials import views + + +urlpatterns = [ + re_path(r'^$', views.CredentialsView.as_view(), name='index'), + re_path(r'^(?P[^/]+)/update/$', + views.UpdateView.as_view(), name='update'), + re_path(r'^create/$', views.CreateView.as_view(), name='create'), +] diff --git a/openstack_dashboard/dashboards/identity/credentials/views.py b/openstack_dashboard/dashboards/identity/credentials/views.py new file mode 100644 index 0000000000..d377838304 --- /dev/null +++ b/openstack_dashboard/dashboards/identity/credentials/views.py @@ -0,0 +1,107 @@ +# 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.urls import reverse +from django.urls import reverse_lazy +from django.utils.translation import gettext_lazy as _ + +from horizon import exceptions +from horizon import forms +from horizon import tables +from horizon.utils import memoized + +from openstack_dashboard.api import keystone +from openstack_dashboard.dashboards.identity.credentials \ + import forms as credential_forms +from openstack_dashboard.dashboards.identity.credentials \ + import tables as credential_tables + + +@memoized.memoized +def get_project_name(request, project_id): + if project_id is not None: + project = keystone.tenant_get( + request, project_id, admin=False) + return project.name + return None + + +@memoized.memoized +def get_user_name(request, user_id): + if user_id is not None: + user = keystone.user_get(request, user_id, admin=False) + return user.name + return None + + +class CredentialsView(tables.DataTableView): + table_class = credential_tables.CredentialsTable + page_title = _("User Credentials") + policy_rules = (("identity", "identity:list_credentials"),) + + def get_data(self): + try: + credentials = keystone.credentials_list(self.request) + for cred in credentials: + cred.project_name = get_project_name( + self.request, cred.project_id) + cred.user_name = get_user_name(self.request, cred.user_id) + except Exception: + credentials = [] + exceptions.handle(self.request, + _('Unable to retrieve users credentials list.')) + return credentials + + +class UpdateView(forms.ModalFormView): + template_name = 'identity/credentials/update.html' + form_id = "update_credential_form" + form_class = credential_forms.UpdateCredentialForm + submit_label = _("Update User Credential") + submit_url = "horizon:identity:credentials:update" + success_url = reverse_lazy('horizon:identity:credentials:index') + page_title = _("Update User Credential") + + @memoized.memoized_method + def get_object(self): + try: + return keystone.credential_get( + self.request, self.kwargs['credential_id']) + except Exception: + redirect = reverse("horizon:identity:credentials:index") + exceptions.handle(self.request, + _('Unable to update user credential.'), + redirect=redirect) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + args = (self.get_object().id,) + context['submit_url'] = reverse(self.submit_url, args=args) + return context + + def get_initial(self): + credential = self.get_object() + return {'id': credential.id, + 'user_name': credential.user_id, + 'data': credential.blob, + 'cred_type': credential.type, + 'project_name': credential.project_id} + + +class CreateView(forms.ModalFormView): + template_name = 'identity/credentials/create.html' + form_id = "create_credential_form" + form_class = credential_forms.CreateCredentialForm + submit_label = _("Create User Credential") + submit_url = reverse_lazy("horizon:identity:credentials:create") + success_url = reverse_lazy('horizon:identity:credentials:index') + page_title = _("Create User Credential") diff --git a/openstack_dashboard/dashboards/identity/users/credentials/__init__.py b/openstack_dashboard/dashboards/identity/users/credentials/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openstack_dashboard/dashboards/identity/users/credentials/tables.py b/openstack_dashboard/dashboards/identity/users/credentials/tables.py new file mode 100644 index 0000000000..f32ff02d73 --- /dev/null +++ b/openstack_dashboard/dashboards/identity/users/credentials/tables.py @@ -0,0 +1,26 @@ +# 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 gettext_lazy as _ + +from horizon import tables + +from openstack_dashboard.dashboards.identity.credentials \ + import tables as credentials_tables + + +class CredentialsTable(credentials_tables.CredentialsTable): + user_name = tables.WrappingColumn('user_name', hidden=True) + + class Meta(object): + name = "credentialstable" + verbose_name = _("Credentials") diff --git a/openstack_dashboard/dashboards/identity/users/tabs.py b/openstack_dashboard/dashboards/identity/users/tabs.py index 4bfca87528..94aa2ee5ba 100644 --- a/openstack_dashboard/dashboards/identity/users/tabs.py +++ b/openstack_dashboard/dashboards/identity/users/tabs.py @@ -19,6 +19,10 @@ from horizon import exceptions from horizon import tabs from openstack_dashboard import api +from openstack_dashboard.dashboards.identity.credentials.views \ + import get_project_name +from openstack_dashboard.dashboards.identity.users.credentials \ + import tables as credentials_tables from openstack_dashboard.dashboards.identity.users.groups \ import tables as groups_tables from openstack_dashboard.dashboards.identity.users.role_assignments \ @@ -151,6 +155,33 @@ class GroupsTab(tabs.TableTab): return user_groups +def get_credentials(request, user): + user_credentials = [] + try: + user_credentials = api.keystone.credentials_list(request, user=user) + for cred in user_credentials: + cred.project_name = get_project_name(request, cred.project_id) + except Exception: + exceptions.handle( + request, _("Unable to retrieve the credentials of this user.")) + + return user_credentials + + +class CredentialsTab(tabs.TableTab): + """Credentials of the user.""" + table_classes = (credentials_tables.CredentialsTable,) + name = _("Credentials") + slug = "credentials" + template_name = "horizon/common/_detail_table.html" + preload = False + policy_rules = (("identity", "identity:list_credentials"),) + + def get_credentialstable_data(self): + user = self.tab_group.kwargs['user'] + return get_credentials(self.request, user) + + class UserDetailTabs(tabs.DetailTabsGroup): slug = "user_details" - tabs = (OverviewTab, RoleAssignmentsTab, GroupsTab,) + tabs = (OverviewTab, RoleAssignmentsTab, GroupsTab, CredentialsTab,) diff --git a/openstack_dashboard/dashboards/settings/credentials/__init__.py b/openstack_dashboard/dashboards/settings/credentials/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openstack_dashboard/dashboards/settings/credentials/forms.py b/openstack_dashboard/dashboards/settings/credentials/forms.py new file mode 100644 index 0000000000..a10a3817d4 --- /dev/null +++ b/openstack_dashboard/dashboards/settings/credentials/forms.py @@ -0,0 +1,33 @@ +# 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 gettext_lazy as _ + +from horizon import forms + +from openstack_dashboard.dashboards.identity.credentials \ + import forms as credentials_forms + + +class CreateCredentialForm(credentials_forms.CreateCredentialForm): + user_name = forms.CharField(label=_("User"), widget=forms.HiddenInput) + failure_url = 'horizon:settings:credentials:index' + + def __init__(self, request, *args, **kwargs): + super().__init__(request, *args, **kwargs) + + self.fields['user_name'].initial = request.user + + +class UpdateCredentialForm(credentials_forms.UpdateCredentialForm): + user_name = forms.CharField(label=_("User"), widget=forms.HiddenInput) + failure_url = 'horizon:settings:credentials:index' diff --git a/openstack_dashboard/dashboards/settings/credentials/panel.py b/openstack_dashboard/dashboards/settings/credentials/panel.py new file mode 100644 index 0000000000..6060dc1ffe --- /dev/null +++ b/openstack_dashboard/dashboards/settings/credentials/panel.py @@ -0,0 +1,25 @@ +# 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 gettext_lazy as _ + +import horizon + +from openstack_dashboard.dashboards.settings import dashboard + + +class CredentialsPanel(horizon.Panel): + name = _("User Credentials") + slug = 'credentials' + + +dashboard.Settings.register(CredentialsPanel) diff --git a/openstack_dashboard/dashboards/settings/credentials/tables.py b/openstack_dashboard/dashboards/settings/credentials/tables.py new file mode 100644 index 0000000000..45e9eb28a7 --- /dev/null +++ b/openstack_dashboard/dashboards/settings/credentials/tables.py @@ -0,0 +1,42 @@ +# 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 gettext_lazy as _ + +from horizon import tables + +from openstack_dashboard.dashboards.identity.credentials \ + import tables as credentials_tables + + +class CreateCredentialAction(credentials_tables.CreateCredentialAction): + url = 'horizon:settings:credentials:create' + + +class UpdateCredentialAction(credentials_tables.UpdateCredentialAction): + url = 'horizon:settings:credentials:update' + + +class DeleteCredentialAction(credentials_tables.DeleteCredentialAction): + pass + + +class CredentialsTable(credentials_tables.CredentialsTable): + user_name = tables.WrappingColumn('user_name', hidden=True) + + class Meta(object): + name = "credentialstable" + verbose_name = _("User Credentials") + table_actions = (CreateCredentialAction, + DeleteCredentialAction) + row_actions = (UpdateCredentialAction, + DeleteCredentialAction) diff --git a/openstack_dashboard/dashboards/settings/credentials/templates/credentials/_create.html b/openstack_dashboard/dashboards/settings/credentials/templates/credentials/_create.html new file mode 100644 index 0000000000..df1a8330ab --- /dev/null +++ b/openstack_dashboard/dashboards/settings/credentials/templates/credentials/_create.html @@ -0,0 +1,13 @@ +{% extends "horizon/common/_modal_form.html" %} +{% load i18n %} + +{% block modal-body-right %} +

{% trans "Description:" %}

+

{% trans "Create a new credential." %}

+

{% blocktrans trimmed %} + Project limits the scope of the credential. It is is mandatory if the credential type is EC2. + {% endblocktrans %}

+

{% blocktrans trimmed %} + If the credential type is EC2, credential data has to be {"access": <access>, "secret": <secret>}. + {% endblocktrans %}

+{% endblock %} diff --git a/openstack_dashboard/dashboards/settings/credentials/templates/credentials/_update.html b/openstack_dashboard/dashboards/settings/credentials/templates/credentials/_update.html new file mode 100644 index 0000000000..1f405874e6 --- /dev/null +++ b/openstack_dashboard/dashboards/settings/credentials/templates/credentials/_update.html @@ -0,0 +1,13 @@ +{% extends "horizon/common/_modal_form.html" %} +{% load i18n %} + +{% block modal-body-right %} +

{% trans "Description:" %}

+

{% trans "Edit the credential's details." %}

+

{% blocktrans trimmed %} + Project limits the scope of the credential. It is is mandatory if the credential type is EC2. + {% endblocktrans %}

+

{% blocktrans trimmed %} + If the credential type is EC2, credential data has to be {"access": <access>, "secret": <secret>}. + {% endblocktrans %}

+{% endblock %} diff --git a/openstack_dashboard/dashboards/settings/credentials/templates/credentials/create.html b/openstack_dashboard/dashboards/settings/credentials/templates/credentials/create.html new file mode 100644 index 0000000000..030ce3612e --- /dev/null +++ b/openstack_dashboard/dashboards/settings/credentials/templates/credentials/create.html @@ -0,0 +1,7 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{% trans "Create Credential" %}{% endblock %} + +{% block main %} + {% include 'settings/credentials/_create.html' %} +{% endblock %} diff --git a/openstack_dashboard/dashboards/settings/credentials/templates/credentials/update.html b/openstack_dashboard/dashboards/settings/credentials/templates/credentials/update.html new file mode 100644 index 0000000000..ccccacfdb4 --- /dev/null +++ b/openstack_dashboard/dashboards/settings/credentials/templates/credentials/update.html @@ -0,0 +1,7 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{% trans "Update Credential" %}{% endblock %} + +{% block main %} + {% include 'settings/credentials/_update.html' %} +{% endblock %} diff --git a/openstack_dashboard/dashboards/settings/credentials/tests.py b/openstack_dashboard/dashboards/settings/credentials/tests.py new file mode 100644 index 0000000000..219273d90a --- /dev/null +++ b/openstack_dashboard/dashboards/settings/credentials/tests.py @@ -0,0 +1,39 @@ +# 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.urls import reverse + +from openstack_dashboard import api +from openstack_dashboard.test import helpers as test + +INDEX_URL = reverse('horizon:settings:credentials:index') +INDEX_VIEW_TEMPLATE = 'horizon/common/_data_table_view.html' + + +class CredentialsViewTests(test.TestCase): + + def _get_credentials(self, user): + credentials = [cred for cred in self.credentials.list() + if cred.user_id == user.id] + return credentials + + @test.create_mocks({api.keystone: ('credentials_list', + 'user_get', 'tenant_get')}) + def test_index(self): + user = self.users.list()[0] + self.mock_user_get.return_value = user + credentials = self._get_credentials(user) + self.mock_credentials_list.return_value = credentials + + res = self.client.get(INDEX_URL) + self.assertTemplateUsed(res, INDEX_VIEW_TEMPLATE) + self.assertCountEqual(res.context['table'].data, credentials) diff --git a/openstack_dashboard/dashboards/settings/credentials/urls.py b/openstack_dashboard/dashboards/settings/credentials/urls.py new file mode 100644 index 0000000000..585ac4cb2f --- /dev/null +++ b/openstack_dashboard/dashboards/settings/credentials/urls.py @@ -0,0 +1,23 @@ +# 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.urls import re_path + +from openstack_dashboard.dashboards.settings.credentials import views + + +urlpatterns = [ + re_path(r'^$', views.CredentialsView.as_view(), name='index'), + re_path(r'^(?P[^/]+)/update/$', + views.UpdateView.as_view(), name='update'), + re_path(r'^create/$', views.CreateView.as_view(), name='create'), +] diff --git a/openstack_dashboard/dashboards/settings/credentials/views.py b/openstack_dashboard/dashboards/settings/credentials/views.py new file mode 100644 index 0000000000..7bbded7904 --- /dev/null +++ b/openstack_dashboard/dashboards/settings/credentials/views.py @@ -0,0 +1,47 @@ +# 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.urls import reverse_lazy +from django.utils.translation import gettext_lazy as _ + +from horizon import tables + +from openstack_dashboard.dashboards.identity.credentials \ + import views as credential_views +from openstack_dashboard.dashboards.identity.users.tabs \ + import get_credentials +from openstack_dashboard.dashboards.settings.credentials \ + import forms as credential_forms +from openstack_dashboard.dashboards.settings.credentials \ + import tables as credential_tables + + +class CredentialsView(tables.DataTableView): + table_class = credential_tables.CredentialsTable + page_title = _("Credentials") + policy_rules = (("identity", "identity:list_credentials"),) + + def get_data(self): + user = self.request.user + return get_credentials(self.request, user) + + +class UpdateView(credential_views.UpdateView): + form_class = credential_forms.UpdateCredentialForm + submit_url = "horizon:settings:credentials:update" + success_url = reverse_lazy('horizon:settings:credentials:index') + + +class CreateView(credential_views.CreateView): + form_class = credential_forms.CreateCredentialForm + submit_url = reverse_lazy("horizon:settings:credentials:create") + success_url = reverse_lazy('horizon:settings:credentials:index') diff --git a/openstack_dashboard/dashboards/settings/dashboard.py b/openstack_dashboard/dashboards/settings/dashboard.py index b1f1d006be..ed9f56039e 100644 --- a/openstack_dashboard/dashboards/settings/dashboard.py +++ b/openstack_dashboard/dashboards/settings/dashboard.py @@ -21,7 +21,7 @@ import horizon class Settings(horizon.Dashboard): name = _("Settings") slug = "settings" - panels = ('user', 'password', ) + panels = ('user', 'password', 'credentials', ) default_panel = 'user' def nav(self, context): diff --git a/openstack_dashboard/enabled/_3100_identity_credentials_panel.py b/openstack_dashboard/enabled/_3100_identity_credentials_panel.py new file mode 100644 index 0000000000..86798a2359 --- /dev/null +++ b/openstack_dashboard/enabled/_3100_identity_credentials_panel.py @@ -0,0 +1,10 @@ +# The slug of the panel to be added to HORIZON_CONFIG. Required. +PANEL = '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.credentials' + '.panel.CredentialsPanel') diff --git a/openstack_dashboard/test/test_data/keystone_data.py b/openstack_dashboard/test/test_data/keystone_data.py index 8aa9f15017..72407bfd6d 100644 --- a/openstack_dashboard/test/test_data/keystone_data.py +++ b/openstack_dashboard/test/test_data/keystone_data.py @@ -27,6 +27,7 @@ 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 +from keystoneclient.v3 import credentials from keystoneclient.v3 import domains from keystoneclient.v3 import groups from keystoneclient.v3 import role_assignments @@ -181,6 +182,7 @@ def data(TEST): TEST.idp_protocols = utils.TestDataContainer() TEST.application_credentials = utils.TestDataContainer() + TEST.credentials = utils.TestDataContainer() admin_role_dict = {'id': '1', 'name': 'admin'} @@ -540,3 +542,21 @@ def data(TEST): app_cred_detail = application_credentials.ApplicationCredential( None, app_cred_dict) TEST.application_credentials.add(app_cred_create, app_cred_detail) + + user_cred_dict = { + 'id': 'cred1', + 'user_id': '1', + 'type': 'totp', + 'blob': 'ONSWG4TFOQYTM43FMNZGK5BRGYFA', + 'project_id': 'project1' + } + user_cred_create = credentials.Credential(None, user_cred_dict) + user_cred_dict = { + 'id': 'cred2', + 'user_id': '2', + 'type': 'totp', + 'blob': 'ONSWG4TFOQYTM43FMNZGK5BRGYFA', + 'project_id': 'project2' + } + user_cred_detail = credentials.Credential(None, user_cred_dict) + TEST.credentials.add(user_cred_create, user_cred_detail)